ChatGPT mit WhatsApp verbinden: KI-Agent mit OpenAI API bauen

OpenAI mit WhatsApp verbinden in TypeScript: Webhook-Signatur prüfen, Verlauf speichern, die Responses API aufrufen und per API-Call antworten. Voller Code für Node 22.

Ein Laptop mit abstrakten Codezeilen, die über eine leuchtend grüne Linie mit einem Smartphone voller Chat-Blasen verbunden sind.

Das Wichtigste

  • Ein WhatsApp-Agent auf der OpenAI API besteht aus drei Teilen: einem Webhook, der jede Nachricht empfängt, einem Aufruf der Responses API mit dem bisherigen Gespräch und einem POST, der die Antwort sendet.
  • Prüfe die Standard-Webhooks-Signatur am rohen Body, schreibe das Event in deine Datenbank und antworte mit 204, bevor du das Modell aufrufst, denn ein Modellaufruf kann länger dauern als das 10-Sekunden-Fenster der Zustellung.
  • Die Server von whasp speichern keine Nachrichten, du speicherst das Gespräch also selbst und schickst bei jeder Anfrage die letzten Turns an das Modell.
  • Bilde den Idempotency-Key der Antwort aus der ID der eingehenden Nachricht, damit ein erneut zugestelltes Event nie eine zweite Antwort sendet.

Ein WhatsApp-Agent auf Basis von OpenAI braucht drei Teile: eine WhatsApp-Nummer, die jede eingehende Nachricht an einen Webhook sendet, einen Server, der jedes Event prüft und die OpenAI Responses API mit dem bisherigen Gespräch aufruft, und einen API-Call, der die Antwort sendet. Dieses Tutorial baut alle drei in rund 120 Zeilen TypeScript auf Node 22, wobei whasp die Nummer liefert.

Wie funktioniert der Agent?

Der Agent läuft in fünf Schritten, und nur der erste und der letzte berühren WhatsApp.

  1. Eine Person schreibt an deine Nummer.
  2. whasp sendet ein signiertes message.received-Event per POST an deinen Server. Das Event enthält die einzige Kopie, die du bekommst, denn die Server von whasp speichern keine Nachrichten.
  3. Dein Server prüft die Signatur, speichert das Event und antwortet mit 204.
  4. Im Hintergrund lädt er die letzten Turns des Chats aus deiner eigenen Datenbank und ruft die Responses API auf.
  5. Er sendet die Antwort mit POST /v1/messages.

Zwei Eigenschaften von whasp-Nummern prägen dieses Design. Menschen schreiben zuerst: Eine Nummer kann keinen Chat von sich aus eröffnen (409 chat_not_open), der Agent antwortet also immer nur. Und deine Datenbank ist das Gedächtnis des Agenten, weil die Server von whasp keinen Verlauf speichern. Jeden Weg, einen KI-Agenten auf WhatsApp zu betreiben, stellt ein eigener Leitfaden gegenüber: KI-Agent auf WhatsApp: alle Optionen im Vergleich.

Was brauchst du vor dem Start?

Dieses Tutorial braucht Node 22.18 oder neuer, einen OpenAI-API-Key und eine whasp-Nummer mit API-Key.

  • Node 22.18 oder neuer. Ab dieser Version führt Node .ts-Dateien direkt aus, indem es die Typen entfernt, ein Build-Schritt entfällt also. node:sqlite ist seit 22.13 eingebaut und gibt auf Node 22 eine Warnung zu einem experimentellen Feature aus.
  • Ein OpenAI-API-Key.
  • Eine whasp-Nummer und ein API-Key, beides aus dem Dashboard. Eine Nummer kostet $49 pro 30 Tage aus einem Prepaid-Guthaben.

Lege das Projekt an:

mkdir wa-agent && cd wa-agent
npm init -y && npm pkg set type=module
npm install hono @hono/node-server openai standardwebhooks

Ergänze dann eine .env-Datei. Das Webhook-Secret stammt aus dem Registrierungsschritt gegen Ende.

