Capay Global

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
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"
  }'
201 response
{
  "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.

delivery headers
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
delivery body
{
  "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.

curl
# 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.

Node
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.

AttemptWait before it
11m
25m
330m
42h
56h
612h
724h

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.

endpoint after auto-disable
"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
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.

idempotent receiver
// 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 acknowledging

Two 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
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.completed

Source 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.