Skip to content

Webhooks

Routes delivers lifecycle updates via webhooks and exposes a pull-based event feed for replay and reconciliation.

Delivery is at-least-once. Treat events as immutable and dedupe by event_id.

Register your endpoint

curl -X POST https://routes.srcry.xyz/v1/webhooks \
  -H "Authorization: Bearer $ROUTES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/routes", "active": true }'

Example response:

{
  "request_id": "req_01J...",
  "webhook": {
    "webhook_id": "wh_01J...",
    "url": "https://example.com/webhooks/routes",
    "active": true,
    "secret": "whsec_...",
    "created_at_ms": 1730000000000
  }
}

Store the returned secret. It is only shown once. Subsequent GET and PATCH responses omit it.

Webhook subscription management

Routes supports a single active webhook endpoint per organization. You may store multiple webhook resources, but at most one may have active=true. Setting a new webhook to active deactivates the previously-active webhook.

Method Path Purpose
POST /v1/webhooks Create a webhook subscription
GET /v1/webhooks List webhook subscriptions
PATCH /v1/webhooks/{webhook_id} Update a webhook
POST /v1/webhooks/{webhook_id}/rotate-secret Rotate the signing secret

POST /v1/webhooks

Creates a webhook subscription.

Auth:

  • Bearer token required

Request body:

Field Type Required Description
url string Yes Endpoint URL that receives deliveries
active boolean No Whether this webhook should be active immediately (true/false)

Response behavior:

  • 200 with webhook object and one-time secret.
  • 400 validation error (for example malformed URL).
  • 401 missing or invalid bearer auth.
  • 429 rate limited.

GET /v1/webhooks

Lists webhook subscriptions for your organization.

Auth:

  • Bearer token required

Response behavior:

  • 200 with webhook resources. secret is omitted.
  • 401 missing or invalid bearer auth.
  • 429 rate limited.

PATCH /v1/webhooks/{webhook_id}

Updates a webhook subscription.

Auth:

  • Bearer token required

Request body (at least one field):

Field Type Required Description
url string No Replace endpoint URL
active boolean No Activate/deactivate this webhook

Response behavior:

  • 200 with updated webhook resource. secret is omitted.
  • 400 validation error.
  • 401 missing or invalid bearer auth.
  • 404 unknown webhook_id.
  • 429 rate limited.

POST /v1/webhooks/{webhook_id}/rotate-secret

Rotates the signing secret for an existing webhook.

Auth:

  • Bearer token required

Response behavior:

  • 200 with new one-time secret.
  • 401 missing or invalid bearer auth.
  • 404 unknown webhook_id.
  • 429 rate limited.

Signature verification

Each webhook request is signed. Verify the signature before processing the event.

Headers

Every webhook delivery includes:

  • Routes-Webhook-Id
  • Routes-Event-Id
  • Routes-Delivery-Id
  • Routes-Timestamp — milliseconds since epoch
  • Routes-Signaturev1=<hex>

Signature scheme

Compute:

payload = Routes-Timestamp + "." + <raw_request_body_bytes>

Then:

sig = hex(hmac_sha256(webhook_secret, payload))

The request is valid when:

Routes-Signature == "v1=" + sig

Use the raw body bytes exactly as received (no JSON re-encoding). Reject requests where Routes-Timestamp is too old or too new (recommended window: ±5 minutes). After rotating secrets, accept the previous secret for a short overlap window.

Python

import hashlib, hmac, time

def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    if abs(time.time_ns() // 1_000_000 - int(timestamp)) > 5 * 60 * 1000:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, f"v1={expected}")

TypeScript

import { timingSafeEqual, createHmac } from "node:crypto";

function verifyWebhook(rawBody: Buffer, timestamp: string, signature: string, secret: string): boolean {
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;

  const expected = createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]))
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(`v1=${expected}`);
  return a.length === b.length && timingSafeEqual(a, b);
}

Go

func VerifyWebhook(body []byte, timestamp, signature, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || abs(time.Now().UnixMilli()-ts) > 5*60*1000 {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(expected))
}

Event format

All deliveries use a consistent envelope:

{
  "event_id": "evt_01J...",
  "type": "trade.filled",
  "created_at_ms": 1730000006500,
  "account_id": "acct_01J...",
  "data": {
    "trade_id": "trd_01J...",
    "quote_id": "qt_01J..."
  }
}

Field semantics:

  • event_id — globally unique, stable identifier (dedupe key)
  • type — event name (see Event types)
  • created_at_ms — event creation time on Routes
  • account_id — the end-user account this event relates to. Use this to route events to the correct user in your system.
  • data — type-specific payload (often minimal identifiers; fetch the full resource via REST if needed)

Event types

Deposits