WHASP_API_KEY=wsk_...
WHASP_WEBHOOK_SECRET=whsec_...
OPENAI_API_KEY=sk-...

Wie sieht der komplette Server aus?

Der ganze Agent steckt in einer Datei, server.ts. Eingehende Events und das Gespräch liegen in einer SQLite-Datei, ein Neustart verliert also nichts.

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

const WHASP = "https://api.whasp.ai/v1";
const MODEL = "gpt-6-luna";
const SYSTEM = `Du bist Ava, eine freundliche Assistentin, der Leute auf WhatsApp schreiben.
Schreib wie ein Mensch im Chat: kurz, einfacher Text, keine Markdown-Überschriften oder Tabellen.
Antworte in der Sprache, in der die Person schreibt. Stell höchstens eine Frage auf einmal.`;
const OPT_OUT = new Set(["STOP", "UNSUBSCRIBE", "OPTOUT", "CANCEL", "END", "QUIT"]); // exakt, Großbuchstaben

type Message = { id: string; chat: string; text: string | null };
type WhaspEvent = { id: string; type: string; data: { message?: Message } };
type Turn = { role: "user" | "assistant"; content: string };

const openai = new OpenAI(); // liest OPENAI_API_KEY
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...

const db = new DatabaseSync("agent.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, body TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0);
  CREATE TABLE IF NOT EXISTS turns (
    id INTEGER PRIMARY KEY, ref TEXT UNIQUE, chat TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL);
  CREATE INDEX IF NOT EXISTS turns_chat ON turns (chat, id);
`);

const app = new Hono();

app.post("/whasp/webhook", async (c) => {
  const body = await c.req.text(); // Raw Body: erst prüfen, dann parsen
  let event: WhaspEvent;
  try {
    event = wh.verify(body, c.req.header()) as WhaspEvent;
  } catch {
    return c.text("Invalid signature", 401);
  }
  // Erst speichern, dann antworten. Eine wiederholte Event-ID fügt nichts ein.
  const { changes } = db.prepare("INSERT OR IGNORE INTO inbox (id, body) VALUES (?, ?)").run(event.id, body);
  if (changes > 0) enqueue(event);
  return c.body(null, 204);
});

// Eine Spur pro Chat: Die Nachrichten einer Person laufen der Reihe nach, verschiedene Personen parallel.
const lanes = new Map<string, Promise<void>>();
function enqueue(event: WhaspEvent) {
  const key = event.data.message?.chat ?? "system";
  const run = (lanes.get(key) ?? Promise.resolve()).then(() => handle(event));
  lanes.set(key, run);
  void run.finally(() => lanes.get(key) === run && lanes.delete(key));
}

async function handle(event: WhaspEvent) {
  try {
    if (event.type === "message.received") await reply(event.data.message!);
    db.prepare("UPDATE inbox SET done = 1 WHERE id = ?").run(event.id);
  } catch (err) {
    console.error(event.id, err); // bleibt done = 0: wird beim nächsten Start erneut verarbeitet
  }
}

async function reply(m: Message) {
  const text = m.text?.trim();
  if (!text) return send(m.chat, "Ich kann im Moment nur Textnachrichten lesen.", `reply-${m.id}`);
  if (OPT_OUT.has(text) || /^opt[\s_-]*out$/i.test(text)) {
    return send(m.chat, "Du hast dich abgemeldet. Schreib jederzeit wieder, um neu anzufangen.", `reply-${m.id}`, true);
  }

  const turn = db.prepare("INSERT OR IGNORE INTO turns (ref, chat, role, content) VALUES (?, ?, ?, ?)");
  turn.run(m.id, m.chat, "user", text);
  const history = db
    .prepare("SELECT role, content FROM (SELECT * FROM turns WHERE chat = ? ORDER BY id DESC LIMIT 20) ORDER BY id")
    .all(m.chat) as Turn[];

  const response = await openai.responses.create({
    model: MODEL,
    instructions: SYSTEM,
    input: history,
    reasoning: { effort: "low" },
    max_output_tokens: 1000,
    store: false, // der Verlauf liegt in unserer Datenbank, nicht bei OpenAI
    safety_identifier: createHash("sha256").update(m.chat).digest("hex"),
  });
  const answer = response.output_text.trim();
  if (!answer) throw new Error(`empty answer, status ${response.status}`);

  await send(m.chat, answer.slice(0, 4096), `reply-${m.id}`);
  turn.run(`reply-${m.id}`, m.chat, "assistant", answer);
}

async function send(to: string, text: string, key: string, overrideOptout = false) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`${WHASP}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.WHASP_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key, // ein Replay sendet nie doppelt
      },
      body: JSON.stringify({ to, text, ...(overrideOptout && { override_optout: true }) }),
    });
    if (res.ok) return;
    if (res.status === 403 || res.status === 409) {
      // Abgemeldet, Chat nicht offen, Nummer nicht verfügbar: Ein Retry hilft nicht.
      console.warn("whasp refused the reply:", res.status, await res.text());
      return;
    }
    if ((res.status !== 429 && res.status < 500) || attempt === 4) throw new Error(`whasp ${res.status}`);
    await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // 2 s, 4 s, 8 s
  }
}

