> ## Documentation Index
> Fetch the complete documentation index at: https://docs.daimo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get notified when sessions change status

Webhooks let your server receive real-time notifications when a session transitions to a new status. Instead of polling, Daimo sends a `POST` request to your endpoint with the event payload.

## Event types

| Event                | Session status | Trigger                              | Example use case                |
| -------------------- | -------------- | ------------------------------------ | ------------------------------- |
| `session.processing` | `processing`   | Deposit detected, funds being routed | Show "payment received" to user |
| `session.succeeded`  | `succeeded`    | Funds delivered to destination       | Fulfill the order, send receipt |
| `session.bounced`    | `bounced`      | Delivery failed, funds refunded      | Alert support, notify customer  |

Subscribe to all events with `["*"]` or pick specific types.

## Quickstart

### 1. Register an endpoint

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.daimo.com/v1/webhooks \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://example.com/webhooks/daimo",
      "events": ["*"]
    }'
  ```

  ```typescript fetch theme={null}
  const response = await fetch("https://api.daimo.com/v1/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DAIMO_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://example.com/webhooks/daimo",
      events: ["*"],
    }),
  });

  const { webhook } = await response.json();
  // Save webhook.secret — you'll need it to verify signatures
  console.log(webhook.secret);
  ```
</CodeGroup>

The response includes a `secret`. Store it securely, you'll use it to verify that incoming requests are from Daimo.

### 2. Handle events

Set up a route on your server to receive webhook events:

```typescript theme={null}
import { createServer } from "node:http";

const server = createServer((req, res) => {
  if (req.method === "POST" && req.url === "/webhooks/daimo") {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const event = JSON.parse(body);

      // TODO: verify signature (see below)

      switch (event.type) {
        case "session.succeeded":
          // Handle successful delivery
          break;
        case "session.bounced":
          // Handle failed delivery
          break;
      }

      res.writeHead(200).end();
    });
  }
});

server.listen(4242);
```

### 3. Send a test event

Verify your endpoint is working by sending a test event:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.daimo.com/v1/webhooks/{webhookId}/test \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"eventType": "session.succeeded"}'
  ```

  ```typescript fetch theme={null}
  await fetch(`https://api.daimo.com/v1/webhooks/${webhookId}/test`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.DAIMO_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ eventType: "session.succeeded" }),
  });
  ```
</CodeGroup>

Test events include `isTestEvent: true` in the payload so you can filter them out of your business logic.

## Verify signatures

Every webhook delivery includes a `Daimo-Signature` header for verifying authenticity. Always verify signatures in production to ensure requests are from Daimo.

### How it works

The signature header looks like this:

```
Daimo-Signature: t=1700000000,v1=5257a869...
```

To verify a webhook:

1. **Read the raw body.** Don't parse JSON first — you need the exact bytes.
2. **Extract `t` and `v1`** from the `Daimo-Signature` header by splitting on `,` and `=`.
3. **Compute HMAC-SHA256** of `${t}.${rawBody}` using your webhook secret.
4. **Compare** the computed signature to `v1` using `crypto.timingSafeEqual`.
5. **Reject stale timestamps.** If `t` is more than 5 minutes old, discard the event to prevent replay attacks.

### Full verification function

```typescript theme={null}
import * as crypto from "crypto";

const TIMESTAMP_TOLERANCE_SEC = 300; // 5 minutes

function verifyWebhookSignature(
  secret: string,
  signatureHeader: string,
  rawBody: string,
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => {
      const [k, ...v] = p.split("=");
      return [k, v.join("=")];
    }),
  );
  const ts = parts["t"];
  const sig = parts["v1"];
  if (!ts || !sig) return false;

  const tsNum = parseInt(ts, 10);
  if (isNaN(tsNum)) return false;
  const age = Math.abs(Math.floor(Date.now() / 1000) - tsNum);
  if (age > TIMESTAMP_TOLERANCE_SEC) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(sig, "hex"),
      Buffer.from(expected, "hex"),
    );
  } catch {
    return false;
  }
}
```

### Complete handler with verification

```typescript theme={null}
import { createServer } from "node:http";
import * as crypto from "crypto";