Event Description
deposit.pending Transaction detected on-chain, waiting for confirmations. Not yet credited to balance.
deposit.verified Deposit source address passed all screening checks and reached Routes' recommended finality threshold. A corresponding credit.created event follows.
deposit.flagged Deposit source address failed screening (Routes or partner). No credit is created. See Transaction Monitoring — Ingress screening.
deposit.partner_screening (Opt-in) Deposit detected on a partner-screening-enabled account. The integrator has a time-limited window to flag the transaction. See Partner screening.

Every deposit is screened at confirmation time. Only deposits whose source address passes screening produce a deposit.verified event and a credit. Deposits from flagged addresses produce a deposit.flagged event and no credit. See Transaction Monitoring for the full verification flow and recovery path.

The deposit.pending event data includes asset_key, detected_atoms, and deposit_address_id (the deposit address that received the funds).

The deposit.verified event data includes credit_id (the spendable credit created from this deposit), asset_key, credited_atoms, source_address, and deposit_address_id.

The deposit.flagged event data includes asset_key, detected_atoms, source_address, flag_reason, flagged_by, tx_hash, and deposit_address_id. When flagged_by is partner, the event also includes partner_flag_category and partner_flag_message.

The deposit.partner_screening event data includes deposit_address_id, tx_hash, source_address, asset_key, detected_atoms, and screening_deadline_ms. This event only fires for accounts whose integrator has opted into partner screening.

Cancels

Event Description
cancel.created Cancel request accepted, queued for processing.
cancel.sent Transaction broadcast to chain.
cancel.confirmed On-chain confirmation received at recommended finality threshold.
cancel.failed Transaction failed or rejected.

Cancel event data includes cancel_id, credit_id, and client_cancel_id (if provided at creation time). cancel.sent and cancel.confirmed also include tx_hash. cancel.failed includes failure_code.

Trades

Event Description
trade.executing Quote accepted, execution begun.
trade.filled MM committed to the fill.
trade.pending_settle Settlement transaction in progress.
trade.settled Settlement confirmed at recommended finality threshold. Settlement and change credits follow via credit.created.
trade.failed Execution or settlement did not complete.

Settlement monitoring:

Event Description
trade.settlement_orphaned A previously-confirmed settlement transaction (either leg) was dropped from the chain.
trade.settlement_reconfirmed A resubmitted settlement transaction has been confirmed.

Trade event data includes trade_id, quote_id, and client_order_id (if provided at accept time). trade.settled also includes credit_id for the settlement credit.

Zaps

Event Description
zap.awaiting_deposit Zap created, waiting for funds at deposit address.
zap.executing Deposit received or credit_id provided, execution begun.
zap.filled MM committed to the fill.
zap.pending_settle Settlement transaction in progress.
zap.settled Settlement confirmed at recommended finality threshold. Settlement and change credits follow via credit.created.
zap.failed Execution or settlement did not complete. If failure_code is deposit_flagged, the deposit's source address failed screening and no credit was created. If failure_code is from_asset_mismatch, the deposited asset differed from the requested source asset and the deposit was still credited normally for the deposited asset. If failure_code is zap_expired, caller-provided expiry was reached before execution began. See Transaction Monitoring.

Settlement monitoring:

Event Description
zap.settlement_orphaned A previously-confirmed settlement transaction (either leg) was dropped from the chain.
zap.settlement_reconfirmed A resubmitted settlement transaction has been confirmed.

Zap event data includes zap_id and client_zap_id (if provided). zap.failed includes failure_code. When recovery is enabled and failure leaves an open credit, zap.failed includes recovery_id + credit_id. zap.settled also includes credit_id for the settlement credit.

Recoveries

Event Description
recovery.created Recovery record created for an eligible failed zap credit.
recovery.claimed End user successfully claimed recovery via email OTP. Includes cancel_id.
recovery.failed Recovery claim attempt failed (for example invalid OTP, expired OTP, or destination screening failure).

Recovery event data includes recovery_id, credit_id, and asset_key. recovery.claimed also includes cancel_id. recovery.failed includes failure_code. Recoveries do not expire automatically while the underlying credit remains open.

Destinations

Event Description
destination.suspended Destination was suspended by delta screening and cannot be referenced by new trades, zaps, or cancels.
destination.reactivated Destination cleared screening and is usable again for new trades, zaps, and cancels.

Destination event data includes destination_id. destination.suspended also includes suspended_at_ms and suspension_reason.

Credits

Event Description
credit.created A new credit is available. Fired for all credit sources: deposits, trade settlements, and change.

The credit.created event data includes credit_id, asset_key, atoms, and source_type (deposit, trade_settlement, or change).

This is the single event to watch for spendable balance changes. Subscribe to credit.created rather than tracking credit creation across deposit, trade, and zap events individually.