for (const row of db.prepare("SELECT body FROM inbox WHERE done = 0 ORDER BY rowid").all()) {
  enqueue(JSON.parse(row.body as string));
}
serve({ fetch: app.fetch, port: 3000 });
console.log("Listening on :3000");

Warum speichert der Webhook das Event vor der Antwort?

Der Webhook-Handler schreibt das Event in SQLite, bevor er antwortet, denn whasp löscht den Body eines Events, sobald es zugestellt wurde, und die Nachricht existiert sonst nirgends. whasp wertet jedes 2xx innerhalb von 10 Sekunden als Erfolg und versucht es sonst bis zu 12 Mal erneut. Ein Modellaufruf kann länger als 10 Sekunden dauern, der Handler kann darauf also nicht warten. Erst schreiben, dann antworten heißt: Sobald whasp 204 sieht, liegt die Nachricht sicher auf deiner Platte.

Drei Details im Handler sind wichtig:

  • Der rohe Body. c.req.text() liefert die Bytes, die whasp signiert hat. Die Klasse Webhook aus standardwebhooks prüft webhook-id, webhook-timestamp (sie lehnt alles ab, was mehr als fünf Minuten abweicht) und webhook-signature gegen <id>.<timestamp>.<body>, deshalb scheitert die Prüfung eines geparsten und neu serialisierten Objekts.
  • Duplikate. INSERT OR IGNORE auf der Event-ID macht aus einer wiederholten Zustellung ein No-op, und das Duplikat bekommt trotzdem ein 204, sonst würde whasp es weiter erneut senden.
  • Replay und Reihenfolge. Zeilen mit done = 0 werden beim Start erneut eingereiht. Die Spur pro Chat in enqueue arbeitet die Nachrichten einer Person der Reihe nach ab, während verschiedene Personen parallel laufen.

Der Webhook-Leitfaden behandelt, was diese Version weglässt: Retries des Workers, den Retry-Plan von whasp, Reihenfolge und Tests.

Wie ruft der Code die OpenAI Responses API auf?

Der Agent ruft openai.responses.create einmal pro Nachricht auf. OpenAI empfiehlt die Responses API für alle neuen Projekte, und diese Anfrage nutzt sieben Felder:

Feld Wert im Code Warum
model gpt-6-luna Das effizienteste Modell in OpenAIs Liste; zum Tauschen änderst du MODEL.
instructions der System-Prompt Ein Feld auf oberster Ebene, also keine System-Nachricht im Verlauf.
input die letzten 20 Turns als { role, content } Die API ist zustandslos: Du schickst den Verlauf jedes Mal mit.
reasoning.effort low Chat braucht selten tiefes Reasoning, und weniger Effort ist schneller.
max_output_tokens 1000 Begrenzt die Kosten einer Antwort. Reasoning-Tokens zählen dazu.
store false Verhindert, dass OpenAI das Response-Objekt speichert.
safety_identifier SHA-256 der Telefonnummer Lässt OpenAI Missbrauch auf einen Endnutzer zurückführen.

