Skip to navigation

Webhooks

Get told when a deposit lands, instead of polling for it.
View as MarkdownOpen in Claude

Every other part of this API is something you ask. Webhooks are the one thing that arrives on its own: when a user receives funds at one of their addresses, we POST a signed event to an HTTPS endpoint you own.

This is the counterpart to the polling loop in Reliability. A deposit has no request of yours behind it, so polling is the only alternative — and that cost grows with every user and every asset you support.

Your webhook endpoint is configured for you by your account manager, along with the signing secret and which event types you want. There is no API call that changes it.

Which deposits produce events

Deposit events are emitted for Ethereum today: native ETH and the ERC-20 assets on it. Deposits on other chains still show up through /balance and /transactions — they just do not push. If you need events on another chain, ask; the catalog grows.

An event is sent only once a deposit has been confirmed, not the moment it appears in a block. A zero-confirmation notification that later disappears is worse for you than a confirmed one a few minutes later.

Event types

TypeWhen
wallet.deposit.confirmedA deposit reached the confirmation depth for its chain and is final as far as this API is concerned.
wallet.deposit.revertedA deposit we had seen in a block was dropped by a reorganisation before it reached the confirmation depth. It was never confirmed, so you were never told to credit it. Do not reverse anything in response to this event.

wallet.deposit.reverted is informational and rare. A confirmed deposit is never reverted: the confirmation depth is chosen so that a reorganisation deeper than it is out of scope. A dropped transaction can be mined again later — in that case a wallet.deposit.confirmed for the same txHash follows the reverted, carrying a different eventId, and it is the one to credit.

The payload

{
"eventId": "8f1c2f3e-5b6a-4d7c-9e01-2a3b4c5d6e7f",
"type": "wallet.deposit.confirmed",
"createdAt": "2026-09-22T12:00:00.000Z",
"data": {
"scope": "user",
"userId": "6512a1b2c3d4e5f607182930",
"chain": "ETHEREUM",
"assetId": 21,
"symbol": "USDT",
"address": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",
"counterparty": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"amount": "125.5",
"txHash": "0x4e3a…",
"blockNumber": 20000000,
"confirmations": 12,
"status": "confirmed"
}
}
FieldNotes
eventIdUnique per event. This is your idempotency key — see below.
typeOne of the types above. Match on this, and ignore any type you do not recognise.
data.scopeuser for one of your users’ wallets, platform for your own platform wallet.
data.userIdPresent on scope: "user" only.
data.assetIdThe same asset ID you pass everywhere else in this API.
data.amountA decimal string in whole units, like /balance returns. Never parse it as a float.
data.counterpartyThe address the funds came from.
data.confirmationsHow many confirmations the deposit had when the event was raised.

New fields may be added to data over time. Ignore fields you do not know rather than rejecting the request.

Headers

HeaderValue
X-Webhook-IdThe eventId, for logging and routing. Not covered by the signature.
X-Webhook-EventThe type, for logging and routing. Not covered by the signature.
X-Webhook-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> — see below.

Verifying the signature

Check the signature before you trust anything in the body. Your endpoint is on the public internet and anyone can POST to it.

The signature is computed over "<t>.<raw request body>", keyed by your signing secret:

import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Reject anything older than five minutes: a valid signature stays valid
// forever, so the timestamp is what stops a captured body being replayed.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Three things that break this in practice:

  • Sign the raw bytes, not a re-serialised object. If your framework parses JSON before you see it, key ordering and whitespace change and the HMAC will not match. Capture the body as a string or buffer first.
  • Compare in constant time. == on a hex string leaks how much of the signature you matched.
  • Enforce the timestamp. Without it, one captured request can be replayed at any point in the future.

Responding

Return any 2xx as soon as you have stored the event. Do the real work afterwards, on your own time.

  • Anything that is not a 2xx counts as a failure and is retried.
  • Redirects are not followed. A 301 or 302 is a failure — give us the final URL.
  • Your endpoint must be HTTPS, and must not resolve to a private or loopback address.
  • We stop reading your response body after a small number of bytes; there is no way to send data back through it.

Retries

A failed delivery is retried on a fixed schedule — 8 attempts over about 21 hours:

AttemptAfter
1immediately
230 seconds
32 minutes
410 minutes
530 minutes
62 hours
76 hours
812 hours

After the eighth failure the event stops being retried. It is not lost — it can be replayed for you on request — but a sustained outage on your side needs a conversation, not a ninth attempt.

Idempotency and ordering

Delivery is at-least-once. The same event can arrive more than once — after a network timeout where your 2xx never reached us, or a retry that crossed with a slow success. Exactly-once delivery is not on offer over a network, and designing as though it were is how double credits happen.

Dedupe on eventId from the verified body, never on the X-Webhook-Id header. The signature covers the body only, so a replayed delivery with a rewritten header would still verify; the eventId, type and data inside the body are the only values you can trust. Record the eventId before you act, and make the second arrival a no-op:

INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT DO NOTHING;

If that inserted nothing, you have already handled this event — return 2xx and stop.

Order is not guaranteed. Two deposits to the same user can arrive in either order, and a retry of an older event can land after a newer one. Use data.blockNumber and createdAt if sequence matters to you; never infer it from arrival order. The same applies to one transaction: a reverted can arrive after the confirmed that superseded it, which is harmless as long as reverted never debits.

A webhook is a notification, not the source of truth. Reconcile against /balance and /transactions — if you have missed events during an outage, that is what closes the gap.