Zum Inhalt springen
Mithgard
Webhooks

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.