Capay Global

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

POST/v1/webhook_endpoints

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

FieldTypeRequiredDescription
urlstringRequiredHTTPS URL that receives deliveries. Must start with https:// and be at most 2048 characters.
event_typesstring[] | nullOptionalArray of event types to subscribe to. Omit or send null to receive every type. Unknown types are rejected.
descriptionstringOptionalOptional label for your own reference, up to 300 characters.
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"
}

List your endpoints

GET/v1/webhook_endpoints

Returns 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
curl https://api.capayglobal.com/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response
{
  "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.

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
  }
}

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.

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

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.

node.js
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

GET/v1/webhook_endpoints/{id}/deliveries

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

FieldTypeRequiredDescription
idstring (path)RequiredThe endpoint id (we_...) or its raw uuid, in the path.
limitintegerOptionalNumber of delivery rows to return, 1 to 100. Defaults to 25.
curl
curl "https://api.capayglobal.com/v1/webhook_endpoints/we_7Qm2Kp0Zr41Xa/deliveries?limit=10" \
  -H "Authorization: Bearer sk_test_XXXXXXXXXXXX"
200 response
{
  "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).

StatusMeaning
PENDINGQueued and not yet attempted, or re-armed by a replay.
RETRYINGThe last attempt failed; another is scheduled at next_retry_at.
DELIVEREDYour endpoint answered with a 2xx status.
FAILEDAll 8 attempts were exhausted without a 2xx. Dead-lettered; replay the event to retry.
EXPIREDThe 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.completed

Rotating 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

POST/v1/events/{id}/replay

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

CodeHTTPWhen it is returned
invalid_api_key401Missing, malformed, revoked, expired, or wrong-environment key.
insufficient_scope403The key lacks the webhooks:read or webhooks:write scope this route needs.
validation_error422On 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_found404On deliveries: no endpoint with this id exists in this environment.
rate_limited429The app exceeded its request quota; back off and see the X-RateLimit-* headers.
internal_error500An unexpected server-side error. Safe to retry.