# Route webhooks

**Availability:** outbound delivery requires Route to approve your receiver’s HTTPS origin. Creating an endpoint alone does not enable delivery. Use [payment polling](recovery.md#polling-and-retry-schedule) until your receiver is enabled.

Webhooks report Route payment observations asynchronously. Verify and deduplicate each event, then read the current payment before updating your application. Events may be duplicated or arrive out of order.

## Configure an endpoint

Host the receiver on your application’s backend, for example `https://your-app-domain.com/api/webhooks/route`. A separate webhook service is not required. Verify the signature, durably accept the event, and return a 2xx response within ten seconds. Process longer work asynchronously.

Use **Webhooks** in the dashboard, or a server-side Route key with `webhooks:write`:

```http
POST /v1/webhooks
Authorization: Bearer <Route key>
Content-Type: application/json
Idempotency-Key: <UUID>

{"name":"Payments backend","url":"https://your-app-domain.com/api/webhooks/route"}
```

The response contains the endpoint metadata and a `whsec_…` signing secret, shown once. Store it in your backend secret manager. It cannot be retrieved. A repeated creation key returns `SECRET_ALREADY_ISSUED`, preventing a second issuance. Creation or rotation without a UUID key returns `400 IDEMPOTENCY_REQUIRED` and issues nothing. Rotate if the original response was lost.

`GET /v1/webhooks` requires `webhooks:read` and returns safe endpoint metadata and the latest 100 delivery records. No secret or encrypted secret is returned. Each endpoint and delivery record includes `deliveryStatus`: `ready`, `disabled`, or `awaiting-activation`. The response-level `deliveryEnabled` describes whether any receiver origin is enabled on Route; check your endpoint’s own status before relying on delivery.

When Route processes an event for delivery, it queues it only for currently enabled endpoints with an approved origin and matching scope. Events already processed while an endpoint was disabled or unapproved are not replayed after activation. A recent event still awaiting processing may be queued after activation if the endpoint already existed when that event was created. Deliveries expire 24 hours after the event was created, including older queued events. Poll payments during onboarding and read current payment state after activation; webhook activation is not a complete history replay.

- `PUT /v1/webhooks/{id}` with `{enabled,version}` enables or disables delivery.
- `POST /v1/webhooks/{id}/rotate` with `{version}` and a fresh UUID idempotency key issues a replacement signing secret.
- `POST /v1/webhooks/{id}/test` with `{}` queues a clearly identified test event for an active, approved endpoint; otherwise it returns `WEBHOOK_NOT_ACTIVE`.
- `POST /v1/webhooks/{id}/retry` with `{deliveryId}` restarts a dead or disabled delivery less than 24 hours old for an active, approved endpoint. The original event ID is retained. Expired deliveries cannot be replayed; read the current payment instead.

Endpoints created through an API key inherit its project and policy. The key can list and manage only endpoints for that policy, and those endpoints receive only payments belonging to it. Dashboard-created endpoints have project-wide scope. API keys, including keys bound to the default policy, do not list or manage project-wide endpoints; use the dashboard for them, or create the endpoint with the key that should manage it. Endpoint metadata exposes `policy_id` (`null` means project-wide); its scope cannot be changed after creation.

Endpoints created before mainnet 1.43.0-preview had no recorded scope. The upgrade attributed each one from its creation evidence and kept its enabled state and version where the creator could be established. If no API key of its creator could have created it, it stays project-wide. If only API keys of one policy could have created it, and no recent dashboard sign-in could have, it takes that policy, so that policy's keys can list and manage it again. If both were possible, or keys of several policies could have created it, the endpoint was disabled and shows `scope_review_required: true`. A dashboard user with webhook-management permission must review it and re-enable it as project-wide before it receives events. API keys cannot see or reactivate it; to manage that receiver through the API, create a replacement endpoint with the key. Each policy assignment (`webhook.policy_scope_assigned`) and review requirement (`webhook.scope_review_required`) is recorded in the workspace audit log as a system action.

Key revocation, member removal and role changes are checked on each request. Dashboard secret creation/rotation requires a sign-in less than 15 minutes old. A secret rotation is checked immediately before sending; a request already in flight may use the previous secret.

## Verify the exact request body

Headers:

- `X-Route-Event-Id`: stable event UUID; use this for durable deduplication.
- `X-Route-Timestamp`: Unix seconds for this delivery attempt.
- `X-Route-Signature`: `v1=` followed by lowercase hexadecimal HMAC-SHA256.

Signing input is the UTF-8 string `<eventId>.<timestamp>.<rawBody>`. The HMAC key is the complete signing secret as UTF-8, including `whsec_`. Do not parse and reserialize JSON before verification. Reject timestamps more than five minutes in the past or future. Verify the signature, ensure the body `id` equals the header event ID, and atomically deduplicate the event with your business update.

Example receiver verification (Node.js, before JSON middleware):

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyRouteEvent({secret, rawBody, headers}) {
  const id = headers['x-route-event-id'];
  const rawTime = headers['x-route-timestamp'];
  const signature = headers['x-route-signature'];
  if (typeof id !== 'string' || !/^\d{1,12}$/.test(rawTime ?? '') ||
      !/^v1=[0-9a-f]{64}$/.test(signature ?? '')) throw Error('Invalid signature');
  const time = Number(rawTime);
  if (Math.abs(Date.now() / 1000 - time) > 300) throw Error('Expired signature');
  const expected = createHmac('sha256', secret)
    .update(`${id}.${rawTime}.`).update(rawBody).digest();
  if (!timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex')))
    throw Error('Invalid signature');
  const event = JSON.parse(rawBody.toString('utf8'));
  if (event.id !== id) throw Error('Event identity mismatch');
  return event; // Persist/deduplicate event.id before acknowledging.
}
```

## Events and delivery semantics

Events include `payment.created`, `payment.cancelled`, `payment.preparing`, `payment.awaiting_action`, `payment.submitted`, `payment.source_confirmed`, `payment.requires_attention`, `payment.completed`, `payment.refunded`, `payment.failed` and `webhook.test`. For transactions sent by Route, `payment.submitted` follows the acknowledged broadcast; saving a signature does not emit it. An uncertain send emits `payment.requires_attention`, including an uncertain send of a spending approval; that approval stays the reason for attention until a later action is saved, even after tracking proves it was sent and `next-action` offers the next transaction. A wallet handoff (`POST /transaction/broadcast`) emits no event while Route waits for the wallet's hash: the payment reads `WALLET_SUBMISSION_UNKNOWN`. Once Route verifies a funding transaction's broadcast, `payment.submitted` follows. An externally registered source hash can also produce `payment.submitted`; that event is not proof of funding. So can a Stellar transaction the wallet broadcast itself, once Route finds and adopts its exact envelope. A proof-gated closure of an expired Stellar payment emits `payment.cancelled` and no later `payment.requires_attention`. A Stellar renewal or close check that finds the saved transaction's wallet sequence used, without a receipt Route can adopt, emits `payment.requires_attention`; the payment stays under review. Completed/refunded events require verified source and settlement receipts. Existing records created before eventing was installed are not backfilled.

A failed destination transaction, a failed or expired managed destination relay, or a managed relay whose submission response was lost (`submission-unknown`) emits `payment.requires_attention` even after source confirmation. When Route's single guarded automatic re-mint then resubmits the managed mint, the payment leaves review and emits the same event as any other recovery from review (`payment.source_confirmed` for a direct payment) until delivery is verified. A duplicate relay that failed only because the message was already minted is not reported as a destination failure: the payment completes from the recorded relay that minted it, or stays under review until that mint's hash is supplied. A temporary receipt, provider-status or destination-lookup outage emits nothing: tracking retries, a pending payment reads `VERIFICATION_PENDING` (an uncertain send or wallet handoff keeps its state), and the next event follows the next verified change. Any other tracking error, such as a receipt that does not prove the saved action or a removed provider connection, emits `payment.requires_attention`; a later temporary outage does not replace that error or emit an event, and it clears only when an observation succeeds. A detected wallet change to a mainnet approval, funding transaction or mint also emits this event, with the saved `actionId`; the action may still be `ready` because Route has not accepted the changed envelope. Read the payment and its next action before continuing. These observations do not replace an already verified completion or refund, and never authorize sending replacement funds.

Two-step routes also emit parent `payment.intermediate_received` and `payment.second_leg_submitted` events. Parent payloads identify `paymentId`, `legPaymentId` and `leg`; three-step routes additionally emit `payment.second_intermediate_received` and `payment.third_leg_submitted`. Parent completion requires verified settlement of every expected transfer. A parent stream combines its transfers: each transfer's parent event is emitted only when that transfer's observed state changes, so refreshing an earlier, settled transfer never repeats its milestone, and no parent event follows `payment.completed`. A parent `payment.requires_attention` is therefore not repeated for the same transfer until that transfer reaches another parent milestone: after an uncertain approval send, for example, a later uncertain send in that transfer appears only on the transfer's own stream, and the latest parent event still carries the approval's `actionId`. Read the payment or the transfer for its current action. A proof-gated cancellation of the first transfer ends the parent stream at `payment.cancelled`; the transfer's own stream still reports `payment.requires_attention` before its `payment.cancelled`. See [composed payments](composed-payments.md).

Testnet payments emit `payment.created` and then one event per saved testnet status change, named after that status: `payment.awaiting_action`, `payment.approval_confirmed`, `payment.source_pending`, `payment.attesting`, `payment.awaiting_destination`, `payment.destination_pending`, `payment.completed`, `payment.source_reverted`, `payment.destination_failed` and `payment.cancelled`. A renewed customer-signed testnet mint emits `payment.mint_renewed` with `authorizationId` and `renewedFrom`, and no `status`. These events carry `data.network: "testnet"`, except a `payment.cancelled` recorded by a proof-gated wallet-broadcast close, which carries `cancellationHash` instead.

The JSON envelope includes `id`, `type`, `version`, `createdAt`, `projectId`, and a small `data` object with `paymentId`, the observed `status` and the same `version`. `data.status` describes the event, not the payment's current `paymentState`. It uses its own vocabulary: never compare it with `paymentState`, and fetch the payment for its current state. Submission events also carry `actionId` and the saved action's `submissionState`; composed parent events carry `legPaymentId`, `leg` and `intermediateWallet`.

| Event | Mainnet `data.status` | Testnet `data.status` | `paymentState` usually read afterwards |
| --- | --- | --- | --- |
| `payment.created` | `DRAFT` | `draft` | `DRAFT` |
| `payment.preparing` | `PREPARING` | — | `PREPARING` |
| `payment.awaiting_action`, `payment.approval_confirmed` | `AWAITING_ACTION` | `awaiting_action`, `approval_confirmed` | `AWAITING_ACTION`, or `WALLET_SUBMISSION_UNKNOWN` after a wallet handoff |
| `payment.submitted`, `payment.source_pending` | `SUBMITTED` | `source_pending` | `SOURCE_PENDING` |
| `payment.source_confirmed`, `payment.attesting`, `payment.awaiting_destination`, `payment.destination_pending` | `SOURCE_CONFIRMED` | `attesting`, `awaiting_destination`, `destination_pending` | `DESTINATION_PENDING`, `SOURCE_PENDING` until the source is final, or `DELIVERED_PENDING_FINALITY` |
| `payment.intermediate_received`, `payment.second_intermediate_received` (parent) | `INTERMEDIATE_RECEIVED`, `SECOND_INTERMEDIATE_RECEIVED` | — | `INTERMEDIATE_HELD` |
| `payment.second_leg_submitted`, `payment.third_leg_submitted` (parent) | `SECOND_LEG_SUBMITTED`, `THIRD_LEG_SUBMITTED` | — | The active transfer's pending state, such as `SOURCE_PENDING` |
| `payment.requires_attention`, `payment.destination_failed` | `REQUIRES_ATTENTION` | `destination_failed` | `REQUIRES_ATTENTION` |
| `payment.completed` | `COMPLETED` | `completed` | `COMPLETED`; testnet reads `VERIFICATION_PENDING` or `DELIVERED_PENDING_FINALITY` until every receipt is final |
| `payment.refunded` | `REFUNDED` | — | `REFUNDED` |
| `payment.failed`, `payment.source_reverted` | `FAILED` | `source_reverted` | `FAILED` |
| `payment.cancelled` | `CANCELLED` | `cancelled` | `CANCELLED` |

Each payment has its own strictly increasing webhook sequence, independent of payment/preparation revision numbers. Composed parent and child payments have separate sequences; testnet payments use the same sequencing contract. Repeated observations of the current event type are suppressed, but returning to an earlier state emits a new event with a higher version. Each `payment.mint_renewed` announces a different authorization, so consecutive renewals are each sent. A standalone `webhook.test` event has version 1. Fetch the current payment through Route to inspect evidence. Delivery is at least once and ordering is not guaranteed. Never regress your state based on a delayed event; compare versions and fetch the authoritative payment. Store event IDs durably.

HTTP 2xx acknowledges delivery. Timeouts, network errors, redirects and non-2xx responses retry with exponential backoff, up to eight attempts. For HTTP 429 and 503, a valid `Retry-After` can extend the delay up to one day. Disabled receivers are not contacted; re-enable the endpoint and explicitly retry its disabled deliveries when ready. Response bodies are discarded. Delivery records and safe attempt details are retained for 90 days. Expiry and retention do not remove payment history or accounting journals, and event sequences never restart. Dead deliveries less than 24 hours old can be explicitly retried without starting a new payment.

## Current deployment boundary

Delivery requires an approved receiver HTTPS origin. Only public DNS-form HTTPS URLs on the default port are accepted; credentials, fragments, literal IPs and reserved local names are rejected. Redirects are not followed. Contact Route to enable your receiver before relying on event delivery, and keep payment polling available while onboarding is pending.