Stand 19. Oktober 2026 hat OpenAIs Modellliste drei Flaggschiff-Modelle: gpt-6-luna ($0,10 Input und $0,50 Output pro Million Tokens, für hohes Volumen gebaut), gpt-6.1-sol ($2 und $10) und gpt-6-astra ($10 und $50, das leistungsfähigste). Zu den Preisen von Luna kostet eine Antwort, die 1.500 Tokens liest und 150 schreibt, etwa $0,0002, plus eventuelle Reasoning-Tokens.

Der Verlauf gehört absichtlich dir. Die API ist zustandslos, solange du nicht previous_response_id verkettest oder ein Conversations-Objekt anhängst, und Response-Objekte werden standardmäßig 30 Tage gespeichert. Dieser Agent schickt Text-Turns aus seiner eigenen Datenbank mit und setzt store: false, das Löschen oder Zusammenfassen eines Gesprächs hängt also nie von der Seite von OpenAI ab. Der Preis dafür: Bei Reasoning-Modellen empfiehlt OpenAI, jedes Output-Item erneut mitzuschicken, nicht nur den Text. Ein reiner Text-Verlauf verliert die Reasoning-Items, was bei kurzen Chat-Antworten mit niedrigem Effort vertretbar ist.

safety_identifier trägt eine stabile, gehashte ID pro Endnutzer. OpenAI sagt, du sollst den Nutzernamen oder die E-Mail-Adresse hashen, statt sie zu senden, deshalb hasht der Code die Telefonnummer.

Zwanzig Turns sind ein Fenster, kein Gedächtnis: Wer nach drei Wochen zurückkommt, bekommt nur seine letzten zwanzig Nachrichten. Konversationsgedächtnis behandelt Zusammenfassungen und Langzeitfakten, und einen KI-Agenten wie einen Menschen schreiben lassen den System-Prompt.

Wie antwortet der Agent, und was kann schiefgehen?

Der Agent antwortet mit einem POST /v1/messages. Er sendet to und text und lässt from weg, whasp nimmt also die Nummer, an die diese Person zuletzt geschrieben hat. Die API antwortet mit 202 und der Nachricht als queued, und eine Nummer sendet ihre Nachrichten eine nach der anderen, im Abstand von etwa einer Sekunde.

Der Header Idempotency-Key ist reply- plus die ID der eingehenden Nachricht. whasp behält einen Key 7 Tage lang, und derselbe Key liefert die erste Nachricht zurück und sendet nie zweimal. Ein erneut abgespieltes Event kann also niemanden doppelt anschreiben.

Die Funktion send sortiert Fehler in drei Gruppen:

Antwort Was sie bedeutet Was der Code tut
403 chat_opted_out, 409 chat_not_open, 409 number_unavailable, 409 idempotency_conflict Ein Retry hilft nicht: Die Person hat sich abgemeldet, nie geschrieben, die Nummer ist nicht verfügbar, oder dieselbe Antwort ist schon unterwegs. Loggt es und verwirft die Antwort.
429 rate_limited (über 100 Anfragen pro 10 Sekunden und Account), 429 number_daily_limit, jeder 5xx Vorübergehend. Versucht es viermal, mit 2, 4 und 8 Sekunden Pause dazwischen, und wirft dann einen Fehler; das Event wird beim nächsten Start erneut verarbeitet.
Jeder andere 4xx Ein Fehler auf deiner Seite, etwa ein falscher API-Key. Wirft einen Fehler; das Event bleibt offen, bis du es behebst.

STOP wird zweimal behandelt. whasp meldet einen Chat ab (Opt-out), wenn eine Nachricht genau STOP, UNSUBSCRIBE, OPTOUT, CANCEL, END oder QUIT lautet (Groß- und Kleinschreibung zählt) oder irgendeine Schreibweise von opt out, und der nächste Versand bekommt 403. Der Code prüft zuerst dieselben Wörter, überspringt das Modell und sendet einen Abschied mit override_optout: true. Höfliche Bitten wie „bitte keine Nachrichten mehr“ musst du selbst abfangen: Halte dafür ein eigenes Flag vor und ruf für diesen Chat das Modell nicht mehr auf.

