Webhooks
Webhooks push events to your server in real time, so you don't have to poll. They're the backbone of Location Monitoring: when a customer drifts out of their zone, AfriHex POSTs the event to your endpoint.
Events
| Event | Fired when |
|---|---|
drift | A monitored customer moves out of their registered zone |
geofence_breach | A ping lands inside a defined geofence (see Monitoring) |
delivery.confirmed | A proof-of-delivery receipt is confirmed |
consent_revoked | A customer revokes location consent |
Configure a webhook
curl -X POST "https://api.afrihex.com/v2/webhooks/configure" \
-H "X-API-Key: $AFRIHEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://bank.example.com/webhooks/afrihex",
"secret": "a-long-random-secret",
"event_types": ["drift", "geofence_breach"]
}'
If event_types is omitted it defaults to ["drift"]. The secret is stored
encrypted and is used to sign every delivery.
Delivery format
Each delivery is a POST with Content-Type: application/json. Example drift
payload:
{
"event": "drift",
"customer_id": "cust_123",
"home_hex": "AF-GH-7-ABC123...",
"current_hex": "AF-GH-7-XYZ789...",
"distance_hops": 3,
"timestamp": "2026-08-06T08:00:00Z"
}
Headers
Every delivery includes:
| Header | Meaning |
|---|---|
X-Webhook-Event | The event type |
X-Webhook-Delivery | The delivery ID (for retries and debugging) |
X-Webhook-Signature | HMAC-SHA256 of the raw body, hex-encoded, keyed with your secret |
X-Webhook-Timestamp | Unix timestamp (seconds) of the delivery attempt |
X-Webhook-Signature-V2 | HMAC-SHA256 of "{timestamp}." + raw_body, hex-encoded — see Replay protection |
Verify the signature
Compute HMAC_SHA256(secret, raw_body) and hex-encode it, then compare with
X-Webhook-Signature. Use a constant-time comparison:
import {createHmac, timingSafeEqual} from 'node:crypto'
export function verify(body: Buffer, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex')
const a = Buffer.from(signature)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
Always verify the signature before trusting an event. Consider also verifying
that X-Webhook-Event matches the payload you expected.
Replay protection
X-Webhook-Signature alone never expires — a captured signed payload stays
valid forever, so anyone who intercepts (or is handed) one old delivery can
replay it and it will still verify. X-Webhook-Signature-V2 closes that gap
by binding the timestamp into the signed material instead of just sending it
alongside: it's HMAC_SHA256(secret, "{timestamp}." + raw_body), not the raw
body alone (Stripe's convention, if you've integrated that before).
import {createHmac, timingSafeEqual} from 'node:crypto'
export function verifyV2(body: Buffer, timestamp: string, signatureV2: string, secret: string): boolean {
// The actual replay defense — reject anything more than 5 minutes stale,
// regardless of whether the signature checks out.
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (age > 300) return false
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; adopt it whenever convenient, there's no migration deadline.
Delivery reliability
- Deliveries are retried with exponential backoff (attempts: immediate, then
increasing delays) until the endpoint returns a
2xx. - Inspect delivery history with
GET /v2/webhooks/deliveries?days=7. - Re-send a failed delivery with
POST /v2/webhooks/retry/{deliveryId}.
Best practices
- Respond fast. Return
2xxas soon as you've accepted the event; process asynchronously. A slow or failed response triggers retries. - Idempotency. Your handler may receive the same delivery more than once —
deduplicate on
X-Webhook-Delivery. - Return
4xxto skip retries only if you truly don't want the event.