Skip to main content

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
ElementDescription
tUnix timestamp (seconds) when the signature was generated
v1Hex HMAC-SHA256 of "{t}.{raw_body}" keyed with your whsec_... secret

✅ Verification Steps​

  1. Read the raw request body (before any JSON parsing/re-serialization).
  2. Parse t and v1 from the header.
  3. Compute HMAC_SHA256(secret, "{t}.{raw_body}").
  4. Compare it to v1 using a constant-time comparison.
  5. Reject if t is 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​

PitfallFix
Verifying re-serialized JSONUse the raw request bytes — re-serialization changes whitespace and breaks the HMAC
Plain == comparisonUse crypto.timingSafeEqual / hmac.compare_digest to avoid timing attacks
Ignoring the timestampEnforce a tolerance window to prevent replay of captured requests
Logging the secretKeep 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 401 the 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.

Last Updated: May 2026 | Need help? [email protected]