Skip to content

API Gateway

Webhooks

A webhook subscribes an https endpoint of yours to one or more payment topics of your account. Create one with POST /webhooks, passing callback_url and topics (required); the response carries the shared secret once: store it, it is never shown again (rotate_secret issues a new one).

One subscription has one secret and one circuit breaker, whatever its topics. Two subscriptions of the same account may not deliver the same topic to the same callback_url (409 conflict), and an account holds at most 20 subscriptions (422 webhook_limit_reached; disabled ones count, deleted ones don’t).

TopicFires when
payments.created, payments.signedthe payment is created / signed by the buyer
payments.authorized, payments.chargedfunds are escrowed / paid directly
payments.captured, payments.voided, payments.released, payments.refundedthe matching operation confirms
payments.authorization_expiringonce, ahead of authorization_expiry, while there is still something to capture
payments.expiredthe authorization lapsed uncaptured
payments.failedany attempt died — read last_error_code to decide what to do
payments.disputed, payments.dispute_closedthe buyer raised / lowered the dispute flag

payments.failed is one topic for every way an attempt fails: submission_timeout means it was never mined (retry), event_not_found means the reported hash did something else, and a contract or token error code means a real rejection (don’t retry blindly).

Every delivery is a POST with the same JSON shape (abbreviated here — payment carries the full set of fields listed under WebhookTopic):

{
"id": "0f1e2d3c-4b5a-4968-8776-655443322110",
"emitted_at": "2026-10-01T12:00:00Z",
"topic": "payments.captured",
"payment": {
"id": "5f0c2b1e-8d4a-4f6e-9a7b-2c3d4e5f6a7b",
"status": "captured",
"amount": "10500000",
"capturable_amount": "0",
"metadata": { "order_id": "ord_1042" }
},
"transaction": { "operation": "capture", "status": "confirmed" }
}

transaction is null for events that involve none (created, signed, authorization_expiring, expired). Delivery is at-least-once: deduplicate on the top-level id, which is identical across retries and redeliveries of the same event.

Each delivery carries:

X-Rail0-Topic: payments.captured
X-Rail0-Timestamp: 1770000000
X-Rail0-Signature: <hex HMAC-SHA256(secret, "{timestamp}.{raw_body}")>

Verify over the raw request body, before parsing it, and reject timestamps outside a tolerance window (the gateway uses ±5 minutes) — that window is what stops a captured delivery from being replayed. X-Rail0-Topic names the event, so one endpoint can route several topics without parsing first.

PUT /webhooks/{id}/rotate_secret returns a new secret and keeps the old one for a one-hour grace window. Inside it every delivery is dual-signed:

X-Rail0-Signature: <HMAC under the new secret>
X-Rail0-Signature-Previous: <HMAC under the old secret>

So deliveries keep verifying while you roll the new secret out: check your secret against either header and accept the delivery when one of them matches. Never accept it just because both headers are present. A second rotation inside the window drops the oldest key, so at most two keys exist at once.

import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// signatures: X-Rail0-Signature and, during a rotation, X-Rail0-Signature-Previous.
export function verifyRail0Webhook(
rawBody: string,
timestamp: string,
signatures: (string | undefined)[],
secret: string,
): boolean {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some((signature) => {
if (!signature) return false;
const given = Buffer.from(signature, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}

Your endpoint must be https, must not resolve to a private or loopback address, and must not embed credentials in the URL — authenticate with a header instead.

Repeated delivery failures trip the webhook’s circuit breaker, which disables it. To recover:

  1. Fix the endpoint.
  2. PUT /webhooks/{id}/reset_circuit to close the breaker and re-enable the webhook.
  3. List what was missed with GET /webhooks/{id}/event_callbacks?status=failed.
  4. POST /webhooks/{id}/event_callbacks/{callback_id}/redeliver each one — the exact original payload, same event id, with a fresh signature. A 503 queue_unavailable means nothing was queued: retry.

Delivery is guaranteed once an event is queued, but queueing itself is best effort: an outage at the wrong moment can lose an event. A webhook is a notification, not the record, so reconcile from GET /payments when you need every state change.