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

# Webhooks

> Receive HMAC signed events when your positions settle, get bought back, or your funds move. Configure an endpoint, verify signatures, handle retries, and replay deliveries.

Webhooks push events to your server as they happen, so you don't have to poll. Each event is
**HMAC signed** so you can verify it came from Totalis, delivered only for events whose **scope your
API key holds**, and **retried with backoff** until it succeeds or is dead lettered.

The endpoint belongs to your **account** (the principal), not to the key that created it — any of
your active keys with the right scope manages it, and revoking one key among several does not stop
delivery.

<Note>
  Webhooks are the durable channel — at least once, signed, retried. For the same events pushed
  live to a connected client (a UI or trading bot), use the [WebSocket](/guides/websocket). See
  [Real time & data channels](/guides/realtime-and-data) for the full channel map.
</Note>

## Configuring your endpoint

Set your endpoint URL and the events you want with `PUT /v1/webhooks` (managing the endpoint needs
`account:read` to view it, `account:write` to change it):

```bash theme={null}
curl -X PUT https://api.totalis.trade/v1/webhooks \
  -H "X-API-Key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.example/totalis/webhooks",
    "events": ["position.settled", "position.bought_back", "funds.withdrawn"]
  }'
```

* **`url`** — must be HTTPS and must not resolve to private/internal infrastructure.
* **`events`** — any subset of the [catalog](#event-catalog) below. An empty array parks the endpoint (nothing matches) without deleting it.
* **Signing secret** — generate one with `POST /v1/webhooks/rotate-secret`. **Deliveries only start once a signing secret exists** (`whsec_…`, shown exactly once — store it securely). A typical setup is `PUT /v1/webhooks` then `POST /v1/webhooks/rotate-secret`.

<Info>
  You only receive an event if your key holds the **scope that event requires** (rechecked at
  delivery time, not just at subscribe time). A `position.settled` delivery needs `positions:read`;
  a `funds.*` delivery needs `balances:read`.
</Info>

<Note>
  **Market makers** manage a separate endpoint with `?owner_kind=mm` (gated on `mm:quote`). Its events
  carry the MM's side of a position — `mm_result` and, on buyback, `mm_pays_user` — rather than the
  bettor's. A `user` endpoint (the default) ships your own bettor side event data.
</Note>

## Event catalog

<Note>
  **`parlay.status_changed`** tracks an RFQ's live progression — `status` is one of `accepted`,
  `confirmed`, `executed`, or `cancelled` (`accepted_quote_id` is set once a quote is accepted, else
  `null`). Terminal **settlement** comes via `position.settled`, and expiry isn't part of this event,
  so use the `position.*` events for final outcomes. A cancellation fires **both**
  `parlay.status_changed` (`status: "cancelled"`) and `position.cancelled` — dedupe on
  `X-Totalis-Event-Id` if you subscribe to both.
</Note>

| Event                   | Required scope   | `data` fields                                                                                                                                                           |
| ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `position.settled`      | `positions:read` | `rfq_id`, `outcome`, `tx_signature`, `occurred_at`                                                                                                                      |
| `position.cancelled`    | `positions:read` | `rfq_id`, `reason`, `occurred_at`                                                                                                                                       |
| `position.bought_back`  | `positions:read` | `rfq_id`, `amount` (the directional buyback amount, net on top of the refunded stake — see `mm_pays_user` for direction), `mm_pays_user`, `tx_signature`, `occurred_at` |
| `parlay.status_changed` | `positions:read` | `rfq_id`, `status`, `accepted_quote_id`, `occurred_at`                                                                                                                  |
| `funds.deposited`       | `balances:read`  | `amount_usdc`, `vault_balance`, `occurred_at`                                                                                                                           |
| `funds.withdrawn`       | `balances:read`  | `amount_usdc`, `vault_balance`, `occurred_at`                                                                                                                           |

## Delivery format

Each delivery is an HTTP `POST` with a JSON body and these headers:

| Header                 | Description                                                                       |
| ---------------------- | --------------------------------------------------------------------------------- |
| `X-Totalis-Signature`  | `t=<unix-seconds>,v1=<hmac-sha256-hex>` — see [verifying](#verifying-signatures). |
| `X-Totalis-Event-Id`   | Unique event id. **Use this to deduplicate.**                                     |
| `X-Totalis-Event-Type` | The event name, e.g. `position.settled`.                                          |
| `Content-Type`         | `application/json`                                                                |

The body is a stable envelope — `id`, `type`, and an event specific `data` object:

```json theme={null}
{
  "id": "evt_01HZX…",
  "type": "position.settled",
  "data": {
    "rfq_id": "11111111-1111-1111-1111-111111111111",
    "outcome": "user_wins",
    "tx_signature": "5Hk…",
    "occurred_at": "2026-06-22T00:00:00.000Z"
  }
}
```

Respond with any `2xx` status to acknowledge. Anything else is treated as a failure and retried.

## Verifying signatures

The `X-Totalis-Signature` header has the form `t=<timestamp>,v1=<signature>`, where the signature is
`HMAC-SHA256(secret, "<timestamp>.<raw-body>")` hex encoded. To verify:

1. Parse `t` and `v1` from the header.
2. Reject if `t` is outside your tolerance (**±5 minutes** recommended) — this blocks replay.
3. Recompute the HMAC over the **exact raw request body** joined to the timestamp as `t.body`.
4. Compare to `v1` with a constant time equality check.

<Warning>
  Compute the HMAC over the **raw bytes** of the request body, before any JSON parsing or
  reserialization. Restringifying parsed JSON can reorder keys or change whitespace and will break
  the signature.
</Warning>

<CodeGroup>
  ```ts Node / Express theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verifyTotalisWebhook(
    secret: string,
    signatureHeader: string,
    rawBody: string | Buffer, // express.raw() gives a Buffer; both are fine
    toleranceSec = 300,
  ): boolean {
    const fields = new Map<string, string>();
    for (const part of signatureHeader.split(",")) {
      const i = part.indexOf("=");
      if (i !== -1) fields.set(part.slice(0, i), part.slice(i + 1));
    }
    const t = Number(fields.get("t"));
    const v1 = fields.get("v1");
    if (!Number.isFinite(t) || !v1) return false;
    if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

    // Chained .update() signs "<t>." then the raw body bytes — no string coercion of the Buffer.
    const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(v1);
    return a.length === b.length && timingSafeEqual(a, b);
  }

  // Express: capture the raw body so you can sign over the exact bytes.
  // app.use("/totalis/webhooks", express.raw({ type: "application/json" }));
  ```

  ```python Python / Flask theme={null}
  import hmac, hashlib, time

  def verify_totalis_webhook(secret: str, signature_header: str, raw_body: bytes, tolerance_sec: int = 300) -> bool:
      fields = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
      t, v1 = fields.get("t"), fields.get("v1")
      if not t or not v1:
          return False
      if abs(time.time() - int(t)) > tolerance_sec:
          return False
      signed = f"{t}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, v1)

  # Flask: use request.get_data() for the raw body, not request.json.
  ```
</CodeGroup>

## Idempotency & ordering

* **Deduplicate on `X-Totalis-Event-Id`.** At least once delivery means you may occasionally receive
  the same event more than once (a retry that actually succeeded, or a [replay](#replaying-deliveries)).
  Treat the event id as the unique key.
* **Don't assume ordering.** Process each event on its own merits; use `occurred_at` if you need to
  reason about sequence.

## Retries & failure handling

If your endpoint doesn't return `2xx`, Totalis retries with exponential backoff:

* **Retried:** `5xx`, `408`, `429`, timeouts, and network errors → status `failed`, will retry.
* **Not retried:** other `4xx` responses → immediately `dead_letter` (your endpoint rejected it; a retry won't help).
* After the retry budget is exhausted, the delivery is `dead_letter`.

Return `2xx` quickly and do heavy work asynchronously — a slow endpoint that exceeds the delivery
timeout counts as a failed attempt.

## Inspecting & replaying deliveries

`GET /v1/webhooks/deliveries` lists recent deliveries with their status, attempt count, and last
response code (newest first):

```json theme={null}
{
  "deliveries": [
    {
      "id": "…",
      "event_type": "position.settled",
      "status": "delivered",
      "attempts": 1,
      "response_code": 200,
      "created_at": "2026-06-22T12:00:00.000Z"
    }
  ]
}
```

### Replaying deliveries

A **delivered** or **dead lettered** delivery can be requeued with
`POST /v1/webhooks/deliveries/{id}/redeliver` — useful after you fix a bug or an outage on your side.
Replays reuse the same `X-Totalis-Event-Id`, so your deduplication will recognize them.

<Note>
  A `failed` delivery that is still retrying can't be manually replayed — it's already scheduled for
  another attempt. Wait for it to land or be dead lettered.
</Note>

## Rotating your signing secret

Rotate the secret anytime with `POST /v1/webhooks/rotate-secret`. The new secret is shown once and the
**old one stops signing immediately** — there's no overlap window where both are valid. A safe
sequence:

<Steps>
  <Step title="Rotate during a quiet window">
    Generate the new secret. Capture it immediately — it's shown only once.
  </Step>

  <Step title="Update your verifier">
    Deploy the new secret to your endpoint as fast as possible. Any deliveries signed with the new
    secret that arrive before your verifier is updated will fail verification.
  </Step>

  <Step title="Replay anything that failed in the gap">
    From the deliveries list, replay any `dead_letter` (or, once they exhaust retries, formerly
    `failed`) deliveries from the swap window — they'll be signed again with the current secret.
  </Step>
</Steps>