const WEBHOOK_SECRET = process.env.DAIMO_WEBHOOK_SECRET!;

const server = createServer((req, res) => {
  if (req.method === "POST" && req.url === "/webhooks/daimo") {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const signature = req.headers["daimo-signature"] as string;
      if (!verifyWebhookSignature(WEBHOOK_SECRET, signature, body)) {
        res.writeHead(400).end("invalid signature");
        return;
      }

      const event = JSON.parse(body);

      switch (event.type) {
        case "session.succeeded":
          // Handle successful delivery
          break;
        case "session.bounced":
          // Handle failed delivery
          break;
      }

      res.writeHead(200).end();
    });
  }
});

server.listen(4242);
```

## Event payload

### Field reference

| Field          | Type      | Description                                                                                                                  |
| -------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `id`           | `string`  | Unique event ID (UUID). Use for idempotency.                                                                                 |
| `type`         | `string`  | One of `session.processing`, `session.succeeded`, `session.bounced`                                                          |
| `createdAt`    | `number`  | Unix timestamp (seconds) when the event was created                                                                          |
| `data.session` | `object`  | Session snapshot at event time. Same shape as the [session object](/guides/sessions#session-object), without `clientSecret`. |
| `isTestEvent`  | `boolean` | `true` for test events sent via `/test` endpoint. Omitted for real events.                                                   |

Here's an example of a `session.succeeded` event:

```json theme={null}
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "type": "session.succeeded",
  "createdAt": 1700000000,
  "data": {
    "session": {
      "sessionId": "abcdef1234567890abcdef1234567890",
      "status": "succeeded",
      "destination": {
        "type": "evm",
        "address": "0x...",
        "chainId": 8453,
        "chainName": "Base",
        "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
        "tokenSymbol": "USDC",
        "amountUnits": "10.00",
        "delivery": {
          "txHash": "0x...",
          "receivedUnits": "10.00"
        }
      },
      "display": {
        "title": "Deposit to Acme",
        "verb": "Deposit"
      },
      "paymentMethod": {
        "type": "evm",
        "receiverAddress": "0x...",
        "createdAt": 1700000000
      },
      "metadata": { "myUserId": "user_123" },
      "createdAt": 1700000000,
      "expiresAt": 1700003600
    }
  }
}
```

## Delivery behavior

Every delivery includes these headers:

| Header            | Description                                                                    |
| ----------------- | ------------------------------------------------------------------------------ |
| `Content-Type`    | `application/json`                                                             |
| `Daimo-Signature` | `t=<unix_seconds>,v1=<hmac_hex>` (see [Verify signatures](#verify-signatures)) |

* Daimo waits **10 seconds** for your server to respond.
* Any **2xx** status code counts as success.
* Failed deliveries are retried with **exponential backoff**: the n-th retry waits 2^(n-1) minutes.
* After **10 failed attempts**, the event is marked as failed and no further retries are made.

## Test events

Use `POST /v1/webhooks/{webhookId}/test` to send a test event. You can optionally specify an `eventType` parameter (defaults to `session.succeeded`).

Test events contain `isTestEvent: true` in the payload. Use this flag to skip business logic during testing.

## Best practices

* **Return 200 quickly.** Process events asynchronously if your handler does heavy work. Daimo times out after 10 seconds.
* **Verify signatures.** Always verify the `Daimo-Signature` header in production to confirm requests are from Daimo.
* **Handle test events.** Check `event.isTestEvent` and skip side effects (e.g. order fulfillment) for test events.
* **Be idempotent.** Daimo may deliver the same event more than once. Log processed event IDs and skip duplicates. The `event.id` uniquely identifies each event.
