Skip to main content
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.
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. See Real time & data channels for the full channel map.

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):
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 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.
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.
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.

Event catalog

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.
EventRequired scopedata fields
position.settledpositions:readrfq_id, outcome, tx_signature, occurred_at
position.cancelledpositions:readrfq_id, reason, occurred_at
position.bought_backpositions:readrfq_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_changedpositions:readrfq_id, status, accepted_quote_id, occurred_at
funds.depositedbalances:readamount_usdc, vault_balance, occurred_at
funds.withdrawnbalances:readamount_usdc, vault_balance, occurred_at

Delivery format

Each delivery is an HTTP POST with a JSON body and these headers:
HeaderDescription
X-Totalis-Signaturet=<unix-seconds>,v1=<hmac-sha256-hex> — see verifying.
X-Totalis-Event-IdUnique event id. Use this to deduplicate.
X-Totalis-Event-TypeThe event name, e.g. position.settled.
Content-Typeapplication/json
The body is a stable envelope — id, type, and an event specific data object:
{
  "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.
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.
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" }));
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.

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). 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):
{
  "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.
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.

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

Rotate during a quiet window

Generate the new secret. Capture it immediately — it’s shown only once.
2

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.
3

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.