Events & webhooks
On in production since 2026-10-08
Every operation on this page is implemented and x-swaps-status: available in the contract — this is not a design doc. Three resource flags gate it, and all three were switched on with dashboard v2 (2026-10-08): api_v1.events (the /v1/events reads and the stream), api_v1.webhooks (the webhook_endpoints.*/webhook_deliveries.* REST routes) and api_v1.webhooks_delivery (the fan-out trigger and the delivery worker — a separate switch, because registering an endpoint and actually delivering to it are different guarantees). The global api_v1 flag gates the REST calls too. While any of these is off, the affected calls return 503 temporarily_unavailable. With api_v1.webhooks on and api_v1.webhooks_delivery off, endpoints can be registered but nothing is delivered to them — api_v1.webhooks_delivery is what actually runs the worker.
The event stream
GET /v1/events is the single cross-domain, append-only outbox every other event-shaped read is a view over — payment_links.events, payouts.events, payroll_run.events and so on each read from the same table rather than keeping their own parallel history.
Code
GET /v1/events— list, filter bytypeorobject(a resource id), cursor-paginated (see Conventions). Poll with?since=only when a live connection is unavailable.GET /v1/events/{id}— one event, read-only — events are never created through the API.GET /v1/events/stream— Server-Sent Events, same auth and envelope as the REST list, one frame per event. This is the live channel for every client, first-party dashboard included — there is no private realtime side-door. Authenticate withAuthorization: Bearer …orx-api-key, opened withfetch()against a readable stream — never native browserEventSource, which cannot set a header. On a dropped connection, reconnect withLast-Event-IDset to the last id you received; the stream resumes immediately after it, never from the start. A gap wider than the retention window is not silently bridged — fall back toGET /v1/events?since=.
What reaches webhooks and /v1/events today
type is one catalogue read from two sides. On a request — the type filter of events.list, or event_types on webhook_endpoints.create/.update — it is closed: an unknown or retired name is 400 invalid_request. On a response — Event.type, a delivery's type, WebhookEndpoint.event_types — it is open: treat an unrecognized name as an opaque string and never fail to parse it, because the catalogue grows within /v1 and a delivery may carry a type added after your client was generated.
Every member is marked live (written to the api_events outbox today) or catalogued (not written to the outbox yet). Subscribing to a catalogued type is accepted, and it is never delivered to a webhook endpoint and never listed by /v1/events or /v1/activity until its outbox allowlist ships. A per-resource event list (payouts.events.list, payroll_runs.events.list) reads the product's own ledger and may still show a catalogued type. The contract carries the same marking as x-swaps-event-status on EventType. No history before 2026-09 is backfilled for any type.
| Family | Live — in the event outbox | Catalogued — not in the outbox, never delivered or in /v1/events |
|---|---|---|
payment_link | payment_link.created, payment_link.activated, payment_link.viewed, payment_link.paid, payment_link.settled, payment_link.partially_paid, payment_link.refunded, payment_link.cancelled, payment_link.expired, payment_link.reminder_sent, payment_link.reminder_schedule_updated | — |
payment | payment.created, payment.marked_sent, payment.awaiting, payment.paid, payment.settled, payment.underpaid, payment.overpaid, payment.unmatched, payment.expired | payment.detected, payment.processing |
subscription | subscription.created, subscription.paused, subscription.resumed, subscription.cancelled, subscription.invoice_issued, subscription.invoice_overdue, subscription.invoice_paid | — |
payout | payout.created, payout.funded, payout.cancelled, payout.marked_sent, payout.created_as_replacement, payout.beneficiary_added | payout.awaiting_funds, payout.funds_received, payout.processing, payout.paid, payout.settled, payout.failed, payout.returned, payout.paid_with_shortfall, payout.replaced, payout.configuration_committed |
payroll_run | payroll_run.created, payroll_run.approved, payroll_run.cancelled, payroll_run.funding_verified, payroll_run.execution_started, payroll_run.completed, payroll_run.partial, payroll_run.failed | payroll_run.funding_instructions_requested, payroll_run.funding_instructions_verified, payroll_run.funding_instructions_failed, payroll_run.funding_instructions_blocked, payroll_run.underfunded, payroll_run.execution_requested, payroll_run.execution_blocked |
payroll_item | — | payroll_item.destination_updated, payroll_item.destination_changed, payroll_item.payout_queued, payroll_item.payout_processing, payroll_item.payout_paid, payroll_item.payout_failed, payroll_item.payout_returned |
payroll_template | — | payroll_template.created |
deposit_intent | — | deposit_intent.created, deposit_intent.source_detected, deposit_intent.bridging_started, deposit_intent.settled, deposit_intent.failed, deposit_intent.expired, deposit_intent.recovery_started, deposit_intent.manual_recovery_required |
send_intent | — | send_intent.created, send_intent.source_submitted, send_intent.bridging_started, send_intent.settled, send_intent.failed, send_intent.expired |
offramp_intent | — | offramp_intent.created, offramp_intent.funds_received, offramp_intent.payment_submitted, offramp_intent.payment_processed, offramp_intent.refunded, offramp_intent.refund_failed, offramp_intent.canceled |
virtual_account | — | virtual_account.created, virtual_account.deactivated, virtual_account.reactivated, virtual_account.deposit_received |
wallet | — | wallet.created |
conversion | — | conversion.built, conversion.submitted, conversion.settled, conversion.failed |
customer | customer.verification_state_changed, customer.rejected, customer.requirements_updated | — |
capability | — | capability.blocked, capability.unblocked |
order | order.created, order.status_changed, order.failed, order.refunded, order.cancelled | — |
screening | — | screening.completed, screening.risk_elevated |
account | — | account.access_state_changed |
address_book | — | address_book.entry_rechecked |
credit | — | credit.consumed, credit.purchased |
test | test.ping | — |
webhook_endpoint | webhook_endpoint.disabled | — |
Who produces the live types:
payment_link.*,payment.*— the payment-link service layer: merchant mutations, payer rail selection, the Bridge webhook reducer, the expiry/reminder cron.payment.underpaid,payment.overpaid,payment.unmatched— also the Tempo payment watcher (crypto_tempo/crypto_relay).payment.unmatchedfires when a payment becomesunmatched, anddata.object.unmatched_reasonsays why:partial_before_cancelwhen the merchant cancelled the link after the payer had sent part of the amount (the cancel emits it; a failed emit does not block the cancel), ordeposit_after_closewhen money is first seen at the address of a payment that had already closed unpaid (expired, or its link cancelled before any money was seen). It is an open enum: treat an unknown value likenull. The same field is on the payment itself (GET /v1/payments/{id}). A second deposit to an already-paid payment is recorded for support and not published on /v1; a deposit matching no payment is recorded for support.payout.*—appendPayoutEvent, from an api-v1 mutation or a dashboard/admin action.payroll_run.*—payroll/lib.ts'sappendEvent, for any caller.subscription.*— the crypto subscriptions service.order.*—emitOrderOutboxEvent, from the Bridge-native order paths and the Bridge webhook reducer.customer.*—emitCustomerOutboxEvent, from the Bridge customer webhook handler.test.ping—webhook_endpoints.send_test_event, delivered only to the endpoint under test, never fanned out.webhook_endpoint.disabled—api-webhooks-worker, the moment an endpoint's auto-disable counter trips.
Webhook endpoints
Registered destinations, one https URL per endpoint. Callers: a business key, or a first-party bearer session (dashboard or agent — an agent authenticates with a business key like any other caller; the OAuth agent class this used to imply never existed, decision C4-D32, see Authentication). create, update, delete, rotate_secret and send_test_event additionally require a bearer caller to be the account's owner — the same bar api_keys management uses, since a signing secret is account-level credential material; a business key needs no further gate. list/get (both resources) and replay carry no owner requirement. Every operation uses the developers.read/developers.write scope pair — not a webhooks.* scope. Test mode: full — a test-mode caller only ever sees, and can only ever register, test-mode endpoints.
| Operation | Method & path | Scope |
|---|---|---|
webhook_endpoints.list | GET /v1/webhook_endpoints | developers.read |
webhook_endpoints.create | POST /v1/webhook_endpoints | developers.write |
webhook_endpoints.get | GET /v1/webhook_endpoints/{id} | developers.read |
webhook_endpoints.update | PATCH /v1/webhook_endpoints/{id} | developers.write |
webhook_endpoints.delete | DELETE /v1/webhook_endpoints/{id} | developers.write |
webhook_endpoints.rotate_secret | POST /v1/webhook_endpoints/{id}/rotate_secret | developers.write |
webhook_endpoints.send_test_event | POST /v1/webhook_endpoints/{id}/send_test_event | developers.write |
webhook_deliveries.list | GET /v1/webhook_deliveries | developers.read |
webhook_deliveries.get | GET /v1/webhook_deliveries/{id} | developers.read |
webhook_deliveries.replay | POST /v1/webhook_deliveries/{id}/replay | developers.write |
Code
event_types omitted or [] means "every type this account can ever emit, present and future." An account may register at most 20 endpoints; create past the limit is 409 webhook_endpoint_limit_exceeded.
URL rules. url must be https:// — a non-https scheme is 400 webhook_url_not_https. Everything else that makes a URL unusable for a webhook — an embedded user:pass@ credential, a localhost/.local/.internal/.arpa literal, an IP literal in a private/loopback/link-local/CGNAT/reserved range (including IPv4-mapped and NAT64-embedded IPv6 forms), or a hostname that resolves to one of those ranges, or fails to resolve at all — is 400 webhook_url_not_allowed, checked again by the delivery worker immediately before every send. This narrows, not closes, the SSRF window — see Verifying a delivery below for the one residual risk this design does not defend against.
The secret. create and rotate_secret return secret — the full signing secret in cleartext — exactly once, in that one response; every later read returns secret_prefix only (e.g. whsec_ab12cd, safe to display anywhere). A replayed Idempotency-Key on create/rotate_secret gets the identical body back with secret overwritten to null — never the cleartext twice, even from the idempotency store. rotate_secret keeps the OLD secret's hash live as previous_secret_hash for a 24-hour overlap window: every delivery signed in that window carries a signature for BOTH the new and the old key, so a receiver that has not yet picked up the new secret keeps validating instead of failing every delivery from the instant of rotation.
Auto-disable. An endpoint that racks up 3 consecutive fully-exhausted deliveries (any succeeded delivery resets the counter to 0) is disabled automatically: enabled becomes false, disabled_reason becomes auto_disabled_repeated_failures, and a webhook_endpoint.disabled event is emitted (readable via /v1/events — never delivered to the endpoint it just disabled). PATCH .../{id} with {"enabled": true} re-enables it and resets the counter; rotate_secret does not re-enable a disabled endpoint.
Deliveries and replay
A delivery's body carries id, type, created_at, livemode, api_version and data — the same values as the REST Event, minus object and request, which are not sent on a delivery. The contract publishes it as the WebhookEvent schema under OpenAPI's webhooks, one entry per event family, so a generated client gets a typed payload: in @swaps/sdk, generated.WebhookEventSchema.parse(JSON.parse(rawBody)) after the signature check below, and the webhooks type for the static shape. GET /v1/webhook_deliveries lists every attempted-or-scheduled delivery across the account's endpoints, filterable by endpoint_id or status; GET /v1/webhook_deliveries/{id} reads one. Fields:
| Field | Meaning |
|---|---|
attempt | How many delivery attempts this row has made so far. |
status | pending (never attempted), failed (a retry is scheduled at next_attempt_at — automatic, no action needed), succeeded (terminal), or exhausted (every scheduled attempt failed — terminal; replay it explicitly). |
next_attempt_at | When the next automatic retry is due, or null. |
response_status | The HTTP status your endpoint returned, or null if the attempt never got one (timeout, DNS/SSRF refusal, connection error). |
response_ms | Round-trip time in milliseconds. The response body is never stored. |
error | A short machine-readable reason for the most recent failure (e.g. timeout, connection_error, non_2xx_response, or url_not_allowed:<reason> — the SSRF/DNS refusal carries its own suffix, e.g. url_not_allowed:dns_resolution_failed) — never your endpoint's response body. |
delivered_at | Set once, the moment status first becomes succeeded. |
A non-2xx response — including a 3xx: the worker never follows a redirect, a redirect is recorded as a failed attempt, not a success — a connection failure, or a 10-second timeout schedules a retry on this fixed schedule:
| Attempt that just failed | Next retry after |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
| 6 | 24 hours |
| 7 | none — exhausted |
POST /v1/webhook_deliveries/{id}/replay enqueues a new delivery row for the same event and the same endpoint — the original row is never mutated. Safe to call repeatedly. Refused with 409 webhook_endpoint_disabled when the endpoint is currently disabled, or a generic 409 conflict when the endpoint's current event_types no longer include this event's type (the owner may have narrowed subscriptions since the original delivery went out).
POST /v1/webhook_endpoints/{id}/send_test_event mints one synthetic test.ping event and queues exactly one delivery to that one endpoint. It answers honestly rather than silently accepting work nothing will run:
409 webhook_endpoint_disabled— the endpoint is disabled.503 temporarily_unavailable—api_v1.webhooks_deliveryis off (a queued test would sit forever with nothing to process it).503 temporarily_unavailable—api_v1.eventsis off (there is no outbox to write the test event to).
Verifying a delivery
Every delivery carries a Swaps-Signature header:
Code
- The signed string is the exact raw bytes of the request body you received, never a re-serialized or re-indented copy:
"<t>.<raw body>". - The HMAC key is the raw 32 bytes of
SHA-256(UTF8(secret))— the same digest Swaps stores hex-encoded as the endpoint'ssecret_hash— never the 64-character hex string itself, and never the secret's own bytes. This is the one non-obvious step: hashing the cleartext secret first, then using that raw digest as the HMAC-SHA256 key. v1ishex(hmac-sha256(that key, "<t>.<raw body>")).- Compare in constant time. Reject a
tmore than 300 seconds (DEFAULT_SIGNATURE_TOLERANCE_SECONDS) from your own clock, either direction — replay protection. - During a
rotate_secretoverlap window, one header carries twov1tokens — one signed with the new secret, one with the old. Accept a match against anyv1token present, not only the first. - On failure, reject the request and do not process the body. A 3xx response from your endpoint is never treated as success — the worker does not follow redirects.
Prove your own implementation against this fixed vector before pointing it at a live endpoint — generated with signWebhookPayload (packages/contracts-api/webhook_signature.ts), the exact function the delivery worker signs with:
Code
verifySwapsSignature(secret, header, body, { now: timestamp }) returns true against this vector; change one character of body and it returns false.
DNS rebinding — an honest, open residual risk
Both the create/update-time URL check and the delivery-time check resolve DNS and reject a private/reserved destination, but the actual delivery fetch() performs its own, independent DNS lookup a moment later. A hostile or compromised resolver can answer the check with a public address and the fetch() with a private one — there is no way to pin the specific IP a prior resolution returned. This narrows the SSRF window; it does not close it. Closing it fully needs an egress proxy or a network-level allowlist, not a smarter application check — tracked as a follow-up, not shipped here.
Next: Agents & MCP for how a tool call surfaces the same error envelope · Changelog for what shipped when.