---
title: Webhooks
description: HMAC-signed event delivery for passport lifecycle events. Retry ladder, signature verification, replay protection.
canonical: "https://www.tracepass.eu/docs/webhooks"
locale: en
source: "https://www.tracepass.eu/docs/webhooks"
---

# Webhooks

> HMAC-signed event delivery for passport lifecycle events. Retry ladder, signature verification, replay protection.

TracePass POSTs JSON-bodied events to your URL when passports, extractions, or supplier requests change state. Every delivery is HMAC-SHA-256 signed with a per-endpoint secret you control, retried on a fixed exponential ladder, and carries a stable`X-TracePass-Event-Id`for replay protection.

## Configuring an endpoint

Open **Developer → Webhooks** in the dashboard. Add your URL, copy the per-endpoint signing secret (returned once at creation — capture it then, roll by delete + recreate), and select which events to deliver. You can have multiple endpoints — typically one for staging, one for production. The dashboard's **Send test** button fires a `webhook.test` envelope so you can wire the receiver before real events flow.

## Events

| Event                | Fires when                                                                                                                           |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| passport.published   | A passport transitions to status=published.                                                                                          |
| passport.suspended   | A passport is suspended via API or UI. The public viewer flips to a suspended state.                                                 |
| passport.expired     | A published passport's expiresAt timestamp passes (daily cron). Used by the revenue-protection moat after 30-day cancellation grace. |
| passport.archived    | A passport is archived. The public viewer returns 404 (404 not 410 — the URL behaves as if it never existed).                        |
| extraction.completed | The multi-agent AI extraction pipeline finishes for a passport. Body carries the extraction id for follow-up reads.                  |
| extraction.failed    | An extraction session is beyond the 1h resume window without recovery — the orchestrator marks it failed.                            |
| supplier.submitted   | A supplier completes the token-auth supplier portal submission for one of your supplier requests.                                    |
| epcis.event.captured | One or more EPCIS 2.0 events are captured for a passport via the capture endpoint.                                                   |

**Tip —** Per-subscription daily cap is a flat 10,000 deliveries to defend against a misconfigured endpoint chewing the queue. Hitting that cap pauses delivery for that endpoint until the next UTC day; nothing is lost — undelivered events stay in the queue and resume on the next tick.

## Verifying signatures

Every request carries the signing trio:

| Header                  | Value                                                                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| X-TracePass-Signature   | v1=<hex HMAC-SHA256 of \`${X-TracePass-Timestamp}.${rawBody}\` with your endpoint secret>.                                                   |
| X-TracePass-Timestamp   | Unix seconds at signing time. Reject deliveries whose timestamp is more than 5 minutes off your clock — that's the replay-protection window. |
| X-TracePass-Event       | Event type name (e.g. passport.published). Lets your receiver dispatch without parsing JSON.                                                 |
| X-TracePass-Event-Id    | Stable across retries — dedupe on this. Mirrors the body's \`id\` field.                                                                     |
| X-TracePass-Delivery-Id | Per-attempt id. Use it for support correlation, not deduplication.                                                                           |

Re-compute the signature over `${timestamp}.${rawBody}` — the timestamp is concatenated with a literal `.` separator before the raw bytes. Don't reserialise the JSON; whitespace differences will flip the HMAC. Compare with a constant-time check.

### Node / TypeScript

```typescript
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.TRACEPASS_WEBHOOK_SECRET!;

