WhatsApp-Webhooks verarbeiten: Signatur, Retries, Duplikate

WhatsApp-Webhooks für einen KI-Agenten verarbeiten: Standard-Webhooks-Signatur prüfen, binnen 10 Sekunden antworten, Retries überstehen, Events deduplizieren. TypeScript.

Ein Rohr liefert Briefumschläge mit grünem Siegelwachs in einen dunklen Serverblock, ein Umschlag fliegt in einer Schleife zurück.

Das Wichtigste

  • Ein whasp-Webhook ist mit Standard Webhooks signiert, wird mindestens einmal zugestellt und nicht garantiert in der richtigen Reihenfolge, deshalb muss ein Empfänger prüfen, deduplizieren und eine veränderte Reihenfolge vertragen.
  • Prüfe die Signatur am rohen Request-Body, nie an neu serialisiertem JSON, und lehne Zeitstempel ab, die mehr als fünf Minuten abweichen.
  • Schreibe das Event in dauerhaften Speicher, antworte dann innerhalb von 10 Sekunden mit 2xx und erledige die langsame Arbeit danach: whasp behält den Body eines Events nur, bis es zugestellt oder aufgegeben ist.
  • Schlägt dein Endpoint weiter fehl, versucht whasp es 12 Mal über rund acht Stunden und gibt dann auf, und der Inhalt einer fehlgeschlagenen message.received lässt sich nicht erneut senden.
  • Dedupliziere anhand der Event-ID und sende jede Antwort mit einem Idempotency-Key, der aus der ID der eingehenden Nachricht gebildet wird.

Ein whasp-Webhook ist ein HTTPS-POST pro Event, mit Standard Webhooks signiert, mindestens einmal zugestellt und ohne garantierte Reihenfolge. Ein zuverlässiger Empfänger tut vier Dinge: Er prüft die Signatur am rohen Body, schreibt das Event in dauerhaften Speicher, antwortet innerhalb von 10 Sekunden mit 2xx und erledigt die langsame Arbeit danach, wobei er anhand der Event-ID dedupliziert. Dieser Leitfaden baut diesen Empfänger in TypeScript.

Was enthält eine Webhook-Zustellung?

Eine whasp-Zustellung ist ein POST mit einem JSON-Umschlag und drei Signatur-Headern. Der Umschlag enthält die Event-id, den type, einen created_at-Zeitpunkt und die data des Events. Die Header sind webhook-id (die Event-ID, bei jedem Retry gleich), webhook-timestamp (Unix-Sekunden) und webhook-signature (v1, gefolgt von einem Base64-HMAC-SHA256).

Es gibt acht Event-Typen:

Typ Wann es ausgelöst wird Was ein Empfänger tut
message.received Eine Person hat an eine Nummer geschrieben. Enthält text, media und mehr. Speichern und den Agenten ausführen. Das ist die einzige Kopie des Inhalts.
message.sent Eine ausgehende Nachricht hat das Telefon verlassen. Den eigenen Datensatz aktualisieren.
message.failed Eine ausgehende Nachricht ist fehlgeschlagen; der Grund steht in message.error. Loggen. Nur dann erneut versuchen, wenn der Grund vorübergehend ist.
reaction.received Jemand hat auf eine Nachricht reagiert; ein leeres emoji heißt entfernt. Meist ignorieren.
number.ready Eine Nummer im Status provisioning ist jetzt aktiv. Ab jetzt Nutzer darauf leiten.
number.status_changed health oder online hat sich geändert. Bei critical alarmieren.
number.replaced Eine gesperrte Nummer wurde ersetzt. Beide Nummern stehen im Event. Nutzer auf die neue Nummer umziehen.
webhook.test Du hast POST /v1/webhook/test aufgerufen. Mit 2xx antworten.

Wie prüfst du die Signatur?

Die Signaturprüfung belegt, dass eine Anfrage von whasp stammt und niemand ihren Body verändert hat. Die Bibliothek standardwebhooks erledigt das in einem Aufruf, und das Projekt veröffentlicht Bibliotheken für JavaScript, Python, Go, Java, Ruby, Rust und .NET. Das hier ist der Algorithmus aus der Standard-Webhooks-Spezifikation, ausgeschrieben mit node:crypto, für Stacks ohne Bibliothek (in Hono übergibst du c.req.raw.headers und den Text-Body):

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

