Webhook Security
Because your webhook endpoint is publicly reachable, you must verify that each incoming request genuinely came from AfriRoute. Every webhook includes an HMAC-SHA256 signature in the X-AfriRoute-Signature header, computed over the raw request body using your webhook's signing secret.
🔐 The Signature Header
X-AfriRoute-Signature: t=1715337005,v1=4f3c2a1b9d8e7f60...
X-AfriRoute-Event-Id: evt_9f8a7b6c
| Element | Description |
|---|---|
t | Unix timestamp (seconds) when the signature was generated |
v1 | Hex HMAC-SHA256 of "{t}.{raw_body}" keyed with your whsec_... secret |
✅ Verification Steps
- Read the raw request body (before any JSON parsing/re-serialization).
- Parse
tandv1from the header. - Compute
HMAC_SHA256(secret, "{t}.{raw_body}"). - Compare it to
v1using a constant-time comparison. - Reject if
tis older than your tolerance window (e.g. 5 minutes) to block replays.
💻 Node.js (Express)
verify.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.AFRIROUTE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE = 5 * 60; // seconds
// IMPORTANT: capture the RAW body for signature verification.
app.use('/webhook', express.raw({ type: 'application/json' }));
function verify(rawBody, header) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('='))
);
const timestamp = parseInt(parts.t, 10);
// Reject stale requests (replay protection)
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(parts.v1, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook', (req, res) => {
const sig = req.headers['x-afriroute-signature'];
const raw = req.body.toString('utf8');
if (!sig || !verify(raw, sig)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(raw);
console.log('Verified event:', event.event);
res.sendStatus(200);
});
app.listen(3000);
💻 Python (Flask)
verify.py
import hashlib, hmac, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"whsec_..." # from environment in real code
TOLERANCE = 5 * 60
def verify(raw_body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > TOLERANCE:
return False
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
@app.route("/webhook", methods=["POST"])
def webhook():
sig = request.headers.get("X-AfriRoute-Signature", "")
if not verify(request.get_data(), sig):
abort(401)
# request.json is safe to use now
return "", 200
⚠️ Common Pitfalls
| Pitfall | Fix |
|---|---|
| Verifying re-serialized JSON | Use the raw request bytes — re-serialization changes whitespace and breaks the HMAC |
Plain == comparison | Use crypto.timingSafeEqual / hmac.compare_digest to avoid timing attacks |
| Ignoring the timestamp | Enforce a tolerance window to prevent replay of captured requests |
| Logging the secret | Keep whsec_... out of logs and source control |
💡 Best Practices
- Store the secret in an environment variable or vault, never in code.
- Reject before parsing — return
401the moment verification fails. - Rotate secrets on a schedule and immediately if a leak is suspected.
- Allowlist AfriRoute IPs as defense in depth, but treat the signature as the source of truth.
- Terminate TLS at your edge and reject any non-HTTPS traffic.
📚 Related Resources
Last Updated: May 2026 | Need help? [email protected]