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:
200withwebhookobject and one-timesecret.400validation error (for example malformed URL).401missing or invalid bearer auth.429rate limited.
GET /v1/webhooks¶
Lists webhook subscriptions for your organization.
Auth:
- Bearer token required
Response behavior:
200with webhook resources.secretis omitted.401missing or invalid bearer auth.429rate 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:
200with updated webhook resource.secretis omitted.400validation error.401missing or invalid bearer auth.404unknownwebhook_id.429rate limited.
POST /v1/webhooks/{webhook_id}/rotate-secret¶
Rotates the signing secret for an existing webhook.
Auth:
- Bearer token required
Response behavior:
200with new one-timesecret.401missing or invalid bearer auth.404unknownwebhook_id.429rate limited.
Signature verification¶
Each webhook request is signed. Verify the signature before processing the event.
Headers¶
Every webhook delivery includes:
Routes-Webhook-IdRoutes-Event-IdRoutes-Delivery-IdRoutes-Timestamp— milliseconds since epochRoutes-Signature—v1=<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 Routesaccount_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.created—label,external_idaccount.wallet_created—chain,address,asset_keyaccount.deactivated— no additional data fieldsaccount.export_initiated—export_source("integrator"when initiated via authenticatedPOST /export-keys,"user_email"when initiated via public OTP flow)account.key_exported—keysarray (each entry containingchain,address, andexport_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 bycreated_at_ms, but consumers should still tolerate reordering.
Delivery and retries¶
- A delivery is considered successful when your endpoint returns any
2xxstatus code. - Non-
2xxresponses, 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:
- Read your last persisted cursor or watermark.
- Call
GET /v1/events?cursor=<last_cursor>and process all missed events (apply the same dedup logic as webhook delivery). - Optionally call
POST /v1/webhooks/{webhook_id}/replayto 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:
- Verify the signature (reject
401if invalid) - Check
event_idagainst your store — skip if already processed - Persist the event
- Return
2xximmediately — 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:
200event feed page.400invalid query parameters.401missing or invalid bearer auth.429rate 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:
200replay scheduled.400invalid replay request body.401missing or invalid bearer auth.404unknownwebhook_id.429rate 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:
200deliveries page.400invalid query parameters.401missing or invalid bearer auth.404unknownwebhook_id.429rate 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.
Recommended reliability pattern¶
- Verify signatures and reject invalid requests.
- Deduplicate by
event_id(idempotent consumer). - Process webhooks immediately and persist:
event_idtypecreated_at_ms- any referenced IDs (e.g.
trade_id)
- Maintain a durable watermark:
- either
created_at_ms+ a safety overlap window - or the
next_cursorfromGET /v1/events
- either
- Periodically reconcile using
GET /v1/eventsto fill any gaps. - 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 viacancel_idandGET /v1/cancels/{cancel_id}
- Monitor
settlementobject on settled trades/zaps untilout_finalizedandin_finalizedare bothtrue.