Webhook signature verification
Validate every incoming webhook with HMAC-SHA256 before processing. This guide is the single canonical reference for securing your webhook endpoint.
How It Works
Every outgoing webhook follows a four-step signing process. Your server mirrors steps 1–2 and compares the result.
Serialize Payload
ArosaPay serializes the event payload as canonical JSON.
{"event":"funds.secured","data":{...}}Compute HMAC-SHA256
The JSON body is signed using your webhook secret key.
HMAC-SHA256(secret, body) → hex digestSend with Signature Header
The hex digest is attached to the request.
X-Webhook-Signature: a1b2c3d4e5f6...Verify on Your Server
Recompute the hash and compare using constant-time comparison.
timingSafeEqual(expected, received)Implementation Examples
Complete, copy-paste-ready webhook handlers with signature verification, replay protection, and idempotency built in.
const crypto = require("crypto");
const express = require("express");
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use("/webhooks", express.raw({ type: "application/json" }));
const WEBHOOK_SECRET = process.env.AROSAPAY_WEBHOOK_SECRET;
function verifySignature(rawBody, signature) {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature, "utf8"),
Buffer.from(expected, "utf8")
);
}
app.post("/webhooks/arosapay", (req, res) => {
const signature = req.headers["x-webhook-signature"];
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body);
// Replay protection: reject events older than 5 minutes
const age = Date.now() - new Date(event.created_at).getTime();
if (age > 5 * 60 * 1000) {
return res.status(400).json({ error: "Event too old" });
}
// Idempotency: skip already-processed events
if (alreadyProcessed(event.id)) {
return res.json({ received: true });
}
switch (event.event) {
case "funds.secured":
confirmOrder(event.data.transaction_id);
break;
case "payout.sent":
recordPayout(event.data);
break;
}
markProcessed(event.id);
res.json({ received: true });
});Security Best Practices
Signature verification alone isn't enough. Follow these practices to build a production-hardened webhook endpoint.
Constant-Time Comparison
Never use === or == to compare signatures. Standard string comparison leaks timing information that attackers can exploit to forge valid signatures byte-by-byte.
Replay Protection
Check the created_at timestamp in every event. Reject any event older than 5 minutes to prevent attackers from replaying captured webhooks.
Idempotency
Store processed event IDs and skip duplicates. ArosaPay retries failed deliveries, so your endpoint may receive the same event multiple times.
Raw Body Parsing
Always compute the HMAC over the raw request bytes, not a parsed-then-re-serialized object. JSON re-serialization can change whitespace or key order.
Secret Rotation
Rotate your webhook secret periodically. During the transition window, accept signatures from both the old and new secrets to avoid dropped events.
Debugging & Testing
Tools and techniques to verify your implementation locally before going live.
Generate a Test Signature
# Generate a test signature for a sample payload
echo -n '{"event":"funds.secured","data":{"transaction_id":"txn_test123"}}' \
| openssl dgst -sha256 -hmac "whsec_your_test_secret" -hex
# Output: a1b2c3d4e5f6... (your expected signature)Test Your Endpoint
# Test your webhook endpoint with a signed payload
PAYLOAD='{"id":"evt_test_001","event":"funds.secured","created_at":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","data":{"transaction_id":"txn_test123","reference":"AP-TEST-0001","amount":5000,"currency":"KES","funding_source":"mpesa"}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "whsec_your_test_secret" -hex | awk '{print $2}')
curl -X POST https://your-domain.com/webhooks/arosapay \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIGNATURE" \
-d "$PAYLOAD"Common Mistakes
Re-serialized body
Parsing the JSON body and re-serializing it before computing the HMAC. Whitespace or key order changes will produce a different hash.
Wrong header name
Using "X-Arosapay-Signature" instead of "X-Webhook-Signature". Check the exact header name in your framework's request object.
Encoding mismatch
Computing the HMAC as base64 instead of hex, or vice versa. ArosaPay sends the hex-encoded digest.
Missing raw body middleware
Express.js parses JSON by default. You need express.raw() on your webhook route to get the original bytes.
Retry Policy
Failed deliveries are retried up to 5 times with exponential backoff. After all attempts are exhausted, the endpoint is flagged and an admin alert is generated.
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | 1 minute | 1m |
| 2 | 5 minutes | 6m |
| 3 | 30 minutes | 36m |
| 4 | 2 hours | 2h 36m |
| 5 | 24 hours | ~26h 36m |