> For the complete documentation index, see [llms.txt](https://docs.dutchmanbridge.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.dutchmanbridge.com/partner-integration/partner-webhooks.md).

# Webhooks

Dutchman Bridge notifies your backend when something happens to one of your customers: a deposit lands, KYC changes state, a withdrawal completes. You register a URL once, verify each delivery's signature, and reply `2xx`.

Everything on this page can be driven from the Partner API itself under `/partner/v1/webhook` (x-api-key auth) — see [Self-serve endpoints](#self-serve-endpoints).

**Dutchman Bridge never emails or messages your customers.** Every event below is your cue to tell them yourself, in your own brand and channel — a receipt for a completed withdrawal, a "you're verified" note, a failure with the reason. If your endpoint was down, the retries and [replay](#post-deliveriesidreplay) mean nothing is lost.

## Envelope

Every delivery is a `POST` with a JSON body of exactly this shape:

```json
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "type": "deposit.received",
  "createdAt": "2026-09-10T13:00:00.000Z",
  "data": {
    "customerId": "c7c2…",
    "asset": "USDT",
    "network": "polygon",
    "amount": 25,
    "txHash": "0x…",
    "address": "0x…"
  }
}
```

| Field       | Meaning                                                                            |
| ----------- | ---------------------------------------------------------------------------------- |
| `id`        | Unique per event. Stays the same across retries and replays — **dedupe on it**.    |
| `type`      | One of the [event types](#event-catalogue).                                        |
| `createdAt` | When the event was recorded (ISO 8601), not when this particular attempt was sent. |
| `data`      | The event's own payload. Every customer event carries `customerId`.                |

Headers:

| Header                 | Value                                    |
| ---------------------- | ---------------------------------------- |
| `Content-Type`         | `application/json`                       |
| `X-Dutchman-Event-Id`  | Same as `id` in the body.                |
| `X-Dutchman-Signature` | `t=<unix seconds>,v1=<hex>` — see below. |

## Verifying the signature

Each delivery is signed with your webhook secret using HMAC-SHA256 over the string `"<t>.<rawBody>"`, where `t` is the timestamp in the header and `rawBody` is the exact request body bytes. **Hash the raw body, not a re-serialised object** — any reformatting changes the bytes.

```js
// Node / Express. Mount with express.raw({ type: 'application/json' }) so
// req.body is a Buffer of the exact bytes we signed.
const crypto = require('crypto');

const SECRET = process.env.DUTCHMAN_WEBHOOK_SECRET; // from PUT /partner/v1/webhook
const TOLERANCE_SECONDS = 5 * 60;

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=')),
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${t}.${rawBody.toString('utf8')}`)
    .digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  const want = Buffer.from(expected, 'hex');
  return given.length === want.length && crypto.timingSafeEqual(given, want);
}

app.post(
  '/dutchman/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verify(req.body, req.get('X-Dutchman-Signature') ?? '')) {
      return res.status(401).end();
    }
    const event = JSON.parse(req.body.toString('utf8'));
    if (alreadyProcessed(event.id)) return res.status(200).end(); // duplicate
    handle(event); // your logic
    markProcessed(event.id);
    res.status(200).end();
  },
);
```

Reject anything whose `t` is far from now (a few minutes is plenty): the timestamp is part of what is signed, so a captured request cannot be replayed later with a different time.

## Responding, retries, and the replay window

* Answer **`2xx`** as soon as you have durably accepted the event — do the real work afterwards. We time out after **10 seconds**.
* Anything else (non-2xx, timeout, connection error) is retried on this schedule, measured from the previous attempt:

  | Attempt | Delay before it |
  | ------- | --------------- |
  | 2       | 30 seconds      |
  | 3       | 2 minutes       |
  | 4       | 10 minutes      |
  | 5       | 30 minutes      |
  | 6       | 2 hours         |
  | 7       | 6 hours         |
  | 8       | 12 hours        |

  After the 8th failed attempt (roughly 21 hours in total) the delivery is marked **`DEAD`** and not retried on its own.
* Answer **`410 Gone`** to tell us the endpoint is retired; that delivery goes `DEAD` immediately without retries.
* A `DEAD` (or any) delivery can be sent again with [`POST /deliveries/{id}/replay`](#post-deliveriesidreplay). It goes out with the **same `id`**, so your dedupe check handles the case where the original did land after all.
* Deliveries are ordered per event, not globally: two events for the same customer may arrive out of order under retries. Read `createdAt`, or re-fetch the resource, rather than assuming the last-received one is the latest.

Idempotency in one line: **treat `id` as the unit of work, and `2xx` a duplicate without acting on it.**

## Event catalogue

`data` for every customer event includes `customerId`. Where an event mirrors an API resource, `data` is the same object the corresponding `GET` returns, so one parser serves both.

### Customer

| Type                  | When                                       | `data`                                                               |
| --------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
| `customer.registered` | A customer was created under your account. | `{ customerId, email, firstName, lastName, phoneNumber, createdAt }` |

### KYC

| Type                           | When                                                                              | `data`                                                                                                                           |
| ------------------------------ | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `kyc.status_updated`           | A tier submission was auto-decided, queued for review, or decided by an operator. | `{ customerId, kycId, level, status, reason? }` — `status` is `APPROVED`, `REJECTED` or `REVIEW`; `level` is the tier (1, 2, …). |
| `kyc.reverification_requested` | Compliance needs the customer to redo part of KYC.                                | `{ customerId, items, message }`                                                                                                 |

### Funding

| Type               | When                                                                                      | `data`                                                                                             |
| ------------------ | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `deposit.received` | A stablecoin deposit to the customer's wallet confirmed and was credited.                 | `{ customerId, asset, network, amount, txHash, address }`                                          |
| `funding.received` | Fiat landed on the customer's balance — a virtual-account transfer or a card/bank charge. | `{ customerId, currency, amount, reference, method }` — `method` is `virtual_account` or `charge`. |

### Withdrawals

`data` is the withdrawal object from `GET /partner/v1/customers/{customerId}/withdrawals`: `{ customerId, id, reference, amount, currency, status, failureReason, bankAccount, createdAt, updatedAt }`.

| Type                   | When                                                                                        |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| `withdrawal.submitted` | Accepted and the balance reserved. `status` is `PROCESSING`. Fires once per withdrawal.     |
| `withdrawal.completed` | The bank confirmed the payout.                                                              |
| `withdrawal.failed`    | The rail rejected it; the reserved amount is back on the balance. `failureReason` says why. |
| `withdrawal.reversed`  | An operator reversed it by hand; the amount is back on the balance.                         |

### Send (on-chain)

`data` is the send object `POST /partner/v1/customers/{customerId}/send` returns: `{ customerId, reference, asset, network, amount, fee, netAmount, toAddress, txHash, status }`. `txHash` is `null` at submission and stays `null` until the chain hash reaches us.

**Every send starts with `send.pending`.** We do not broadcast: the exchange that custodies the funds does, so at submission there is no transaction hash to give you. The send is accepted and the amount reserved, and it ends in `send.confirmed` or `send.failed`.

Listen for `send.pending`, not `send.broadcast`. `send.broadcast` is retained below only because a send submitted before this change can still arrive on a retry; it is no longer emitted for new sends. If your integration keys on `send.broadcast` alone, it will not see sends.

| Type             | When                                                                                                                                                       |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `send.pending`   | Accepted and the amount reserved. The exchange is delivering it; no transaction hash yet. **This is how every send now opens.**                            |
| `send.confirmed` | It was mined successfully, or the exchange confirmed delivery.                                                                                             |
| `send.failed`    | It was rejected before anything moved, reverted on-chain, dropped from the mempool, or could not be delivered. The reserved amount is back on the balance. |
| `send.broadcast` | *Deprecated.* Emitted only by sends submitted before sending moved to the exchange. Keep handling it if you have retries outstanding; do not build on it.  |

### Global send (cross-border)

`data` is the object `POST /partner/v1/customers/{customerId}/global-send` returns: `{ customerId, reference, corridor, status, destinationCurrency, asset, assetAmount, partnerFee, transferFee, settledBy, purpose, purposeNote }`. `transferFee` is the cross-border transfer fee taken out of the amount sent before conversion (0 when none applies, and always 0 on the domestic corridor, which pays the withdrawal fee). `purpose` is the reason given on initiate (one of `FAMILY_SUPPORT`, `EDUCATION`, `MEDICAL`, `GOODS_PAYMENT`, `SERVICES_PAYMENT`, `SALARY`, `TRAVEL`, `RENT_PROPERTY`, `GIFT`, `OWN_ACCOUNT_TRANSFER`, `OTHER`), always set on a cross-border send and `null` on a domestic send that gave none; `purposeNote` is the customer's own words, always set when `purpose` is `OTHER`.

| Type                    | When                                                                                                                                  |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `global_send.initiated` | Accepted; the sender's funds were converted/reserved. A domestic send also emits the `withdrawal.*` events for the underlying payout. |
| `global_send.completed` | The provider confirmed delivery.                                                                                                      |
| `global_send.failed`    | The provider (or an operator) failed it.                                                                                              |

Every corridor emits the same three, domestic (NG) included — branch on `reference`, which is the one key for the send on every event.

One more event in this family is about the customer rather than a send:

| Type                  | When                                                                                                       | `data`                                                                                                                                                                                                                      |
| --------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `global_send.enabled` | A payout partner approved the customer for a destination group (sent once per group, when it is approved). | `{ customerId, groups, newGroups }` — `groups` lists every approved destination group (`americas_europe`, `china`), `newGroups` the ones this event announces. Re-read `GET .../international-status` for the full picture. |

`reference`-keyed handling does not apply to it: it carries no `reference`. A domestic send additionally emits the `withdrawal.*` events for the payout underneath it, under that same reference; they are finer-grained progress, not a separate payout. Retrying `POST .../global-send` with a `reference` you have already used returns the original send and moves no money a second time.

### Buy and sell (balance conversions)

`data` is the object the execute call returns.

| Type                 | When                                                                                                      | `data`                                                             |
| -------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `sale.completed`     | USDT on the customer's balance was sold for their local currency; the proceeds are on their fiat balance. | `{ customerId, reference, usdtSold, fiatCurrency, fiatCredited }`  |
| `purchase.completed` | Local currency on the customer's balance was used to buy USDT.                                            | `{ customerId, reference, fiatCurrency, fiatSpent, usdtCredited }` |

### Partner account

Emitted about your own account rather than a customer; `data` carries `partnerId` instead of `customerId`.

| Type                             | `data`                                                                          |
| -------------------------------- | ------------------------------------------------------------------------------- |
| `partner.approved`               | `{ partnerId, status: 'APPROVED' }`                                             |
| `partner.rejected`               | `{ partnerId, status: 'REJECTED', reason }`                                     |
| `partner.resubmission_requested` | `{ partnerId, status: 'PENDING', reason }`                                      |
| `webhook.test`                   | `{ partnerId }` — sent synchronously when you set the URL or rotate the secret. |

New event types may be added at any time. Ignore types you do not recognise rather than failing the delivery.

## Self-serve endpoints

All under `/partner/v1/webhook`, authenticated with your `x-api-key`, and wrapped in the standard `{ success, data }` envelope.

### `GET /`

Your current configuration: `{ url, hasSecret, updatedAt }`. The secret itself is never returned here.

### `PUT /`

Body `{ "url": "https://…" }`. Validates the URL (absolute; `https` and a public host in production), **mints a new secret**, stores it encrypted, and immediately POSTs a signed `webhook.test` event to it. Returns:

```json
{
  "url": "https://partner.example/dutchman/webhooks",
  "secret": "3f9a…64 hex chars…",
  "test": { "delivered": true, "statusCode": 200, "error": null }
}
```

`secret` is shown **only in this response** — store it now. A previous secret stops working the moment this call succeeds. `test.delivered === false` does not fail the call; fix the endpoint and use `rotate-secret` or a replay to try again.

### `POST /rotate-secret`

Same response as `PUT /`, keeping the URL. Deliveries still queued are signed with the new secret.

### `GET /deliveries?status&limit&cursor`

Your delivery log, newest first, cursor-paginated. `status` filters on `PENDING`, `DELIVERED` or `DEAD`. Each item:

```json
{
  "id": "9b1deb4d-…",
  "type": "deposit.received",
  "status": "DEAD",
  "attempts": 8,
  "nextAttemptAt": "…",
  "lastAttemptAt": "…",
  "lastResponseStatus": 503,
  "lastError": "Partner responded 503",
  "createdAt": "…",
  "deliveredAt": null
}
```

`id` is the event id (the envelope `id` / `X-Dutchman-Event-Id`).

### `GET /deliveries/{id}`

One delivery, plus `payload`: the full envelope exactly as it was (or will be) POSTed.

### `POST /deliveries/{id}/replay`

Queues the delivery for immediate re-send **with the same event id**, from whatever state it is in. Use it after fixing an endpoint that was returning errors, or to re-drive a handler that lost the event on your side. Answers the requeued delivery (`status: "PENDING"`).

## Console route

The partner console's `POST /partner/update-webhook` (JWT) still works and applies the same URL rules; the secret you supply there is encrypted at rest just like a minted one. Prefer the self-serve endpoints above for new integrations.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.dutchmanbridge.com/partner-integration/partner-webhooks.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