Accounts

Event Description
account.created Account provisioned. Address derivation is asynchronous; chain readiness is announced via account.wallet_created.
account.wallet_created Chain address derived and ready for this account. Wait for this event before provisioning deposit addresses on the chain.
account.deactivated Account suspended or closed.
account.export_initiated Private key export requested (by integrator or end user via email OTP). Account locked — no new RFQs or zaps. Pending settlements are being swept/completed.
account.key_exported All pending settlements resolved, encrypted key bundles delivered. Routes can no longer operate on this account. Irreversible.

Account event data fields:

  • account.createdlabel, external_id
  • account.wallet_createdchain, address, asset_key
  • account.deactivated — no additional data fields
  • account.export_initiatedexport_source ("integrator" when initiated via authenticated POST /export-keys, "user_email" when initiated via public OTP flow)
  • account.key_exportedkeys array (each entry containing chain, address, and export_bundle), export_source

The export_bundle in each key entry is HPKE-encrypted to the target_public_key provided at export time. Only the holder of the corresponding P-256 private key can decrypt it. For user-initiated exports, the end user can also retrieve the encrypted keys directly via GET /public/export-status.

Sensitive payload

The account.key_exported event contains encrypted private key material. While the bundles are encrypted to the target public key, ensure your webhook endpoint uses TLS and handle this payload with the same security controls as any other key material. Do not log the raw event body.

Lifecycle parity matrix

Use this matrix to keep polling and webhook consumers consistent:

Flow Resource endpoint Non-terminal statuses Terminal statuses Primary events
Trade GET /v1/trades/{trade_id} executing, filled, pending_settle settled, failed trade.*
Zap GET /v1/zaps/{zap_id} awaiting_deposit, executing, filled, pending_settle settled, failed zap.*
Cancel GET /v1/cancels/{cancel_id} pending, sent confirmed, failed cancel.*
Recovery POST /v1/recoveries/{recovery_id}/activate, POST /v1/public/recover-funds created, active claimed, failed recovery.*

Status transitions are at-least-once in events. Always dedupe by event_id and treat resource fetch as canonical for current state.

Finality and chain confirmations

Terminal events (deposit.verified, cancel.confirmed, trade.settled, zap.settled) fire when Routes' recommended chain-specific finality threshold is met. These thresholds are chosen to be safe under normal chain operation but are not immune to deep uncles or catastrophic reorgs. Chain-specific threshold values are internal policy and are not currently exposed via API.

Non-terminal events represent in-flight activity that is not yet final:

  • Detection: deposit.pending — transaction observed on-chain, waiting for confirmations and screening. Not yet a credit.
  • Settlement: cancel.sent, trade.pending_settle, zap.pending_settle — settlement transaction broadcast, waiting for chain confirmation.

Treat all non-terminal events as progress indicators, not guarantees. Transactions may be orphaned or reorged before reaching finality.

If a settlement transaction is orphaned after reaching settled, Routes fires a *.settlement_orphaned event and attempts to resubmit. On successful resubmission, a *.settlement_reconfirmed event follows. The trade/zap resource includes a settlement object with per-leg tx hashes, confirmation counts, and finality status for monitoring.

deposit.pending does not produce a credit. Routes' ledger does not make pending deposits available for matching against market makers. Only after deposit.verified does a credit.created event fire with a spendable credit_id. Deposits that fail screening produce a deposit.flagged event instead — see Transaction Monitoring.

Event ordering

Do not assume global ordering across events.

  • Ordering is not guaranteed across accounts.
  • Within a single account_id, events are best-effort ordered by created_at_ms, but consumers should still tolerate reordering.

Delivery and retries

  • A delivery is considered successful when your endpoint returns any 2xx status code.
  • Non-2xx responses, timeouts, and network errors trigger retries with exponential backoff for up to 72 hours.
  • After the retry window, undelivered events must be recovered via the event feed (GET /v1/events) or replay.

Routes treats webhook timeout thresholds as internal operational policy and may adjust them over time. Keep handlers fast: verify, persist, enqueue, and return 2xx.

Event retention

Events are retained in the feed for at least 30 days. After this window, events are no longer available via GET /v1/events or replay. Persist any events you need beyond 30 days in your own store.

Catching up after downtime

If your webhook endpoint was unreachable or your service was down:

  1. Read your last persisted cursor or watermark.
  2. Call GET /v1/events?cursor=<last_cursor> and process all missed events (apply the same dedup logic as webhook delivery).
  3. Optionally call POST /v1/webhooks/{webhook_id}/replay to re-trigger webhook delivery for the gap period.

