Webhooks¶
The platform delivers real-time event notifications to your backend via
outbound webhooks. When something happens — a transaction is created, a wallet
is provisioned, a balance changes — the platform sends an HTTP POST to a
URL you configure, signed with HMAC-SHA256.
This guide covers configuration, signature verification, the event catalog with payload schemas, delivery semantics, and best practices.
How it works¶
Your backend the platform Outbox worker
| | |
|-- PUT /webhooks/outbound| |
| url + signing secret | |
| | |
| (event happens) |
| |-- enqueue to outbox ---->|
| | |
| | (every ~5 s) |
|<-- POST {payload}, X-Webhook-Signature ------------|
|-- 2xx ----------------->| |
- You register an HTTPS endpoint URL via
PUT /webhooks/outbound. - The platform returns an HMAC-SHA256 signing secret. Store it.
- As events occur, they are persisted to a durable outbox.
- A background worker delivers each event to your URL with at-least-once semantics. Failed deliveries are retried with exponential back-off.
- Your endpoint verifies the signature, processes the event, and returns
2xxquickly.
Setup¶
1. Configure the URL¶
curl -X PUT {{baseUrl}}/webhooks/outbound \
$HEADERS \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-backend.example.com/webhooks/custody" }'
Response:
{
"url": "https://your-backend.example.com/webhooks/custody",
"signingSecret": "a1b2c3d4...base64-encoded-secret...",
"subscribedEventTypes": [
"transaction.created",
"transaction.status.updated",
"wallet.created",
"balance.updated"
],
"isConfigured": true
}
The signing secret is shown only once
The signingSecret is returned only on the request that generates it
— initial configuration, or whenever you rotate the webhook URL. It is
never echoed back on GET /webhooks/outbound, and re-PUTting the
same URL returns signingSecret: null (the secret is preserved
server-side but you can't re-view it). Store it securely (env var,
secrets manager) the moment you receive it. Lost the secret? Rotate the
URL — that generates a new one.
2. Subscribe to specific events (optional)¶
By default, all event types are delivered. To narrow the subscription:
curl -X PUT {{baseUrl}}/webhooks/outbound \
$HEADERS \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-backend.example.com/webhooks/custody",
"subscribedEventTypes": ["transaction.status.updated", "balance.updated"]
}'
3. Send a test event¶
Verify your endpoint receives and verifies a delivery:
The platform delivers a synthetic webhook.test event to your URL using
the same signing flow as real events.
4. Read the current configuration¶
Fetch your tenant's currently-configured webhook URL and subscribed event types:
Response:
{
"url": "https://your-backend.example.com/webhooks/custody",
"signingSecret": null,
"isConfigured": true,
"subscribedEventTypes": [
"transaction.created",
"transaction.status.updated",
"wallet.created",
"balance.updated"
]
}
signingSecret is always null on this endpoint — see the warning
above. isConfigured is true once both a URL and a secret have been set
server-side. subscribedEventTypes is null when the tenant is subscribed
to all event types (the default).
Outbound IP allowlist¶
Every webhook delivery originates from a small, stable set of source IPs. If your backend sits behind a firewall or a managed-WAF that rejects unknown sources, allowlist these:
31.210.65.157
Why allowlist on top of HMAC?
The HMAC signature alone proves the payload was minted by someone with the signing secret — it doesn't prove the request reached you from us. An IP allowlist closes that gap cheaply: an attacker who leaks neither the signing secret nor compromises a host on our egress range can't even open a TCP connection to your endpoint.
The list is intentionally short and stable — we don't rotate it without notice. Subscribe to the API status page (or whichever channel your account manager pointed you at) to hear about additions before they ship; we hold every change ≥7 days before flipping traffic so you can update your firewall in lockstep.
Envelope¶
Every delivery has the same outer shape:
{
"eventType": "transaction.status.updated",
"entityType": "transaction",
"entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
"timestamp": "2026-04-26T18:45:12.337Z",
"data": { /* event-specific payload — see catalog below */ }
}
| Field | Description |
|---|---|
eventType |
Dotted name from the catalog. Use this to dispatch in your code. |
entityType |
Logical entity the event is about (transaction, wallet, balance). |
entityId |
Stable ID of the entity. Use it for idempotency (see below). |
timestamp |
When the event was emitted (server UTC, ISO-8601). |
data |
Event-specific payload. |
Signature verification¶
Each delivery includes an X-Webhook-Signature header — lowercase hex
HMAC-SHA256 of the raw response body, using your stored signingSecret.
You must verify the signature on every delivery before trusting the
payload. Reject with 401 if it doesn't match.
Node.js¶
const crypto = require('crypto');
function verifyWebhook(rawBody, headerSignature, signingSecret) {
const expected = crypto
.createHmac('sha256', signingSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(headerSignature, 'hex')
);
}
// Express handler — use `express.raw` so `req.body` is a Buffer (raw bytes).
app.post('/webhooks/custody',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyWebhook(
req.body,
req.header('X-Webhook-Signature'),
process.env.WEBHOOK_SIGNING_SECRET
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString());
// ... handle event
res.status(200).end();
}
);
Python (Flask)¶
import hmac, hashlib
from flask import request, abort
@app.route('/webhooks/custody', methods=['POST'])
def webhook():
raw = request.get_data() # raw bytes BEFORE JSON parse
sig = request.headers.get('X-Webhook-Signature', '')
expected = hmac.new(
SIGNING_SECRET.encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
event = request.get_json()
# ... handle event
return '', 200
C¶
using System.Security.Cryptography;
using System.Text;
[HttpPost("/webhooks/custody")]
public async Task<IActionResult> Receive()
{
Request.EnableBuffering();
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
var raw = ms.ToArray();
var headerSig = Request.Headers["X-Webhook-Signature"].ToString();
var expected = Convert.ToHexStringLower(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(_signingSecret), raw));
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected),
Encoding.ASCII.GetBytes(headerSig)))
return Unauthorized();
var event = JsonSerializer.Deserialize<WebhookEnvelope>(raw);
// ... handle event
return Ok();
}
Always sign over raw bytes
Verify the signature against the raw request body, before any JSON parser touches it. Reformatting (whitespace, key reordering) breaks the signature.
Event catalog¶
Four event types ship today. Get the live list any time via
GET /webhooks/events/catalog.
transaction.created¶
Fired when a new transaction is created (any type — deposit detected, internal transfer submitted, withdrawal submitted).
{
"eventType": "transaction.created",
"entityType": "transaction",
"entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
"timestamp": "2026-04-26T18:45:12.337Z",
"data": {
"transactionId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
"type": "Withdrawal",
"status": "Submitted",
"vaultId": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
"vaultExternalId": "cust_12345",
"vaultName": "Alice Johnson",
"assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
"assetSymbol": "ETH",
"assetNetwork": "Ethereum",
"sourceWalletId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
"destinationWalletId": null,
"amount": "0.5000",
"sourceAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"destinationAddress": "0xABC1234567890DEF1234567890abcDeF12345678",
"destinationTag": null,
"txHash": null,
"networkFee": null,
"feeCurrency": null,
"amountUSD": "1842.50",
"feeUSD": null,
"note": "Monthly treasury sweep",
"createdAt": "2026-04-26T18:45:12.000Z"
}
}
| Field | Type | Notes |
|---|---|---|
transactionId |
uuid | Same as envelope entityId. |
type |
enum | Deposit | Withdrawal | InternalTransfer. |
status |
enum | Initial status (always Submitted for newly-created). |
vaultId / vaultExternalId / vaultName |
— | Vault context. vaultExternalId is the value you supplied at vault creation. |
assetId / assetSymbol / assetNetwork |
— | Asset context (e.g. "ETH" on "Ethereum"). |
sourceWalletId |
uuid? | Null for inbound deposits. |
destinationWalletId |
uuid? | Set for InternalTransfer; null for Withdrawal/Deposit. |
amount |
decimal-string | Asset units, e.g. "0.5000". |
sourceAddress / destinationAddress |
string | On-chain addresses. |
destinationTag |
string? | Memo / tag (XRP, XLM, etc.). |
txHash |
string? | On-chain hash; usually null at creation, populated later. |
networkFee / feeCurrency |
— | Populated when the network charges a fee. |
amountUSD / feeUSD |
decimal-string? | USD values at the time of event (best-effort). |
note |
string? | Free-form note from the create request. |
createdAt |
timestamp | When the transaction record was created. |
transaction.status.updated¶
Fired every time a transaction's status changes — Submitted →
PendingSignature → Broadcasting → Confirming → Completed (or Failed /
Cancelled). Watching this is the primary way to know when a deposit or
withdrawal is final.
{
"eventType": "transaction.status.updated",
"entityType": "transaction",
"entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
"timestamp": "2026-04-26T18:46:30.118Z",
"data": {
"transactionId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
"type": "Withdrawal",
"status": "Completed",
"previousStatus": "Confirming",
"vaultId": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
"vaultExternalId": "cust_12345",
"vaultName": "Alice Johnson",
"assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
"assetSymbol": "ETH",
"assetNetwork": "Ethereum",
"sourceWalletId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
"amount": "0.5000",
"sourceAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"destinationAddress": "0xABC1234567890DEF1234567890abcDeF12345678",
"txHash": "0x9af7b0c3...e4d2",
"networkFee": "0.000421",
"feeCurrency": "ETH",
"amountUSD": "1842.50",
"feeUSD": "1.05",
"failureReason": null,
"completedAt": "2026-04-26T18:46:28.500Z"
}
}
| Field | Notes |
|---|---|
status / previousStatus |
Use status === 'Completed' to credit the customer; status === 'Failed' (with failureReason) to refund/notify. |
txHash |
Populated once broadcast — link to a block explorer for end-customers. |
failureReason |
Populated only on Failed. |
completedAt |
Populated when the transaction reaches a terminal state. |
wallet.created¶
Fired when a new wallet is provisioned for a vault.
{
"eventType": "wallet.created",
"entityType": "wallet",
"entityId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
"timestamp": "2026-04-26T18:30:14.502Z",
"data": {
"walletId": "f4a5b6c7-d8e9-0123-fabc-456789abcdef",
"vaultId": "d1e2f3a4-b5c6-7890-d1e2-f3a4b5c67890",
"assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc",
"label": "Primary ETH Wallet",
"depositAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
"depositTag": null,
"status": "Active",
"createdAt": "2026-04-26T18:30:14.000Z"
}
}
| Field | Notes |
|---|---|
depositAddress |
Address to share with the end-customer for funding. |
depositTag |
Memo/tag for chains that require it (XRP, XLM, BNB Beacon, etc.); null otherwise. |
status |
Always Active on creation. |
balance.updated¶
Fired when a wallet's balance changes for any reason — confirmed deposit,
confirmed withdrawal, internal transfer in/out, on-chain rebase. The payload
is intentionally minimal: it only tells you which balance changed, so
you can re-fetch the authoritative numbers via GET /wallets/{id}/balance.
{
"eventType": "balance.updated",
"entityType": "balance",
"entityId": "11223344-5566-7788-99aa-bbccddeeff00:c1d2e3f4-a5b6-7890-cdef-123456789abc",
"timestamp": "2026-04-26T18:46:30.901Z",
"data": {
"vaultAccountId": "11223344-5566-7788-99aa-bbccddeeff00",
"assetId": "c1d2e3f4-a5b6-7890-cdef-123456789abc"
}
}
Why so small?
Balances change frequently and would be racy to embed in the event. Treat
balance.updated as a cache-invalidation signal — when you receive
it, refresh that wallet's balance from the API.
Delivery semantics¶
- At-least-once. Network blips, brief 5xx responses from your endpoint, or worker crashes can result in the same event delivered more than once. Make your handler idempotent (see below).
- Per-event ordering is best-effort but not guaranteed. A burst of status
changes for the same transaction can arrive out-of-order. Compare
previousStatus→statusand thetimestampif order matters. - Retries: failed deliveries (anything that isn't
2xx, plus network errors) are retried with exponential back-off at roughly 10 s, 30 s, 60 s, 5 min, 15 min. After 5 failed attempts the delivery is marked dead and surfaced viaGET /webhooks/deliveries. - Timeout: each HTTP attempt has a 5-second timeout. Slow handlers cause retries.
Best practices¶
Respond fast, work async¶
Return 2xx within a second. Push the actual processing into a queue or
background job. A slow handler causes timeouts and retries — and processing
the same event multiple times.
Idempotency: dedupe by (eventType, entityId, status)¶
The same event can be delivered more than once. Store a row keyed on a hash
of the payload (or on (eventType, entityId, data.status) for transactions)
the first time you process it; skip on the second. Examples:
transaction.status.updatedfor the sametransactionIdarriving twice withstatus=Completed→ process once, ignore the duplicate.wallet.createdarriving twice → only create your downstream record once.
Verify the signature on every request¶
Skipping verification = anyone who guesses your URL can forge events at you.
Re-fetch authoritative state¶
For balance.updated and after transaction.status.updated → Completed,
read the current state from the API (/wallets/{id}/balance,
/transactions/{id}). Webhook payloads are snapshots; the API is the
source of truth.
Subscribe to only what you need¶
Narrow subscribedEventTypes to the events your code actually consumes —
fewer deliveries, fewer chances of bugs.
Inspecting deliveries¶
GET /webhooks/deliveries lists recent delivery attempts and their
status (pending, delivered, failed, dead). Useful for debugging when
events seem to be missing.
GET /webhooks/deliveries/{id} returns the full detail for a single
delivery — the original payload, every retry attempt with HTTP status,
response body, error message and duration. Use it to figure out exactly
why a failed or dead delivery couldn't reach your endpoint.
{
"id": "9f8e7d6c-5b4a-3210-9876-543210fedcba",
"eventType": "transaction.status.updated",
"entityType": "transaction",
"entityId": "b6c7d8e9-f0a1-2345-bcde-6789abcdef01",
"payload": "{\"eventType\":\"transaction.status.updated\", ...}",
"status": "failed",
"attemptCount": 3,
"lastAttemptAt": "2026-04-26T18:51:30.118Z",
"lastError": "HTTP 503 from your endpoint",
"createdAt": "2026-04-26T18:46:30.118Z",
"deliveredAt": null,
"attempts": [
{
"id": "...",
"attemptNumber": 1,
"requestUrl": "https://your-backend.example.com/webhooks/custody",
"httpStatusCode": 503,
"responseBody": "service unavailable",
"errorMessage": null,
"durationMs": 412,
"attemptedAt": "2026-04-26T18:46:30.530Z",
"success": false
}
]
}
POST /webhooks/deliveries/{id}/retry manually re-queues a failed or
dead delivery.
GET /webhooks/events/catalog returns the live list of every event type
with descriptions — keep this synced with your handler if you want to assert
against new event types in CI.