HMAC-signiert. Timing-safe.
Mithgard-Apps pushen Events an deinen Endpoint. Du verifizierst via HMAC-SHA256 — exakt das Pattern, das Mundart in Production einsetzt.
Signature-Header
Jeder Webhook-Request enthält den Header x-mithgard-signature: <hex-digest>. Der Digest ist HMAC-SHA256 über den rohen Request-Body mit dem Webhook-Secret als Key.
Wir nutzen x-mithgard-signature für alle Mithgard-Apps — Mundart, Schreibtisch (zukünftig), Genome (zukünftig). Ein Verifier, alle Webhooks.
Beispiel-Event (Mundart)
Mundart pusht call.completed-Events nach jedem Anruf:
{
"event": "call.completed",
"id": "evt_01HXYZ...",
"ts": 1714060800,
"data": {
"call_id": "call_a3f9",
"dialect": "alemannisch",
"intent": "appointment.booking",
"transcript": "Ich hett gärn am Friitig en Termin..."
}
}
Verifier-Implementierungen
Drei drop-in-Snippets — TS-Verifier-Funktion, Python-Verifier und ein Bash-Test-Push, mit dem du lokal gegen deinen Endpoint testen kannst.
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Mithgard-Webhook-Verifier — HMAC-SHA256, timing-safe.
* Header: x-mithgard-signature: <hex-digest>
*/
export function verifyMithgardWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
if (!signatureHeader) return false;
const expected = createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(signatureHeader, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
Vollständiger Route-Handler
Komplettes Beispiel als Next.js Route-Handler — fail-closed bei fehlendem Secret, timing-safe Compare, raw-Body-Capture.
import { NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs";
export async function POST(req: Request): Promise<NextResponse> {
const sig = req.headers.get("x-mithgard-signature") ?? "";
// WICHTIG: rawBody, nicht JSON.parse() — sonst stimmt der HMAC nicht.
const raw = await req.text();
const secret = process.env.MITHGARD_WEBHOOK_SECRET;
if (!secret) return NextResponse.json({ ok: false }, { status: 503 });
const expected = createHmac("sha256", secret)
.update(raw, "utf8")
.digest("hex");
const a = Buffer.from(sig, "utf8");
const b = Buffer.from(expected, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return NextResponse.json({ ok: false, error: "bad signature" }, { status: 401 });
}
const event = JSON.parse(raw) as { event: string; data: unknown };
// ... event handeln
return NextResponse.json({ ok: true });
}
Best-Practices
Idempotenz
Jedes Event hat eine id (ULID). Speicher die letzten 24h Event-IDs und ignoriere Doubletten — wir retryen bei 5xx-Responses.
Rohen Body verwenden
Niemals JSON.parse() bevor du verifizierst. JSON kann re-serialisiert werden und der Hash stimmt nicht mehr.
Replay-Schutz
Reject Events mit ts älter als 5 Minuten — auch wenn der HMAC stimmt.