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 stableX-TracePass-Event-Idfor replay protection.
Configuring an endpoint
Open Developer → Webhooksin 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. |
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
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
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", 200Go
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, six 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 (final) | 12 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-Idas a deduplication key. It mirrors the body's idfield, 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.