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

# Webhooks

Swapper sends a **signed, server-to-server webhook** when a deposit completes. Use it as your reliable source of truth for crediting accounts — unlike a [widget event](/tracking-deposits/widget-events.md), a webhook isn't lost when the user closes the tab, and it's retried on failure.

Webhooks cover deposits made through the **widget** and transfers quoted through [**API v1**](/api-v1/overview.md). A v1 transfer is recorded — and its webhook sent — once it has been tracked, so API integrations should keep polling [`GET /v1/status`](/api-v1/endpoints/status.md) after the user broadcasts; v1 transfers arrive with `method: "dex_swap"` (and so `flow: "wallet_deposit"`). Deposits to a [v1 deposit address](/api-v1/endpoints/deposit-addresses.md) arrive with `flow: "transfer_crypto"`, like the widget's QR deposits — identify them by `flow`, as their `method` can change.

## Setup

Configure webhooks yourself in the [Integrator Dashboard](/integrator-dashboard/overview.md#webhooks), under **Webhooks** (owners only):

1. **Add your endpoint URL(s)** — up to **5**. Every event is sent to each of them.
2. **Generate a signing secret** and store it on your server. Every delivery is then signed with it (see [Verifying the signature](#verifying-the-signature)). Without a secret, deliveries are sent **unsigned**.
3. **Watch Recent deliveries** on the same page to confirm events are going out.

Webhooks are configured per integrator, not per widget session.

{% hint style="warning" %}
A `webhookUrl` passed to the widget is **ignored** — Swapper never sends webhooks to a URL set in the widget configuration. Configure your endpoints in the dashboard.
{% endhint %}

Your endpoint must be:

* served over **HTTPS** on a **public** host (private and local addresses are refused);
* **publicly reachable** from the internet;
* **fast** — return HTTP 2xx within **10 seconds** (slow responses count as a failure and trigger a retry);
* **answering at the configured URL** — only `307`/`308` redirects are followed (up to 3, each to a public host). Any other redirect (`301`, `302`, `303`) counts as a failed delivery, so configure the final URL.

```js
app.post("/webhooks/deposit", (req, res) => {
  const { eventId, eventType, data } = req.body;
  const { deposit } = data;
  console.log("Deposit completed:", deposit.id, deposit.txHash);
  res.json({ received: true });
});
```

### The request

Each delivery is an HTTP `POST` with these headers:

| Header                | Value                                                                    |
| --------------------- | ------------------------------------------------------------------------ |
| `Content-Type`        | `application/json`                                                       |
| `User-Agent`          | `Swapper-Deposits-Webhook/1.0`                                           |
| `X-Webhook-Signature` | Base64 HMAC-SHA256 of the body — present when you have a signing secret. |

## The event

`transaction.completed` is the **only** event type. You are notified once, when a deposit completes — there are no `created` / `processing` / `failed` events.

### Top-level fields

| Field               | Type    | Description                            |
| ------------------- | ------- | -------------------------------------- |
| `eventId`           | string  | Unique event id — use for idempotency. |
| `eventType`         | string  | Always `transaction.completed`.        |
| `timestamp`         | string  | ISO 8601 timestamp of the event.       |
| `data.deposit`      | Deposit | The deposit (below).                   |
| `data.integratorId` | string  | Your integrator id.                    |

### Deposit object

| Field                | Type         | Description                                                                                                                                                                                                 |
| -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                 | string       | Unique deposit id.                                                                                                                                                                                          |
| `method`             | string       | How the deposit was settled: `onramp` \| `smart_wallet` \| `dex_swap` \| `relay_deposit_address` (see [methods](#deposit-methods)).                                                                         |
| `flow`               | string?      | The widget flow the deposit came from: `deposit_with_fiat` \| `transfer_crypto` \| `wallet_deposit`.                                                                                                        |
| `status`             | string       | Lifecycle status — `completed` on this event.                                                                                                                                                               |
| `destinationAddress` | string       | Where funds are delivered (see [Linking](#linking-onramp--smart-wallet-deposit)).                                                                                                                           |
| `integratorId`       | string       | Your integrator id.                                                                                                                                                                                         |
| `source`             | Asset        | What went into the swap.                                                                                                                                                                                    |
| `deposited`          | Asset?       | What the user originally deposited (`smart_wallet`).                                                                                                                                                        |
| `destination`        | Asset?       | What was delivered on the destination chain.                                                                                                                                                                |
| `depositTxHash`      | string?      | **onramp:** the on-chain tx that delivered crypto into the smart wallet. **relay\_deposit\_address:** the user's transfer into the deposit address — the hash they see in their wallet or exchange history. |
| `fundingTxs`         | string\[]?   | **smart\_wallet only** — every on-chain tx that funded the swap.                                                                                                                                            |
| `txHash`             | string?      | Source-chain swap tx hash. For `relay_deposit_address`, the transaction that swept the deposit address.                                                                                                     |
| `destinationTxHash`  | string?      | Destination-chain tx hash (cross-chain).                                                                                                                                                                    |
| `createdAt`          | string       | ISO 8601.                                                                                                                                                                                                   |
| `updatedAt`          | string       | ISO 8601.                                                                                                                                                                                                   |
| `completedAt`        | string?      | ISO 8601.                                                                                                                                                                                                   |
| `providerData`       | ProviderData | Method-specific data (below).                                                                                                                                                                               |

### Deposit methods

| `method`                | What happened                                                                                                                                                                                                                          | `flow`              |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| `onramp`                | A fiat purchase (card, Apple Pay, bank) through the on-ramp.                                                                                                                                                                           | `deposit_with_fiat` |
| `smart_wallet`          | The user sent crypto to their per-user smart wallet (the QR flow), and Swapper swapped it onward.                                                                                                                                      | `transfer_crypto`   |
| `relay_deposit_address` | The user sent crypto to a deposit address — shown as a QR code in the widget, or an [API deposit address](/api-v1/endpoints/deposit-addresses.md) — from any wallet or exchange, and it was swapped and delivered to your destination. | `transfer_crypto`   |
| `dex_swap`              | The user connected their own wallet and signed the swap route.                                                                                                                                                                         | `wallet_deposit`    |

Both QR settlement paths share `flow: "transfer_crypto"` — group on `flow` to count the QR product as one. (The `smart_wallet` leg of an on-ramp sweep also reports `transfer_crypto`; [link it](#linking-onramp--smart-wallet-deposit) to its `onramp` deposit before counting.) Handle unknown `method` values gracefully: new ones can be added.

### Asset

| Field          | Type    | Description                          |
| -------------- | ------- | ------------------------------------ |
| `chainId`      | string  | Chain identifier.                    |
| `tokenAddress` | string  | Token contract address.              |
| `tokenSymbol`  | string? | Token symbol, when known.            |
| `amount`       | string? | Amount in the token's smallest unit. |
| `amountUsd`    | string? | USD value, when known.               |

### ProviderData

Discriminated by `type`:

```ts
type ProviderData =
  | {
      type: "onramp";
      transactionId: string;
      sessionId?: string;
      externalCustomerId?: string;
      customerId?: string;
      fiatCurrency?: string;
      fiatAmount?: string;
      paymentMethod?: string;
      rawStatus?: string;
      smartWalletAddress?: string; // set when the purchase was sent to a smart wallet
    }
  | {
      type: "smart_wallet";
      authorizationId: string;
      executionId: string;
      smartWalletAddress: string;
    }
  | {
      type: "dex_swap";
      routeRequestId?: string;
      rawStatus?: string;
      routerAddress?: string;
    }
  | {
      type: "relay_deposit_address";
      depositAddress: string;        // the address the QR code showed
      requestId: string;             // routing request that filled the deposit
      depositAddressType?: string;
      depositor?: string;            // where the funds came from — may be an exchange hot wallet
      supersededByRequestId?: string;
      rawStatus?: string;
    };
```

New fields can be added to any of these objects — ignore fields you don't recognize.

### Example — smart-wallet swap

```json
{
  "eventId": "13de526e-a4b5-468f-808f-7eaeea08fc10",
  "eventType": "transaction.completed",
  "timestamp": "2026-06-28T13:11:08.753Z",
  "data": {
    "deposit": {
      "id": "smart_wallet-1782652045143-0xa0b8...eb48",
      "method": "smart_wallet",
      "flow": "transfer_crypto",
      "status": "completed",
      "destinationAddress": "0x928…8da",
      "integratorId": "your-id",
      "source": {
        "chainId": "1",
        "tokenAddress": "0xa0b8...eb48",
        "amount": "11150662",
        "amountUsd": "11.13"
      },
      "deposited": {
        "chainId": "1",
        "tokenAddress": "0xa0b8...eb48",
        "amount": "11150662",
        "amountUsd": "11.13"
      },
      "destination": {
        "chainId": "36900",
        "tokenAddress": "0x9cb8...71c2",
        "amount": "11139512",
        "amountUsd": "11.14"
      },
      "fundingTxs": ["0x47c2d908...26fb5"],
      "txHash": "0x9f2719ff...ebe9e",
      "destinationTxHash": "0xdddeb0c5...4be6a",
      "createdAt": "2026-06-28T13:07:35.000Z",
      "updatedAt": "2026-06-28T13:11:08.689Z",
      "completedAt": "2026-06-28T13:11:08.689Z",
      "providerData": {
        "type": "smart_wallet",
        "authorizationId": "0xbf64...2ac0-1-1782205341782",
        "executionId": "1782652045143-0xa0b8...eb48",
        "smartWalletAddress": "0xbf6…ac0"
      }
    },
    "integratorId": "your-id"
  }
}
```

## Verifying the signature

When you have a signing secret, every request includes an `X-Webhook-Signature` header: an **HMAC-SHA256** of the **raw request body**, keyed with your signing secret, **Base64**-encoded. Verify it before trusting a payload.

|           |                                                                                         |
| --------- | --------------------------------------------------------------------------------------- |
| Algorithm | HMAC-SHA256                                                                             |
| Key       | Your signing secret, used as-is (the UTF-8 string from the dashboard — don't decode it) |
| Payload   | The exact bytes of the request body (UTF-8 JSON)                                        |
| Encoding  | Base64                                                                                  |
| Header    | `X-Webhook-Signature`                                                                   |

```js
const crypto = require("crypto");
const express = require("express");

function verifyWebhookSignature(rawBody, signature, secret) {
  if (typeof signature !== "string") return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody) // the raw request body, byte for byte
    .digest("base64");
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/deposit",
  express.raw({ type: "application/json" }), // keep the body as raw bytes
  (req, res) => {
    const signature = req.headers["x-webhook-signature"];
    const secret = process.env.SWAPPER_WEBHOOK_SECRET;

    if (!verifyWebhookSignature(req.body, signature, secret)) {
      return res.status(401).json({ error: "Unauthorized" });
    }
    const event = JSON.parse(req.body.toString("utf8"));
    // …deduplicate on event.eventId, then process
    res.json({ received: true });
  }
);
```

{% hint style="warning" %}
Verify against the **raw request body bytes**. Re-serializing a parsed body can change key order or whitespace and break the signature. Compare in constant time, store the secret securely (never in version control), and use it only for verification.
{% endhint %}

**No secret, no signature.** Until you generate a signing secret in the dashboard, deliveries carry no `X-Webhook-Signature` header. Once you have one, reject requests without a valid signature.

**Rotating the secret.** Rotate it in the dashboard (**Webhooks → Signing secret**). New deliveries are signed with the new secret immediately — there's no overlap period. A retry of an event first sent just before the rotation can still carry the old signature, so accept both secrets for a minute or two after rotating.

## Delivery, retries & idempotency

* **Attempts:** each event is delivered up to **twice** — an initial attempt, then **one** retry about **30 seconds** later if the first doesn't succeed.
* **Several endpoints:** each configured URL gets its own delivery and retry, in parallel — a slow or failing endpoint doesn't hold up the others.
* **Per-attempt timeout: 10 seconds.** A slow response is treated as a failure.
* **Success = HTTP 2xx.** Any non-2xx (or a timeout) triggers the retry; after the second failure we stop, and the event is not redelivered later.
* **Idempotency:** a retry redelivers the same event, so **deduplicate on `eventId`** and make your handler idempotent. Return `200` quickly, then process asynchronously.

## Linking onramp ↔ smart-wallet deposit

> Advanced — only relevant if you support fiat on-ramps that are swapped onward using smart wallets.

When a fiat on-ramp funds one of our smart wallets and we swap those funds onward, you receive **two independent deposits** — one `onramp` and one `smart_wallet` — each as its own `transaction.completed` event. They are correlated **on-chain**, by the tx that delivered crypto into the smart wallet:

* the **onramp** deposit carries that delivery tx as `depositTxHash`;
* the **smart\_wallet** deposit carries `fundingTxs` — every tx that funded the swap.

A smart\_wallet deposit belongs to an onramp deposit when its `fundingTxs` contains the onramp's `depositTxHash`:

```js
const isFundedBy = (smartWallet, onramp) =>
  smartWallet.fundingTxs?.some(
    (tx) => tx.toLowerCase() === onramp.depositTxHash?.toLowerCase(),
  );
```

Event order is **not guaranteed** — the swap often completes before the on-ramp settles — so buffer an unmatched deposit (keyed by the funding tx) until its partner arrives.

**Things to know**

* **Don't double-count volume.** The two deposits represent the same money (fiat → token). Count the flow once, keyed by the funding tx.
* **`onramp` completion depends on destination.** A standalone on-ramp (delivered straight to an external wallet) is fully done at `completed`. If it funds a smart wallet, final delivery is the linked `smart_wallet` deposit's event (which may arrive later).
* **`destinationAddress` differs by design:** the on-ramp deposit targets the smart wallet (intermediate); the smart\_wallet deposit targets the user's final wallet.
* **Field presence:** `fundingTxs` is only on `smart_wallet` deposits; `depositTxHash` is on `onramp` deposits and absent on plain `dex_swap`.
* **Compare hashes case-insensitively.**

**Deposit-address deposits** (`relay_deposit_address`) need no linking: each user transfer produces exactly **one** deposit and one `transaction.completed` event, describing the source and your configured destination — even when Swapper delivers the funds in two hops internally.

## Support

First check **Recent deliveries** on the dashboard's Webhooks page: it lists every event Swapper sent (time, event type, deposit id, endpoint). It shows what was sent, not how your endpoint responded — check your own logs for that.

If something still looks wrong, open a ticket in [Discord](https://discord.gg/y8eevERxBz) with your `integratorId`, an example `eventId`, the relevant `deposit.id` and funding tx hash (`depositTxHash` or a `fundingTxs` entry), recent log entries, and your endpoint URL. See [Support](/resources/support.md).
