Webhooks
A webhook is how the gateway tells your server that money moved. Register an https endpoint, verify the signature on every delivery, answer 2xx within 10 seconds, and make your handler safe to run twice.
Why webhooks, and what to expect
Mobile money is asynchronous. A collection is accepted in milliseconds and settles seconds or minutes later, after the payer approves the prompt. Polling GET /v1/collections/{id} works, but webhooks tell you the moment the state changes, including states that arrive long after your request returned.
Delivery is at-least-once and not ordered. The same event can arrive twice, and a late processing event can arrive after the completed one. Treat X-Capay-Event-Id as your idempotency key, trust the data object in the body once the signature verifies, and re-fetch the resource whenever you want a second opinion.
Register an endpoint
POST /v1/webhook_endpoints registers an https URL and, optionally, the event types you want. Omit event_types to receive every type. The response is the only place the whsec_ signing secret ever appears: store it in your secret manager before you do anything else. Each environment allows up to 16 active endpoints.
curl -X POST https://api.capayglobal.com/v1/webhook_endpoints \
-H "Authorization: Bearer sk_test_XXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/capay/webhooks",
"event_types": ["collection.completed", "collection.failed", "payout.completed"],
"description": "Order fulfilment listener"
}'{
"status": "success",
"data": {
"id": "we_7Qm2Kp0Zr41Xa",
"object": "webhook_endpoint",
"url": "https://example.com/capay/webhooks",
"event_types": ["collection.completed", "collection.failed", "payout.completed"],
"is_active": true,
"secret": "whsec_3f8a1c9d0e2b4f6a8c0d2e4f6a8c0d2e4f6a8c0d2e4f6a8c0d2e4f6a8c0d2e4f",
"livemode": false,
"created_at": "2026-08-29T12:00:00Z"
},
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}You can also add, disable and rotate endpoints in the dashboard under Developers > Webhooks: https://dashboard.capayglobal.com/developers/webhooks
The delivery
Every delivery is an HTTP POST with Content-Type: application/json and the User-Agent CapayGlobal-Webhooks/1.0. Three headers carry the identity of the event, and the body is the event envelope. Note that the delivered body has no resource_type or resource_id: those fields exist only on the rows returned by GET /v1/events.
POST /capay/webhooks HTTP/1.1 Content-Type: application/json User-Agent: CapayGlobal-Webhooks/1.0 X-Capay-Event-Id: evt_2n4Kq8Zr41Xa X-Capay-Event-Type: collection.completed X-Capay-Signature: t=1767225600,v1=6f8b4c2d9e0a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f0a2b4c6d8e0f1a3b5c
{
"id": "evt_2n4Kq8Zr41Xa",
"object": "event",
"type": "collection.completed",
"created_at": "2026-08-29T12:00:04Z",
"livemode": false,
"data": {
"id": "col_8fK2q0Zr41Xa",
"object": "collection",
"status": "completed",
"amount": "5000",
"currency": "XAF",
"country": "CM",
"method": "mobile_money",
"reason_code": null,
"merchant_reference": "order-10482",
"livemode": false
}
}Answer any 2xx within 10 seconds, then do the slow work. Anything else counts as a failure, and so does a redirect: the dispatcher does not follow 3xx responses. Your response body is stored truncated to 500 characters so you can read it back on the delivery row.
Verify the signature
X-Capay-Signature carries a timestamp and one or more signatures: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "{t}.{raw_body}")>. Compute the HMAC over the RAW request bytes, never over a re-serialized parse. Reject the delivery when the timestamp is more than 300 seconds away from your clock, compare in constant time, and accept if ANY v1 value matches: a second v1 is present while a secret rotation is pending.
# T is the t= value from X-Capay-Signature; delivery.json is the raw body as received.
T=1767225600
SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
{ printf '%s.' "$T"; cat delivery.json; } \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
# Compare the printed hex with each v1= value in the header (either may match
# during a secret rotation), and reject the delivery if |now - T| > 300 s.The official SDKs ship the same algorithm, already tested against the dispatcher. Prefer them over hand-written verification.
import { Webhooks } from '@capay/sdk';
// rawBody: the request body as a string or Uint8Array, BEFORE JSON parsing.
// constructEvent verifies then parses; it throws CapayWebhookError on a bad
// header, a timestamp outside the 300 s window, no matching v1, or bad JSON.
try {
const event = await Webhooks.constructEvent(
rawBody,
req.headers['x-capay-signature'],
process.env.CAPAY_WEBHOOK_SECRET,
);
res.status(200).end(); // acknowledge first, then do the slow work
await handle(event); // event.id is your idempotency key
} catch {
res.status(400).end();
}
// Boolean form (never throws for verification reasons):
const ok = await Webhooks.verify(rawBody, header, secret);
// During a secret rotation pass both secrets you hold:
await Webhooks.constructEvent(rawBody, header, [currentSecret, nextSecret]);Retries
A delivery that does not answer 2xx within 10 seconds is delivered up to 8 times in total (the first attempt plus 7 retries) with growing waits between attempts: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, about 45 hours in all.
| Attempt | Wait before it |
|---|---|
| 1 | 1m |
| 2 | 5m |
| 3 | 30m |
| 4 | 2h |
| 5 | 6h |
| 6 | 12h |
| 7 | 24h |
After the eighth failed attempt the delivery is marked FAILED and is not retried again. It stays on the endpoint's delivery list, and you can replay the event. Delivery statuses are PENDING, RETRYING, DELIVERED, FAILED and EXPIRED.
Auto-disable after 8 consecutive failures
An endpoint that fails 8 deliveries in a row is disabled automatically. One 2xx resets the counter, so this only fires on an endpoint that is genuinely down. When it happens the endpoint is flagged, a webhook_endpoint.disabled event is emitted to your other endpoints and to the /v1/events stream, and everything still queued for that endpoint is marked EXPIRED.
"is_active": false, "disabled_reason": "Disabled automatically after 8 consecutive delivery failures."
Fix the receiver, then re-enable the endpoint in the dashboard under Developers > Webhooks. Replay the events you missed afterwards: nothing is delivered automatically on re-enable.
Replay an event
POST /v1/events/{id}/replay (scope events:write) re-arms every delivery row of that event and fans it out to the endpoints that match it today. It answers { replayed, event, deliveries }. Events are retained for 90 days.
curl -X POST https://api.capayglobal.com/v1/events/evt_2n4Kq8Zr41Xa/replay \ -H "Authorization: Bearer sk_test_8fK2q0Zr41Xa"
Every delivery row in the dashboard has a Replay button, under Developers > Webhooks: https://dashboard.capayglobal.com/developers/webhooks
Write an idempotent consumer
Store the event id before you act on it, and make a repeat a no-op. Insert the id under a UNIQUE constraint inside the same transaction as your business change: a concurrent duplicate then fails on the insert instead of applying twice. Never let a late non-terminal event regress a resource you already marked terminal.
// Pseudocode for an idempotent receiver.
const eventId = req.headers['x-capay-event-id'];
if (await db.webhookEvents.exists(eventId)) return res.status(200).end(); // duplicate: no-op
await db.transaction(async (tx) => {
await tx.webhookEvents.insert({ id: eventId }); // UNIQUE: a concurrent duplicate fails here
const order = await tx.orders.findByReference(event.data.merchant_reference);
if (order.isTerminal) return; // completed/failed/... never regresses on a late event
await tx.orders.applyStatus(order, event.data.status);
});
res.status(200).end(); // 2xx within 10 s; do slow work after acknowledgingTwo sandbox scenarios exist to test exactly this: webhook.duplicate delivers completed twice, and webhook.out_of_order delivers processing after completed. Run both against your receiver before going live: Sandbox
Receive webhooks on localhost
The CLI forwards sandbox events to a local URL as real signed deliveries, so the verification code you ship runs unchanged in development: CLI
capay listen --forward-to http://localhost:3000/webhooks
Event catalog
These are all the event types the gateway publishes today. New types are added over time: ignore a type you do not know rather than failing on it.
collection.pendingcollection.processingcollection.completedcollection.failedcollection.expiredcollection.cancelledpayout.pendingpayout.enqueuedpayout.processingpayout.completedpayout.failedpayout.cancelledpayout.expiredpayout.reversedrefund.processingrefund.completedrefund.failedrefund.cancelledrefund.expiredtransaction.in_reconciliationtransaction.reconciledsettlement.completedbalance.lowprovider.status_changedwebhook_endpoint.disableddispute.openeddispute.evidence_submitteddispute.resolvedpayout_batch.completedpayout_batch.partially_failedpayout_batch.failedpayout_batch.cancelledsubscription.createdsubscription.activatedsubscription.renewedsubscription.past_duesubscription.pausedsubscription.resumedsubscription.cancelledsubscription.completedSource IP addresses
We do not publish a fixed source IP range for webhook deliveries. Authenticate every delivery with the signature, not by IP address, and do not build an allowlist around addresses you observe.
The IP allowlist you can set in the dashboard is a different, inbound control: it restricts which addresses may call the API with a given key. It has no effect on where deliveries come from.
Handle every status
Collections, payouts and refunds share one status vocabulary: initiated, pending, processing, enqueued, in_reconciliation, completed, failed, cancelled, expired, reversed. Handle each value you care about explicitly and make the default branch mean undetermined: keep polling GET /{id} or wait for the next webhook. A status your code does not know is never a failure and never a success.