Webhook Security
The business problem
A webhook endpoint is an unauthenticated-by-default door into your system
unless you actively verify every request against it — anyone who finds the
URL can POST a fake drift or geofence_breach event unless you check the
signature. This guide is the production-hardening pass on top of
Webhooks: verify correctly, survive retries and duplicates,
and don't assume an ordering guarantee that doesn't exist.
What AfriHex returns
Every delivery carries two independent signature schemes and a set of tracing headers:
| Header | Purpose |
|---|---|
X-Webhook-Signature | HMAC_SHA256(secret, raw_body) — proves the payload wasn't tampered with |
X-Webhook-Timestamp | Unix seconds when this delivery attempt was made |
X-Webhook-Signature-V2 | HMAC_SHA256(secret, "{timestamp}." + raw_body) — binds the timestamp into the signature itself |
X-Webhook-Event | The event type (drift, geofence_breach, delivery.confirmed, consent_revoked) |
X-Webhook-Delivery | A unique ID per delivery attempt — use it to dedupe |
Before you begin
- Generate a long, random
secretper webhook endpoint at configure time (POST /v2/webhooks/configure) — don't reuse a secret across environments or across a signing scheme used elsewhere in your stack. - Decide now whether you're verifying
X-Webhook-Signaturealone or addingX-Webhook-Signature-V2— see Why V2 exists. New integrations should verify V2 from day one; there's no reason to start with the weaker guarantee. - Your endpoint must respond fast (see Retry behavior) —
design it to enqueue and return
2xx, not to process synchronously.
Copyable request
What your endpoint actually receives — verify this exact shape before
trusting anything in data:
POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
X-Webhook-Event: drift
X-Webhook-Delivery: 3f9a1c20-...
X-Webhook-Timestamp: 1788349907
X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c592...
X-Webhook-Signature-V2: 8f14e45fceea167a5a36dedd4bea2543...
{"event":"drift","customer_id":"cust_123","home_hex":"AF-GH-7-ABC...","current_hex":"AF-GH-7-XYZ...","distance_hops":3,"timestamp":"2026-09-02T08:00:00Z"}
Full example response
Your handler's only obligation on receipt is to return 2xx fast:
HTTP/1.1 200 OK
Anything else (4xx, 5xx, timeout) is treated as delivery failure and queues a retry — see Retry behavior.
Field-by-field explanation
X-Webhook-Delivery— unique per attempt, not per logical event. A retried delivery of the same underlyingdriftevent gets a new delivery ID each attempt; dedupe on it anyway, since your handler may still receive and successfully process the same attempt twice under load (e.g. your own infrastructure retries a slow acknowledgement).X-Webhook-Timestamp— when this delivery attempt was made, not when the underlying event occurred. The payload's owntimestampfield is the event time; don't conflate the two when reconstructing event order.X-Webhook-Signature-V2's replay defense isn't the hash alone — it's the combination of the hash and rejecting anything with a stale timestamp. Verifying the signature without checkingage(now, X-Webhook-Timestamp) < 300sgets you tamper detection but not replay protection.
Why V2 exists
X-Webhook-Signature alone never expires — a captured valid signature stays
valid forever, so anyone who intercepts, or is handed, one old delivery can
replay it indefinitely and it will still verify. X-Webhook-Signature-V2
binds the timestamp into the signed material instead of sending it
alongside, so the signature itself is only valid for one moment:
import {createHmac, timingSafeEqual} from 'node:crypto'
export function verifyV2(body: Buffer, timestamp: string, signatureV2: string, secret: string): boolean {
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (age > 300) return false // the actual replay defense
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(body)
.digest('hex')
const a = Buffer.from(signatureV2)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
X-Webhook-Signature is unchanged and still sent on every delivery —
existing integrations that only check it keep working exactly as before. V2
is additive, not a migration with a deadline; full code for both schemes is
in Webhooks — Verify the signature.
Retry behavior
- Failed deliveries (no
2xx, or a timeout) are retried with exponential backoff — an immediate retry, then increasing delays. - Inspect delivery history with
GET /v2/webhooks/deliveries?days=7; re-send a specific failed delivery manually withPOST /v2/webhooks/retry/{deliveryId}. - Return a
4xxdeliberately only when you want to stop retries for an event you've determined you'll never be able to process (e.g. malformed data from your own end) — a transient failure should surface as a5xxor a timeout instead, so AfriHex keeps retrying.
Idempotent receivers
Because the same event can arrive more than once (a retry, or your own infra's at-least-once delivery), every handler must be safe to run twice on the same event:
- Dedupe on
X-Webhook-Deliverybefore applying any side effect (updating a loan's status, notifying a collections officer) — check-then-act against a store of already-processed delivery IDs, not against your business state alone (business state can coincidentally look "already applied" for unrelated reasons). - Design the side effect itself to be idempotent where you can — "set
borrower status to
drifted" is naturally idempotent; "increment a drift counter" is not, and needs the dedupe check to matter.
Event ordering
AfriHex does not document a delivery-order guarantee, and retries make out-of-order delivery a real possibility — an event that needed three retries can arrive after a later event that succeeded on the first attempt. Don't build handler logic that assumes "the most recently received event is the most recent event":
- Use the payload's own
timestampfield to sequence events, not arrival order orX-Webhook-Delivery. - For state that only makes sense as a sequence (e.g. a borrower's zone
history), key it by event
timestampand reconcile out-of-order arrivals, rather than blindly overwriting "current state" with whatever arrived last. - If you need the authoritative current state rather than a stream of
events, prefer polling the source of truth (
GET /v2/collections/locate?loan_id=,GET /v2/monitoring/drift) over trusting webhook arrival order for anything decision-critical.
Error and edge cases
- Signature verification fails on an otherwise-legitimate-looking payload. Almost always a body-parsing issue — you're hashing a re-serialized/re-encoded body instead of the exact raw bytes received. Verify against the raw request body before any JSON parsing touches it.
- A duplicate delivery changes your data twice. Missing or broken dedupe — see Idempotent receivers above.
- You stopped getting webhooks and don't know why. Check
GET /v2/webhooks/deliveries?days=7for a run of failed attempts before assuming AfriHex-side silence — a wrong secret, a changed endpoint URL, or a firewall change on your end will look identical to "nothing fired."
Production checklist
-
X-Webhook-Signature-V2is verified with a timing-safe comparison and a timestamp-age check (~5 minutes) — signature validity alone isn't replay protection. - The endpoint returns
2xximmediately and processes asynchronously — no synchronous work that could time out sits before the response. - Every handler dedupes on
X-Webhook-Deliveryagainst a persisted store of processed IDs, not against inferred business state. - Sequencing logic uses the payload's
timestamp, never receipt order. - Delivery history (
GET /v2/webhooks/deliveries) is monitored proactively, so a silently-failing endpoint is caught before a customer notices a missed alert.
Related endpoints / next guide
- Webhooks — full event catalogue, configuration, and delivery format reference.
- Rate Limits, Retries, and Production Reliability — the same idempotency and backoff principles applied to your outbound API calls, not just inbound webhooks.
- Consent-Based Location Monitoring — where
drift,geofence_breach, andconsent_revokedevents originate.