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).
Topics
Section titled “Topics”| Topic | Fires when |
|---|---|
payments.created, payments.signed | the payment is created / signed by the buyer |
payments.authorized, payments.charged | funds are escrowed / paid directly |
payments.captured, payments.voided, payments.released, payments.refunded | the matching operation confirms |
payments.authorization_expiring | once, ahead of authorization_expiry, while there is still something to capture |
payments.expired | the authorization lapsed uncaptured |
payments.failed | any attempt died — read last_error_code to decide what to do |
payments.disputed, payments.dispute_closed | the 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).
Payload
Section titled “Payload”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.
Verifying signatures
Section titled “Verifying signatures”Each delivery carries:
X-Rail0-Topic: payments.capturedX-Rail0-Timestamp: 1770000000X-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.
Rotating the secret
Section titled “Rotating the secret”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); });}require "openssl"
TOLERANCE_SECONDS = 300
# signatures: X-Rail0-Signature and, during a rotation, X-Rail0-Signature-Previous.def verify_rail0_webhook(raw_body, timestamp, signatures, secret) return false unless timestamp.match?(/\A\d+\z/) return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}") signatures.compact.any? { |signature| OpenSSL.secure_compare(expected, signature) }endpackage webhooks
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "time")
const toleranceSeconds = 300
// signatures: X-Rail0-Signature and, during a rotation, X-Rail0-Signature-Previous.func VerifyRail0Webhook(rawBody []byte, timestamp string, signatures []string, secret string) bool { ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if age := time.Now().Unix() - ts; age > toleranceSeconds || age < -toleranceSeconds { return false }
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + "." + string(rawBody))) expected := mac.Sum(nil) for _, signature := range signatures { given, err := hex.DecodeString(signature) if err == nil && len(given) > 0 && hmac.Equal(given, expected) { return true } } return false}Failures and recovery
Section titled “Failures and recovery”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:
- Fix the endpoint.
PUT /webhooks/{id}/reset_circuitto close the breaker and re-enable the webhook.- List what was missed with
GET /webhooks/{id}/event_callbacks?status=failed. POST /webhooks/{id}/event_callbacks/{callback_id}/redelivereach one — the exact original payload, same eventid, with a fresh signature. A503 queue_unavailablemeans 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.