Webhook endpoints
Webhooks push events to your server as things happen: a collection completes, a payout fails, a balance runs low. Register an HTTPS endpoint, verify the signature on every delivery, and reconcile against the /v1/events stream. The same endpoints and envelope apply in sandbox and production; the key decides which.
How delivery works
When a resource changes state, we record an immutable event and fan it out to one delivery per active endpoint subscribed to that type. A background dispatcher POSTs the event to your URL, signs it, and retries failures on a fixed backoff. Deliveries are at-least-once and can arrive out of order: treat the event id as the idempotency key on your side and always fetch the resource, or trust the event data, rather than assuming ordering.
Register an endpoint
/v1/webhook_endpointsCreate an HTTPS endpoint scoped to the calling key's merchant and environment. The signing secret (whsec_...) is returned exactly once in this response and is never readable again: store it in your secret manager immediately. Up to 16 active endpoints are allowed per environment.
This call does not move money and does not take an Idempotency-Key header (unlike /v1/collections, /v1/payouts and /v1/refunds). It requires the webhooks:write scope.
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | Required | HTTPS URL that receives deliveries. Must start with https:// and be at most 2048 characters. |
| event_types | string[] | null | Optional | Array of event types to subscribe to. Omit or send null to receive every type. Unknown types are rejected. |
| description | string | Optional | Optional label for your own reference, up to 300 characters. |
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"
}List your endpoints
/v1/webhook_endpointsReturns every endpoint for the key's merchant and environment, newest first, including live health: is_active, consecutive_failures, and disabled_at / disabled_reason when an endpoint has been auto-disabled. The signing secret is never included here. Requires the webhooks:read scope.
curl https://api.capayglobal.com/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
{
"status": "success",
"data": {
"object": "list",
"data": [
{
"id": "we_7Qm2Kp0Zr41Xa",
"object": "webhook_endpoint",
"url": "https://example.com/capay/webhooks",
"description": "Order fulfilment listener",
"event_types": ["collection.completed", "collection.failed", "payout.completed"],
"is_active": true,
"consecutive_failures": 0,
"disabled_at": null,
"disabled_reason": null,
"livemode": false,
"created_at": "2026-08-29T12:00:00Z"
}
],
"has_more": false
},
"request_id": "9b2c1a08-3e44-4f6d-8a17-c05e9f21d3b6"
}The delivered event
Each delivery is a POST whose JSON body is an event envelope: a top-level event id (evt_...), object, type, created_at, livemode flag, and a data object carrying the resource snapshot. Money fields inside data are decimal strings with an explicit currency (XAF and XOF are zero-decimal, so 5000 XAF is the string "5000", not "50.00"). Reply with any 2xx status within 10 seconds; do slow work asynchronously. A non-2xx, a timeout, or a connection error is treated as a failed attempt and retried.
{
"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
}
}Every delivery carries these headers. X-Capay-Event-Id and X-Capay-Event-Type mirror the envelope; X-Capay-Signature carries the timestamp and one or more v1 signatures.
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
Verify the signature
The X-Capay-Signature header is t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "{t}.{raw_body}")>. Compute the HMAC over the timestamp, a literal dot, and the EXACT raw request body, using your endpoint secret as the key, then compare in constant time against each v1 value. Reject anything older than 300 seconds to stop replay. During a secret rotation the header carries two v1 values (current and next); accept the delivery if either matches. Never parse and re-serialize the body before verifying: JSON re-serialization changes the bytes and breaks the signature.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyCapaySignature(
rawBody: string, // the RAW request body, before any JSON parsing
header: string, // the X-Capay-Signature header value
secret: string, // your endpoint secret (whsec_...)
): boolean {
let t = "";
const v1s: string[] = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") t = value;
if (key === "v1") v1s.push(value); // two entries during secret rotation
}
if (!t || v1s.length === 0) return false;
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
return v1s.some((sig) => {
const sigBuf = Buffer.from(sig, "hex");
return sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf);
});
}Delivery attempts and retries
/v1/webhook_endpoints/{id}/deliveriesInspect the delivery history for one endpoint, newest first. Each row shows the attempt count against max_attempts (8), the status, the last HTTP code and error, the round-trip duration, and next_retry_at while a retry is scheduled. Requires the webhooks:read scope.
| Field | Type | Required | Description |
|---|---|---|---|
| id | string (path) | Required | The endpoint id (we_...) or its raw uuid, in the path. |
| limit | integer | Optional | Number of delivery rows to return, 1 to 100. Defaults to 25. |
curl "https://api.capayglobal.com/v1/webhook_endpoints/we_7Qm2Kp0Zr41Xa/deliveries?limit=10" \ -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
{
"status": "success",
"data": {
"object": "list",
"endpoint": "we_7Qm2Kp0Zr41Xa",
"data": [
{
"id": "d1e2f3a4-5b6c-7d8e-9f0a-1b2c3d4e5f60",
"object": "webhook_delivery",
"event": "evt_2n4Kq8Zr41Xa",
"event_type": "collection.completed",
"status": "DELIVERED",
"attempts": 1,
"max_attempts": 8,
"next_retry_at": "2026-08-29T12:00:04Z",
"last_status_code": 200,
"last_error": null,
"duration_ms": 142,
"created_at": "2026-08-29T12:00:04Z"
},
{
"id": "a9b8c7d6-5e4f-3a2b-1c0d-9e8f7a6b5c40",
"object": "webhook_delivery",
"event": "evt_5r7Lm9Zr41Xa",
"event_type": "payout.failed",
"status": "RETRYING",
"attempts": 3,
"max_attempts": 8,
"next_retry_at": "2026-08-29T14:05:00Z",
"last_status_code": 500,
"last_error": "endpoint answered HTTP 500",
"duration_ms": 87,
"created_at": "2026-08-29T12:10:00Z"
}
],
"has_more": false
},
"request_id": "5c7d9e10-2b34-4a56-9f78-e01a2b3c4d5e"
}A failed attempt moves the delivery to RETRYING and schedules the next attempt on a fixed backoff: 1m, 5m, 30m, 2h, 6h, 12h, 24h (about 45 hours in all). After 8 attempts without a 2xx the delivery is marked FAILED and dead-lettered: it is never retried automatically, but you can replay its event (see below).
| Status | Meaning |
|---|---|
| PENDING | Queued and not yet attempted, or re-armed by a replay. |
| RETRYING | The last attempt failed; another is scheduled at next_retry_at. |
| DELIVERED | Your endpoint answered with a 2xx status. |
| FAILED | All 8 attempts were exhausted without a 2xx. Dead-lettered; replay the event to retry. |
| EXPIRED | The endpoint was disabled before this delivery could be sent. |
Endpoint health is tracked separately. After 8 consecutive delivery failures the endpoint is automatically disabled (is_active becomes false with a disabled_reason), a webhook_endpoint.disabled event is emitted to your other endpoints and the /v1/events stream, and any deliveries still queued for it are marked EXPIRED. A single successful delivery resets the consecutive-failure counter to zero.
Event types
Subscribe an endpoint to a subset by passing event_types, or omit it (null) to receive every type. The vocabulary is a closed, additive list, so new types can appear over time: ignore any type you do not handle. These are all the types published today.
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.completedRotating the signing secret
Rotation is a two-step, zero-downtime flow managed in the dashboard under Developers, not through the /v1 API. Starting a rotation mints a new secret while keeping the old one: until you promote the new secret, every delivery is signed with BOTH, so the header carries two v1 values and verification against either succeeds. Deploy the new secret alongside the old, confirm deliveries verify, then promote to retire the old one.
Replaying an event
/v1/events/{id}/replayAny event in the /v1/events stream can be re-delivered with POST /v1/events/{id}/replay (events:write scope). Replay re-arms every delivery for that event, including ones already DELIVERED or FAILED, and fans it back out to your current active endpoints that match the type. Use it to recover from a dead-lettered delivery or a receiver outage. See the Events reference for the full stream API.
Errors
These endpoints return the standard error envelope. The codes below are the ones the webhook handlers raise, on top of 429 rate_limited from the shared quota gate.
| Code | HTTP | When it is returned |
|---|---|---|
| invalid_api_key | 401 | Missing, malformed, revoked, expired, or wrong-environment key. |
| insufficient_scope | 403 | The key lacks the webhooks:read or webhooks:write scope this route needs. |
| validation_error | 422 | On create: url is not https, an event type is unknown, event_types is not an array of strings, or the 16-endpoint limit is reached. |
| resource_not_found | 404 | On deliveries: no endpoint with this id exists in this environment. |
| rate_limited | 429 | The app exceeded its request quota; back off and see the X-RateLimit-* headers. |
| internal_error | 500 | An unexpected server-side error. Safe to retry. |