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.

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.
- Eine Person schreibt an deine Nummer.
- 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. - Dein Server prüft die Signatur, speichert das Event und antwortet mit
204. - Im Hintergrund lädt er die letzten Turns des Chats aus deiner eigenen Datenbank und ruft die Responses API auf.
- 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:sqliteist 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 KlasseWebhookausstandardwebhooksprüftwebhook-id,webhook-timestamp(sie lehnt alles ab, was mehr als fünf Minuten abweicht) undwebhook-signaturegegen<id>.<timestamp>.<body>, deshalb scheitert die Prüfung eines geparsten und neu serialisierten Objekts. - Duplikate.
INSERT OR IGNOREauf der Event-ID macht aus einer wiederholten Zustellung ein No-op, und das Duplikat bekommt trotzdem ein204, sonst würde whasp es weiter erneut senden. - Replay und Reihenfolge. Zeilen mit
done = 0werden beim Start erneut eingereiht. Die Spur pro Chat inenqueuearbeitet 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.
-
Starte einen Tunnel, damit whasp deinen Laptop erreicht.
cloudflared tunnel --url http://localhost:3000gibt eine temporäretrycloudflare.com-Adresse aus und braucht keinen Cloudflare-Account. -
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" }' -
Kopiere das
secret(es beginnt mitwhsec_) in.envund starte dannnode --env-file=.env server.ts. -
Sende ein Test-Event mit
curl -X POST https://api.whasp.ai/v1/webhook/test -H "Authorization: Bearer $WHASP_API_KEY". Die Antwort meldetdeliveredund den Statuscode, den dein Server zurückgegeben hat. -
Schreib an deine Nummer.
GET /v1/numbers/pickliefert einenwa_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.


