Skip to main content

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:

HeaderPurpose
X-Webhook-SignatureHMAC_SHA256(secret, raw_body) — proves the payload wasn't tampered with
X-Webhook-TimestampUnix seconds when this delivery attempt was made
X-Webhook-Signature-V2HMAC_SHA256(secret, "{timestamp}." + raw_body) — binds the timestamp into the signature itself
X-Webhook-EventThe event type (drift, geofence_breach, delivery.confirmed, consent_revoked)
X-Webhook-DeliveryA unique ID per delivery attempt — use it to dedupe

Before you begin​

  • Generate a long, random secret per 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-Signature alone or adding X-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 underlying drift event 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 own timestamp field 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 checking age(now, X-Webhook-Timestamp) < 300s gets 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 with POST /v2/webhooks/retry/{deliveryId}.
  • Return a 4xx deliberately 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 a 5xx or 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-Delivery before 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 timestamp field to sequence events, not arrival order or X-Webhook-Delivery.
  • For state that only makes sense as a sequence (e.g. a borrower's zone history), key it by event timestamp and 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=7 for 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-V2 is verified with a timing-safe comparison and a timestamp-age check (~5 minutes) — signature validity alone isn't replay protection.
  • The endpoint returns 2xx immediately and processes asynchronously — no synchronous work that could time out sits before the response.
  • Every handler dedupes on X-Webhook-Delivery against 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.