Routes retries webhook delivery for up to 72 hours. Events older than 72 hours that were never successfully delivered will not be retried automatically — use the event feed to recover them.

Handle events idempotently

Your handler should:

  1. Verify the signature (reject 401 if invalid)
  2. Check event_id against your store — skip if already processed
  3. Persist the event
  4. Return 2xx immediately — defer heavy processing to a background job

Event replay and reconciliation

Webhooks are optimized for real-time delivery. The event feed exists to backfill missed events, rebuild state after outages, and reconcile delivery gaps.

Method Path Purpose
GET /v1/events Pull-based feed (cursor pagination)
POST /v1/webhooks/{webhook_id}/replay Trigger replay delivery to your webhook
GET /v1/webhooks/{webhook_id}/deliveries Inspect delivery attempts (debugging)

GET /v1/events

Returns events across all accounts in your organization. Use this for organization-wide reconciliation. For events scoped to a single account, use GET /accounts/{account_id}/activity instead.

Auth:

  • Bearer token required

Query parameters:

Parameter Type Required Description
types string No Comma-separated event type filter (e.g. trade.*, cancel.*)
account_id string No Filter to events for a specific account
since_ms integer No Lower bound by created_at_ms
limit integer No Maximum events to return
cursor string No Pagination cursor from previous response

Response:

{
  "request_id": "req_01J...",
  "events": [ /* event envelopes */ ],
  "has_more": true,
  "next_cursor": "opaque"
}

Each event in the array uses the same envelope as webhook deliveries (see Event format). The account_id field on each event identifies which end user the event belongs to.

Response behavior:

  • 200 event feed page.
  • 400 invalid query parameters.
  • 401 missing or invalid bearer auth.
  • 429 rate limited.

POST /v1/webhooks/{webhook_id}/replay

Request Routes to re-deliver events to your active webhook endpoint.

Auth:

  • Bearer token required

Body:

{
  "since_ms": 1730000000000,
  "types": ["trade.*", "cancel.*"]
}

Response:

{
  "request_id": "req_01J...",
  "status": "scheduled"
}

Replay is also at-least-once. Deduplication is still required.

Response behavior:

  • 200 replay scheduled.
  • 400 invalid replay request body.
  • 401 missing or invalid bearer auth.
  • 404 unknown webhook_id.
  • 429 rate limited.

GET /v1/webhooks/{webhook_id}/deliveries

Inspect recent delivery attempts for debugging. Returns delivery status, HTTP response codes, and timestamps for each attempt.

Auth:

  • Bearer token required

Query parameters:

Parameter Type Required Description
event_id string No Filter to deliveries for a specific event
status string No Filter by delivery status: success, failed, pending
limit integer No Maximum deliveries to return
cursor string No Pagination cursor from previous response

Response:

{
  "request_id": "req_01J...",
  "deliveries": [
    {
      "delivery_id": "dlv_01J...",
      "event_id": "evt_01J...",
      "status": "success",
      "http_status": 200,
      "attempted_at_ms": 1730000006600,
      "next_retry_at_ms": null
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Response behavior:

  • 200 deliveries page.
  • 400 invalid query parameters.
  • 401 missing or invalid bearer auth.
  • 404 unknown webhook_id.
  • 429 rate limited.

Why webhooks, not streaming?

Routes uses push webhooks rather than persistent streaming connections (SSE/WebSocket). Webhooks deliver events to your server as HTTP POST requests — no long-lived connections to manage, no reconnection logic, and they work behind load balancers and CDNs without special configuration. SSE (Server-Sent Events) would require your client to hold an open HTTP connection to Routes indefinitely, handling reconnects, buffering, and backpressure. For real-time pricing, use indicative RFQs with a longer ttl_ms (see RFQs — Indicative vs firm TTL) and poll on your own cadence.

Rotate secrets

After calling POST /v1/webhooks/{webhook_id}/rotate-secret, accept signatures from both the old and new secret for at least 5 minutes before dropping the old one.

  1. Verify signatures and reject invalid requests.
  2. Deduplicate by event_id (idempotent consumer).
  3. Process webhooks immediately and persist:
    • event_id
    • type
    • created_at_ms
    • any referenced IDs (e.g. trade_id)
  4. Maintain a durable watermark:
    • either created_at_ms + a safety overlap window
    • or the next_cursor from GET /v1/events
  5. Periodically reconcile using GET /v1/events to fill any gaps.
  6. For canonical state, fetch the resource:
    • GET /v1/trades/{trade_id}
    • GET /v1/zaps/{zap_id}
    • GET /v1/cancels/{cancel_id}
    • for recovery.claimed, track transfer completion via cancel_id and GET /v1/cancels/{cancel_id}
  7. Monitor settlement object on settled trades/zaps until out_finalized and in_finalized are both true.