export function verify(secret: string, headers: Headers, rawBody: string): boolean {
  const id = headers.get("webhook-id") ?? "";
  const timestamp = headers.get("webhook-timestamp") ?? "";
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false; // älter oder neuer als 5 Minuten; fängt auch NaN ab

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();

  // Der Header kann mehrere durch Leerzeichen getrennte Signaturen enthalten, z. B. "v1,abc= v1,def=".
  return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
    const [version, signature] = part.split(",");
    if (version !== "v1" || !signature) return false;
    const got = Buffer.from(signature, "base64");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

Fünf Fehler brechen die Prüfung häufig:

  • Geparstes JSON. Die Signatur deckt genau die Bytes ab, die whasp gesendet hat. Lies den Body zuerst als Text, prüfe und parse erst dann. Ein neu serialisiertes Objekt kann sich in Leerzeichen oder Schlüsselreihenfolge unterscheiden.
  • Der falsche Schlüssel. Ein Secret sieht aus wie whsec_ plus Base64. Der Schlüssel sind die decodierten Bytes, nicht der String. Die Standard-Webhooks-Bibliotheken entfernen das Präfix und decodieren für dich.
  • Uhrenabweichung. Zeitstempel, die mehr als fünf Minuten von deiner Uhr abweichen, werden abgelehnt. Halte den Server per NTP synchron.
  • Ein einfacher ===-Vergleich. Nutze timingSafeEqual, das bei unterschiedlichen Längen außerdem einen Fehler wirft, daher die Längenprüfung.
  • Nur die erste Signatur lesen. Der Header kann mehrere durch Leerzeichen getrennte Signaturen enthalten. Akzeptiere die Anfrage, wenn irgendeine v1-Signatur passt.

Antworte mit 401, wenn die Prüfung fehlschlägt. whasp behandelt das wie jeden Fehlschlag und versucht es erneut, ein falsches Secret verliert Events also nicht sofort, wohl aber, wenn die Retries aufgebraucht sind. POST /v1/webhook/rotate-secret stellt ein neues Secret aus. Die API sagt nicht, ob whasp während einer Rotation mit beiden Secrets signiert, aktualisiere deine Konfiguration also sofort; alles, was dazwischen abgelehnt wird, wird 30 Sekunden später erneut versucht.

Warum zuerst antworten und danach arbeiten?

Ein Empfänger sollte das Event speichern und zuerst antworten, denn whasp wartet 10 Sekunden auf ein 2xx, und die Arbeit eines Agenten, ein Modellaufruf oder ein Tool-Aufruf, dauert oft länger. Eine späte Antwort erzeugt einen Retry, und ein Retry erzeugt ein Duplikat.

Das Speichern kommt zuerst, weil whasp den Body eines Events nur behält, bis es zugestellt oder aufgegeben ist, und weil die Server von whasp keine Nachrichten speichern. Sobald dein Endpoint 2xx zurückgibt, besitzt du die einzige Kopie einer message.received. Das Muster ist eine dauerhafte Inbox: das rohe Event einfügen, 204 antworten und einen Worker die Zeilen der Tabelle abarbeiten lassen. Der vollständige Empfänger steht unten, mit Hono und node:sqlite. Installiere hono, @hono/node-server und standardwebhooks in einem Projekt mit "type": "module", setze WHASP_WEBHOOK_SECRET und starte ihn auf Node 22.18 oder neuer mit node --env-file=.env webhook.ts.

import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Webhook } from "standardwebhooks";

type WhaspEvent = { id: string; type: string; created_at: string; data: any };

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...
const MAX_ATTEMPTS = 6;

const db = new DatabaseSync("events.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS inbox (
    id TEXT PRIMARY KEY, type TEXT NOT NULL, chat TEXT, body TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',  -- pending | done | dead
    attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT,
    received_at INTEGER NOT NULL DEFAULT (unixepoch()));
