---
title: Webhooks
description: HMAC-signierte Event-Zustellung für Pass-Lebenszyklus-Events. Retry-Ladder, Signaturprüfung, Replay-Schutz.
canonical: "https://www.tracepass.eu/de/docs/webhooks"
locale: de
source: "https://www.tracepass.eu/de/docs/webhooks"
---

# Webhooks

> HMAC-signierte Event-Zustellung für Pass-Lebenszyklus-Events. Retry-Ladder, Signaturprüfung, Replay-Schutz.

TracePass POSTet JSON-Body-Events an Ihre URL, wenn Pässe, Extractions oder Supplier-Requests den Status wechseln. Jede Zustellung ist HMAC-SHA-256-signiert mit einem von Ihnen kontrollierten Per-Endpoint-Secret, wird auf einem festen exponentiellen Ladder wiederholt und trägt eine stabile`X-TracePass-Event-Id`zur Replay-Protection.

## Endpoint konfigurieren

Öffnen Sie **Developer → Webhooks** im Dashboard. Tragen Sie Ihre URL ein, kopieren Sie das Per-Endpoint-Signing-Secret (wird einmal bei der Erstellung ausgegeben — dann erfassen, rotieren via Delete + Recreate) und wählen Sie aus, welche Events zugestellt werden sollen. Sie können mehrere Endpoints haben — typischerweise einen für Staging, einen für Produktion. Der **Send test**\-Button im Dashboard feuert einen `webhook.test`\-Envelope, sodass Sie den Receiver verdrahten können, bevor echte Events fließen.

## Events

| Event                | Wird ausgelöst, wenn                                                                                                                                      |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| passport.published   | Ein Pass wechselt zu status=published.                                                                                                                    |
| passport.suspended   | Ein Pass wird per API oder UI suspendiert. Der öffentliche Viewer schaltet auf einen Suspended-Zustand.                                                   |
| passport.expired     | Der expiresAt-Zeitstempel eines veröffentlichten Passes läuft ab (Daily-Cron). Wird vom Revenue-Protection-Moat nach 30 Tagen Cancellation-Grace genutzt. |
| passport.archived    | Ein Pass wird archiviert. Der öffentliche Viewer liefert 404 (404, nicht 410 — die URL verhält sich, als hätte sie nie existiert).                        |
| extraction.completed | Die Multi-Agent-KI-Extraction-Pipeline schließt für einen Pass ab. Body trägt die extraction-id für Folge-Lesevorgänge.                                   |
| extraction.failed    | Eine Extraction-Session liegt jenseits des 1h-Resume-Fensters ohne Wiederherstellung — der Orchestrator markiert sie als failed.                          |
| supplier.submitted   | Ein Lieferant schließt den token-auth Lieferantenportal-Submit für einen Ihrer Supplier-Requests ab.                                                      |
| epcis.event.captured | Ein oder mehrere EPCIS-2.0-Events werden für einen Pass über den Capture-Endpoint erfasst.                                                                |

**Tip —** Das Per-Subscription-Tageslimit ist eine flache Schwelle von 10.000 Zustellungen, um sich vor einem fehlkonfigurier­ ten Endpoint zu schützen, der die Queue auffrisst. Wird das Limit erreicht, pausiert die Zustellung für diesen Endpoint bis zum nächsten UTC-Tag; nichts geht verloren — nicht zugestellte Events bleiben in der Queue und werden beim nächsten Tick fortgesetzt.

## Signaturen verifizieren

Jede Anfrage trägt das Signing-Trio:

| Header                  | Wert                                                                                                                                                                         |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| X-TracePass-Signature   | v1=<hex HMAC-SHA256 von \`${X-TracePass-Timestamp}.${rawBody}\` mit Ihrem Endpoint-Secret>.                                                                                  |
| X-TracePass-Timestamp   | Unix-Sekunden zum Zeitpunkt der Signierung. Lehnen Sie Zustellungen ab, deren Zeitstempel mehr als 5 Minuten von Ihrer Uhr abweicht — das ist das Replay-Protection-Fenster. |
| X-TracePass-Event       | Event-Typname (z. B. passport.published). Erlaubt Ihrem Receiver Dispatch ohne JSON-Parsing.                                                                                 |
| X-TracePass-Event-Id    | Über Retries stabil — dedupen Sie darauf. Spiegelt das \`id\`-Feld des Bodies.                                                                                               |
| X-TracePass-Delivery-Id | Per-Attempt-ID. Für Support-Korrelation nutzen, nicht für Deduplication.                                                                                                     |

Berechnen Sie die Signatur erneut über `${timestamp}.${rawBody}` — der Zeitstempel wird mit einem literalen `.`\-Separator vor den Rohbytes konkateniert. Nicht den JSON neu serialisieren; Whitespace-Unterschiede flippen den HMAC. Vergleichen Sie mit einem 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

Eine Zustellung gilt als erfolgreich, wenn Ihr Endpoint binnen 10 Sekunden ein `2xx` liefert. Alles andere (Netzwerk-Fehler, Timeout, 4xx, 5xx) löst einen Retry auf diesem festen Plan aus, insgesamt sieben Versuche:

| Versuch     | Verzögerung nach vorigem Versuch |
| ----------- | -------------------------------- |
| 1 (initial) | Sofort, wenn das Event auslöst   |
| 2           | 1 Minute                         |
| 3           | 5 Minuten                        |
| 4           | 30 Minuten                       |
| 5           | 2 Stunden                        |
| 6           | 12 Stunden                       |
| 7 (final)   | 24 Stunden                       |

Nach Versuch 6 wird die Zustellung im Webhook-Event-Log des Dashboards mit Status `failed` geparkt; Sie können sie dort manuell erneut auslösen. Die Zustellungs­historie ist plan-skaliert: Basic 30 Tage, Starter 60, Growth 90, Scale 180, Pro 365, Enterprise 730\. Die TTL wird beim Insert aus dem Plan der Firma berechnet, ein Plan-Wechsel lässt alte Zustellungen nicht rückwirkend ablaufen.

## Replay-Protection

Behandeln Sie `X-TracePass-Event-Id` als Deduplication-Key. Sie spiegelt das `id`\-Feld des Bodies, wiederholt sich über Retries absichtlich (damit Ihr Receiver no-oppen kann, wenn das Event bereits verarbeitet ist) und wiederholt sich nie über verschiedene Events. SQL `UNIQUE` auf der Spalte oder Redis `SET NX` mit 7-Tage-TTL — beides funktioniert.

**Warum der Zeitstempel zählt:** die Signatur allein schützt nicht vor einer abgefangen-und-erneut-gesende­ ten Zustellung. Das 5-Minuten-Zeitstempel-Fenster plus EventId-Deduplication ist das Paar, das Sie brauchen — fällt eines weg, weitet sich die Angriffsfläche. Stripe, Twilio, GitHub setzen beides durch; wir folgen der Konvention.