Medien kommen als Datei an. Ein Foto oder eine Sprachnachricht hat im Event keinen text, der Code antwortet deshalb mit einer kurzen Zeile. Sprachnachrichten transkribieren und Fotos lesen zeigt, wie du sie herunterlädst und transkribierst.

Wie startest du den Agenten und bringst ihn online?

Du startest ihn in fünf Schritten.

  1. Starte einen Tunnel, damit whasp deinen Laptop erreicht. cloudflared tunnel --url http://localhost:3000 gibt eine temporäre trycloudflare.com-Adresse aus und braucht keinen Cloudflare-Account.

  2. Registriere den Endpoint. Die Antwort enthält das Signing-Secret:

    curl -X PUT https://api.whasp.ai/v1/webhook \
      -H "Authorization: Bearer $WHASP_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://YOUR-TUNNEL.trycloudflare.com/whasp/webhook" }'
  3. Kopiere das secret (es beginnt mit whsec_) in .env und starte dann node --env-file=.env server.ts.

  4. Sende ein Test-Event mit curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". Die Antwort meldet delivered und den Statuscode, den dein Server zurückgegeben hat.

  5. Schreib an deine Nummer. GET /v1/numbers/pick liefert einen wa_link, der den Chat auf einem Smartphone öffnet; schreib „hi“ und warte auf die Antwort.

Für die Produktion lässt du einen Node-Prozess auf einem Host mit HTTPS und einer persistenten Platte für agent.db laufen. Plattformen mit flüchtigen Platten oder Setups mit mehreren Instanzen brauchen eine gemeinsame Datenbank wie Postgres und eine Sperre pro Chat statt der Spuren im Arbeitsspeicher. GET /v1/webhook/deliveries listet die Zustellungen der letzten 7 Tage auf.

Was baust du als Nächstes?

Fang mit dem an, was Nutzern zuerst auffällt: ein Konversationsgedächtnis, das Wochen überdauert, und ein Agent, den du wie einen Menschen schreiben lassen kannst. Sichere danach die Zustellung mit dem Webhook-Leitfaden ab oder sieh dir denselben Agenten mit Claude an.

Fragen

Wie verbinde ich ChatGPT mit WhatsApp?

Du baust einen Agenten auf der OpenAI API: WhatsApp-Nachrichten kommen an einem Webhook an, du rufst die Responses API mit dem Gespräch auf und sendest die Antwort über eine Messaging-API wie POST /v1/messages von whasp zurück.

Welches OpenAI-Modell sollte ein WhatsApp-Bot nutzen?

Für Chat-Antworten beginnst du mit gpt-6-luna, dem effizientesten Modell von OpenAI, das am 19. Oktober 2026 $0,10 Input und $0,50 Output pro Million Tokens kostet. Wechsle zu gpt-6.1-sol, wenn die Antworten mehr Reasoning brauchen.

Merkt sich die OpenAI Responses API das Gespräch?

Nicht, solange du es nicht verlangst. Eine Anfrage ist standardmäßig unabhängig, und einen Verlauf setzt du fort, indem du die Historie erneut mitschickst, previous_response_id übergibst oder ein Conversations-Objekt anhängst. Dieses Tutorial schickt seine eigene Historie mit und setzt store auf false.

Wie verhindere ich, dass ein WhatsApp-Bot auf eine Nachricht doppelt antwortet?

Dedupliziere eingehende Events anhand der Event-ID und sende jede Antwort mit einem Idempotency-Key, der aus der ID der eingehenden Nachricht gebildet wird. Dann kann eine wiederholte Zustellung oder ein Replay nach einem Absturz keine zweite Antwort senden.

Kann der Agent ein Gespräch beginnen?

Nein. Bei whasp kann eine Nummer nur Personen antworten, die ihr zuerst geschrieben haben, und eine Nachricht an jede andere Person liefert 409 chat_not_open. Gib neuen Nutzern einen wa.me-Link, damit sie den Chat selbst beginnen.

Alle Artikel