Skip to content

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 ----------------->|                          |
  1. You register an HTTPS endpoint URL via PUT /webhooks/outbound.
  2. The platform returns an HMAC-SHA256 signing secret. Store it.
  3. As events occur, they are persisted to a durable outbox.
  4. A background worker delivers each event to your URL with at-least-once semantics. Failed deliveries are retried with exponential back-off.
  5. Your endpoint verifies the signature, processes the event, and returns 2xx quickly.

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:

curl -X POST {{baseUrl}}/webhooks/outbound/test $HEADERS

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:

curl {{baseUrl}}/webhooks/outbound $HEADERS

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 previousStatusstatus and the timestamp if 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 via GET /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.updated for the same transactionId arriving twice with status=Completed → process once, ignore the duplicate.
  • wallet.created arriving 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.