`);

const app = new Hono();

app.post("/whasp/webhook", async (c) => {
  const body = await c.req.text(); // die rohen Bytes, die signiert wurden
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  const chat = event.data.message?.chat ?? event.data.chat ?? null;
  // Erst dauerhaft speichern, dann 2xx. Eine wiederholte ID fügt nichts ein und bekommt trotzdem ein 2xx.
  db.prepare("INSERT OR IGNORE INTO inbox (id, type, chat, body) VALUES (?, ?, ?, ?)").run(
    event.id, event.type, chat, body,
  );
  void drain(); // sofort starten; der Timer unten holt nur Retries ab
  return c.body(null, 204);
});

// Der Worker: ein Event nach dem anderen pro Chat, viele Chats parallel.
const busy = new Set<string>();

async function drain() {
  const due = db
    .prepare("SELECT id, chat, body, attempts FROM inbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid")
    .all(Date.now()) as { id: string; chat: string | null; body: string; attempts: number }[];
  for (const row of due) {
    const lane = row.chat ?? row.id;
    if (busy.has(lane)) continue;
    busy.add(lane);
    void work(row).finally(() => busy.delete(lane));
  }
}

async function work(row: { id: string; body: string; attempts: number }) {
  try {
    await handle(JSON.parse(row.body));
    db.prepare("UPDATE inbox SET status = 'done' WHERE id = ?").run(row.id);
  } catch (err) {
    const attempts = row.attempts + 1;
    const status = attempts >= MAX_ATTEMPTS ? "dead" : "pending";
    const next = Date.now() + Math.min(2 ** attempts * 5_000, 300_000); // 10 s, 20 s, 40 s...
    db.prepare("UPDATE inbox SET status = ?, attempts = ?, next_at = ?, error = ? WHERE id = ?").run(
      status, attempts, next, String(err), row.id,
    );
  }
}

async function handle(event: WhaspEvent) {
  switch (event.type) {
    case "message.received":
      console.log("inbound from", event.data.message.chat, event.data.message.text);
      return; // hier deinen Agenten aufrufen, danach POST /v1/messages
    case "message.failed":
      console.warn("send failed", event.data.message.id, event.data.message.error);
      return;
    case "number.status_changed":
      if (event.data.number.health === "critical") console.error("number critical", event.data.number.number);
      return;
    case "number.replaced":
      console.warn("replaced", event.data.old_number, "->", event.data.new_number.number);
      return;
    default:
      return; // message.sent, reaction.received, number.ready, webhook.test
  }
}

setInterval(drain, 1000);
serve({ fetch: app.fetch, port: 3000 });

Drei Teile tragen die Last. Das INSERT OR IGNORE auf der Event-ID ist Deduplizierung und Dauerhaftigkeit in einer Anweisung. Die drain-Schleife arbeitet pro Chat ein Event nach dem anderen ab und viele Chats parallel, eine langsame Unterhaltung blockiert die anderen also nie. Und work hält jeden Fehlschlag mit einer Versuchszahl und wachsender Wartezeit fest und legt die Zeile nach sechs Versuchen als dead ab, sodass ein Poison-Event nicht endlos kreist und für dich lesbar in der Tabelle bleibt.

Lösche fertige Zeilen täglich mit DELETE FROM inbox WHERE status = 'done' AND received_at < unixepoch() - 86400. Behalte IDs länger als das Retry-Fenster von whasp, sonst sieht ein später Retry wie ein neues Event aus. Nach einem Ausfall bei deinem Modellanbieter stellst du die abgelegten Zeilen mit UPDATE inbox SET status = 'pending', attempts = 0 WHERE status = 'dead' wieder in die Warteschlange.

Was passiert, wenn dein Endpoint ausfällt?

Wenn dein Endpoint ausfällt, versucht whasp dasselbe Event mit wachsenden Abständen erneut. Ein Fehlschlag ist jede Antwort außerhalb von 2xx, ein Verbindungsfehler oder keine Antwort innerhalb von 10 Sekunden. Auch ein 401 wegen einer fehlerhaften Signatur zählt.

Retry Wartezeit nach dem vorherigen Fehlschlag
1 30 Sekunden
2 2 Minuten
3 10 Minuten
4 30 Minuten
5 bis 12 je 1 Stunde

Die zwölf Retries erstrecken sich über rund acht Stunden. Nach dem letzten wird die Zustellung als failed markiert, und da whasp den Body eines Events verwirft, sobald es zugestellt oder aufgegeben ist, lässt sich der Text einer message.received, die jedes Mal fehlgeschlagen ist, nicht erneut senden. Ohne Webhook-URL werden Events nur als no_endpoint aufgelistet und nie gesendet.

Daraus folgen zwei Gewohnheiten. Halte den Endpoint unabhängig von deinem Modellanbieter: Mit der Inbox zeigt sich dort ein Ausfall als Zeilen in deiner Tabelle, nicht als fehlgeschlagene Zustellungen. Und behalte GET /v1/webhook/deliveries im Blick, das die letzten 7 Tage mit status, attempts, last_status_code und last_error jeder Zustellung auflistet.

Wie gehst du mit Duplikaten und Reihenfolge um?

Doppelte Events sind normal, denn die Zustellung erfolgt mindestens einmal. Dasselbe Event kommt zweimal an, wenn dein 2xx im Netz verloren geht oder nach 10 Sekunden eintrifft. Dedupliziere anhand der Event-ID (derselbe Wert wie webhook-id), antworte bei Wiederholungen weiter mit 2xx, und dein Handler läuft einmal pro Event.

Auch die Reihenfolge ist nicht garantiert. Eine Nachricht, die einen Retry brauchte, kann nach der Nachricht ankommen, die die Person als Nächstes schickte. Der Empfänger oben arbeitet schon pro Chat ein Event nach dem anderen ab. Sortiere den Verlauf, den du für das Modell baust, nach message.created_at statt nach Ankunft, und rechne mit Schüben: Drei Nachrichten in zehn Sekunden erzeugen drei Events, überlege also, zwei oder drei Sekunden auf weitere zu warten und dann einmal zu antworten.

Duplikate betreffen auch das, was du zurücksendest. Sende jede Antwort mit einem Idempotency-Key, der aus der ID der eingehenden Nachricht gebildet wird, zum Beispiel reply-msg_..., nicht mit einem zufälligen Wert pro Versuch. whasp behält einen Key 7 Tage pro Account: Derselbe Key liefert die erste Nachricht zurück und sendet nie zweimal. Ein abgelehnter Versand (4xx) verbraucht den Key nicht, und ein 409 idempotency_conflict heißt, dass die erste Anfrage mit diesem Key noch läuft, versuche es also nach einem Moment erneut. Dieselbe Sorgfalt gilt für Tools mit Seiteneffekten wie Buchungen oder Erstattungen: Ein erneut abgespieltes Event führt deinen Agenten erneut aus, sichere sie also mit der ID der eingehenden Nachricht ab, wie das Claude-Tutorial erklärt.

Wie testest und debuggst du Webhooks?

Du testest einen whasp-Webhook in vier Schritten, und der letzte braucht keinen whasp-Account.

  1. Starte den Empfänger und mach ihn erreichbar. cloudflared tunnel --url http://localhost:3000 liefert eine temporäre HTTPS-Adresse und braucht keinen Cloudflare-Account.
  2. Registriere ihn mit PUT /v1/webhook und dem Body { "url": "https://.../whasp/webhook" }. Die Antwort enthält das Secret.
  3. Rufe POST /v1/webhook/test auf. Es sendet sofort ein webhook.test und antwortet mit { "delivered", "status_code", "error" }, du siehst also, was dein Server zurückgegeben hat.
  4. Sende ein signiertes gefälschtes Event an localhost. Webhook.sign erzeugt den Header-Wert, dieses Skript braucht also weder einen whasp-Account noch einen Tunnel:
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!);
const event = {
  id: `evt_local_${Date.now()}`,
  type: "message.received",
  created_at: new Date().toISOString(),
  data: { message: { id: "msg_local_1", chat: "+15555550111", text: "Hallo aus einem Test", media: null } },
};
const body = JSON.stringify(event);

const res = await fetch("http://localhost:3000/whasp/webhook", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "webhook-id": event.id,
    "webhook-timestamp": String(Math.floor(Date.now() / 1000)),
    "webhook-signature": wh.sign(event.id, new Date(), body),
  },
  body,
});
console.log(res.status); // 204

Starte es mit node --env-file=.env send-test-event.ts, während der Empfänger läuft. Ändere type und data, um message.failed oder number.replaced auf dieselbe Weise zu proben.

Was baust du als Nächstes?

Verbinde den Empfänger mit einem Agenten: Das OpenAI-Tutorial und das Claude-Tutorial gehen beide von diesem Muster aus. Gib dem Agenten ein Konversationsgedächtnis oder baue denselben Ablauf ohne Code in n8n. Reicht eine Nummer nicht, plane die Kapazität für mehrere.

Fragen

Wie prüfe ich die Signatur eines WhatsApp-Webhooks von whasp?

Berechne einen HMAC-SHA256 über den String aus webhook-id, einem Punkt, webhook-timestamp, einem Punkt und dem rohen Body, mit dem Base64-decodierten Teil deines whsec_-Secrets als Schlüssel, und vergleiche ihn in konstanter Zeit mit dem v1-Wert in webhook-signature. Die standardwebhooks-Bibliothek für deine Sprache erledigt das in einem Aufruf.

Wie oft versucht whasp einen fehlgeschlagenen Webhook erneut?

Bis zu 12 Mal, nach 30 Sekunden, 2 Minuten, 10 Minuten, 30 Minuten und danach stündlich. Nach dem letzten Fehlschlag wird die Zustellung als failed markiert und der Body des Events verworfen.

Kann dasselbe Webhook-Event zweimal ankommen?

Ja. Die Zustellung erfolgt mindestens einmal, ein verlorenes 2xx oder eine langsame Antwort erzeugt also ein Duplikat mit derselben Event-ID. Speichere die ID mit einem Unique-Constraint und antworte bei Wiederholungen mit 2xx.

Was soll ein Webhook-Endpoint zurückgeben?

Jeder 2xx-Status innerhalb von 10 Sekunden gilt als zugestellt, 204 ohne Body genügt. Ein 4xx, ein 5xx, ein Timeout oder ein Verbindungsfehler ist ein Fehlschlag und wird wiederholt.

Wie teste ich einen whasp-Webhook lokal?

Mach deinen Port per Tunnel erreichbar, registriere die URL mit PUT /v1/webhook und rufe POST /v1/webhook/test auf. Du kannst auch selbst ein gefälschtes Event mit der standardwebhooks-Bibliothek signieren und an localhost senden.

Alle Artikel