> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.walletstech.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.walletstech.com/_mcp/server.

# Webhooks

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](/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.

> **Note**
>
> 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

| Type                       | When                                                                                                                                                                                                                               |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet.deposit.confirmed` | A deposit reached the confirmation depth for its chain and is final as far as this API is concerned.                                                                                                                               |
| `wallet.deposit.reverted`  | A 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

```json
{
  "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"
  }
}
```

| Field                | Notes                                                                                    |
| -------------------- | ---------------------------------------------------------------------------------------- |
| `eventId`            | Unique per event. **This is your idempotency key** — see below.                          |
| `type`               | One of the types above. Match on this, and ignore any type you do not recognise.         |
| `data.scope`         | `user` for one of your users' wallets, `platform` for your own platform wallet.          |
| `data.userId`        | Present on `scope: "user"` only.                                                         |
| `data.assetId`       | The same [asset ID](/assets) you pass everywhere else in this API.                       |
| `data.amount`        | A **decimal string** in whole units, like `/balance` returns. Never parse it as a float. |
| `data.counterparty`  | The address the funds came from.                                                         |
| `data.confirmations` | How 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

| Header                | Value                                                                     |
| --------------------- | ------------------------------------------------------------------------- |
| `X-Webhook-Id`        | The `eventId`, for logging and routing. **Not covered by the signature.** |
| `X-Webhook-Event`     | The `type`, for logging and routing. **Not covered by the signature.**    |
| `X-Webhook-Signature` | `t=<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:

**`Node.js`**

```javascript title="Node.js"
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);
}
```

**`Python`**

```python title="Python"
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False

    # Reject anything older than five minutes.
    if abs(time.time() - timestamp) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

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**:

| Attempt | After       |
| ------- | ----------- |
| 1       | immediately |
| 2       | 30 seconds  |
| 3       | 2 minutes   |
| 4       | 10 minutes  |
| 5       | 30 minutes  |
| 6       | 2 hours     |
| 7       | 6 hours     |
| 8       | 12 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:

```sql
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.