// Capture the RAW body — needed for HMAC verification.
app.post("/tracepass-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sigHeader = String(req.headers["x-tracepass-signature"] ?? "");
  const ts = String(req.headers["x-tracepass-timestamp"] ?? "");

  // 5-minute replay window.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(ts)) > 300) {
    return res.status(400).send("stale");
  }

  // Parse the v1=<hex> envelope.
  const m = /^v1=([0-9a-f]+)$/i.exec(sigHeader);
  if (!m) return res.status(400).send("malformed signature header");
  const provided = Buffer.from(m[1], "hex");

  // HMAC over `${timestamp}.${body}`.
  const expected = createHmac("sha256", SECRET)
    .update(`${ts}.`)
    .update(req.body)
    .digest();

  if (
    provided.length !== expected.length ||
    !timingSafeEqual(provided, expected)
  ) {
    return res.status(400).send("bad signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // ... your handler ...
  res.status(200).send("ok");
});
```

### Python

```python
import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["TRACEPASS_WEBHOOK_SECRET"].encode()

@app.post("/tracepass-webhook")
def handler():
    sig_header = request.headers.get("X-TracePass-Signature", "")
    ts = request.headers.get("X-TracePass-Timestamp", "0")

    if abs(int(time.time()) - int(ts)) > 300:
        abort(400, "stale")

    if not sig_header.startswith("v1="):
        abort(400, "malformed signature header")
    provided = bytes.fromhex(sig_header[3:])

    raw = request.get_data()
    expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).digest()

    if not hmac.compare_digest(provided, expected):
        abort(400, "bad signature")

    event = request.get_json()
    # ... your handler ...
    return "ok", 200
```

### Go

```go
func handler(w http.ResponseWriter, r *http.Request) {
    sigHeader := r.Header.Get("X-TracePass-Signature")
    ts        := r.Header.Get("X-TracePass-Timestamp")

    tsInt, _ := strconv.ParseInt(ts, 10, 64)
    if math.Abs(float64(time.Now().Unix()-tsInt)) > 300 {
        http.Error(w, "stale", http.StatusBadRequest); return
    }
    if !strings.HasPrefix(sigHeader, "v1=") {
        http.Error(w, "malformed signature header", http.StatusBadRequest); return
    }
    provided, err := hex.DecodeString(strings.TrimPrefix(sigHeader, "v1="))
    if err != nil {
        http.Error(w, "malformed signature header", http.StatusBadRequest); return
    }

    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, []byte(os.Getenv("TRACEPASS_WEBHOOK_SECRET")))
    mac.Write([]byte(ts + "."))
    mac.Write(body)
    expected := mac.Sum(nil)

    if !hmac.Equal(provided, expected) {
        http.Error(w, "bad signature", http.StatusBadRequest); return
    }

    // parse body, dispatch event …
    w.WriteHeader(http.StatusOK)
}
```

## Retry ladder

Delivery is considered successful when your endpoint returns a `2xx` within 10 seconds. Anything else (network error, timeout, 4xx, 5xx) triggers a retry on this fixed schedule, seven attempts total:

| Attempt     | Delay after previous attempt   |
| ----------- | ------------------------------ |
| 1 (initial) | Immediate when the event fires |
| 2           | 1 minute                       |
| 3           | 5 minutes                      |
| 4           | 30 minutes                     |
| 5           | 2 hours                        |
| 6           | 12 hours                       |
| 7 (final)   | 24 hours                       |

After attempt 6 the delivery is parked in the dashboard's webhook event log with status `failed`; you can replay it manually from there. Delivery history retention is plan-scaled: Basic 30d, Starter 60, Growth 90, Scale 180, Pro 365, Enterprise 730\. The TTL is computed at insert from the company's plan, so changing plans doesn't retroactively expire old deliveries.

## Replay protection

Treat `X-TracePass-Event-Id` as a deduplication key. It mirrors the body's `id` field, repeats across retries on purpose (so your receiver can no-op when it's already processed the event), and never repeats across distinct events. A SQL `UNIQUE` on the column, or a Redis `SET NX` with a 7-day TTL, both work.

**Why the timestamp matters:** signature alone doesn't protect against a captured-and-replayed delivery. The 5-minute timestamp window plus eventId deduplication is the pair you need — drop either one and the attack surface widens. Stripe, Twilio, GitHub all enforce both; we follow the convention.
