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

```json
{
  "id": "evt_01J9...",
  "type": "payment_link.activated",
  "created_at": "2026-09-04T14:02:11Z",
  "livemode": false,
  "data": { "object": { "id": "pl_01J9...", "type": "payment_link" } },
  "request": { "id": "req_01J9...", "idempotency_key": "supalabs-2026-014" },
  "api_version": "2026-09-04"
}
```

- `GET /v1/events` — list, filter by `type` or `object` (a resource id), cursor-paginated (see [Conventions](/conventions#pagination)). 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 with `Authorization: Bearer …` or `x-api-key`, opened with `fetch()` against a readable stream — never native browser `EventSource`, which cannot set a header. On a dropped connection, reconnect with `Last-Event-ID` set 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 to `GET /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.

{/* BEGIN GENERATED event-type-status — scripts/openapi/normalize.mjs from EVENT_PAYLOAD_ALLOWLIST; do not edit */}

| 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` | — |

{/* END GENERATED event-type-status */}

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.unmatched` fires when a payment becomes `unmatched`, and `data.object.unmatched_reason` says why: `partial_before_cancel` when 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), or `deposit_after_close` when 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 like `null`. 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`'s `appendEvent`, for any caller.
- `subscription.*` — the crypto subscriptions service.
- `order.*` — `emitOrderOutboxEvent`, from the Bridge-native order paths and the Bridge webhook reducer.
- [`customer.*`](/products/customers) — `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](/agents-mcp) — 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](/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` |

```
POST /v1/webhook_endpoints
{
  "url": "https://example.com/hooks/swaps",
  "event_types": ["payment_link.paid", "payout.settled"]
}
```

`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](#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_delivery` is off (a queued test would sit forever with nothing to process it).
- `503 temporarily_unavailable` — `api_v1.events` is off (there is no outbox to write the test event to).

## Verifying a delivery

Every delivery carries a `Swaps-Signature` header:

```
Swaps-Signature: t=1757030400,v1=5257a869e7bfc7fd6f6e3f5b3ee5e3f...
```

- 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's `secret_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.
- `v1` is `hex(hmac-sha256(that key, "<t>.<raw body>"))`.
- Compare in constant time. Reject a `t` more than **300 seconds** (`DEFAULT_SIGNATURE_TOLERANCE_SECONDS`) from your own clock, either direction — replay protection.
- **During a `rotate_secret` overlap window, one header carries two `v1` tokens** — one signed with the new secret, one with the old. Accept a match against **any** `v1` token 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.

```javascript title="verify-signature.js (Node.js, no dependencies)"
const crypto = require('crypto');

/**
 * Verifies a Swaps-Signature header against the raw request body and the
 * cleartext secret shown once at create/rotate_secret. Reject on `false` —
 * do not process the body.
 */
function verifySwapsSignature(secret, header, rawBody, opts = {}) {
  const toleranceSeconds = opts.toleranceSeconds ?? 300;
  const now = opts.now ?? Math.floor(Date.now() / 1000);

  let timestamp = null;
  const signatures = [];
  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') {
      const parsed = Number(value);
      if (Number.isFinite(parsed)) timestamp = parsed;
    } else if (key === 'v1' && value) signatures.push(value);
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(now - timestamp) > toleranceSeconds) return false;

  // The signing KEY is the raw 32-byte SHA-256 digest of the cleartext
  // secret — never the hex-encoded string, and never the secret's own bytes.
  const signingKey = crypto.createHash('sha256').update(secret, 'utf8').digest();
  const expected = crypto
    .createHmac('sha256', signingKey)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex');

  // A header carries more than one v1 token during a rotate_secret overlap
  // window (24h) — accept a match against ANY of them.
  return signatures.some(
    (sig) =>
      sig.length === expected.length &&
      /^[0-9a-f]+$/i.test(sig) &&
      crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'))
  );
}

module.exports = { verifySwapsSignature };
```

```python title="verify_signature.py (Python, standard library only)"
import hashlib
import hmac
import re
import time

def verify_swaps_signature(secret, header, raw_body, tolerance_seconds=300, now=None):
    """Reject on False — do not process the body."""
    timestamp = None
    signatures = []
    for part in header.split(","):
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if key == "t":
            try:
                timestamp = int(value)
            except ValueError:
                return False
        elif key == "v1" and value and re.fullmatch(r"[0-9a-fA-F]+", value):
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    if now is None:
        now = int(time.time())
    if abs(now - timestamp) > tolerance_seconds:
        return False

    # The signing KEY is SHA-256(secret) — never the secret's own bytes.
    signing_key = hashlib.sha256(secret.encode("utf-8")).digest()
    message = f"{timestamp}.{raw_body}".encode("utf-8")
    expected = hmac.new(signing_key, message, hashlib.sha256).hexdigest()

    # A header carries more than one v1 token during a rotate_secret overlap
    # window (24h) — accept a match against ANY of them.
    return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
```

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:

```json title="Test vector (generated with signWebhookPayload)"
{
  "secret": "whsec_test_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "body": "{\"id\":\"evt_test123\",\"type\":\"test.ping\",\"data\":{\"object\":{\"id\":\"whe_test456\",\"object\":\"webhook_endpoint\"}}}",
  "timestamp": 1735689600,
  "header": "t=1735689600,v1=c371d83d3c227120d0c5069fdc442fdca9121fcb798018105209d0ea9395d5d2"
}
```

`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](/agents-mcp) for how a tool call surfaces the same error envelope · [Changelog](/changelog) for what shipped when.
