1. Webhooks in der ChatGPT App: wer ruft eigentlich wen an
In der klassischen HTTP-Welt ist alles einfach: Sie sind der Client, Sie machen einen POST auf /api/..., der Server antwortet, und die Welt ist glücklich. Bei Webhooks ist es umgekehrt: Ein externer Dienst initiiert selbst eine HTTP-Anfrage an Ihr Backend, wenn draußen etwas passiert ist.
In der ChatGPT-Apps-Ökosphäre taucht das in einigen typischen Szenarien auf. Zum Beispiel erhält GiftGenius nach dem Erstellen eines Checkouts über ACP/Instant Checkout vom Zahlungsanbieter eine Benachrichtigung payment_succeeded per Webhook. Oder ein Hintergrunddienst zur Generierung von Vorschaubildern für Geschenke sendet Ihnen image_ready, wenn das Rendering abgeschlossen ist. In solchen Fällen haben ChatGPT und Ihr MCP-Server bereits alles erledigt, der Ball liegt beim Drittanbieter, und dieser teilt Ihnen das Ergebnis über den Webhook mit.
Der zentrale Punkt: Die Initiative liegt außerhalb Ihres Systems. Die Anfrage kann jederzeit und beliebig oft eintreffen. Deshalb sollten Sie den Webhook-Handler als potenziell verwundbarsten Einstiegspunkt betrachten – dort klopft das ganze Internet an.
Eine kleine Tabelle zum Kontrast:
| Aufruftyp | Wer initiiert | Beispiel in GiftGenius |
|---|---|---|
| Normaler API-Aufruf | Sie | Der MCP-Server ruft die Stripe API auf |
| Webhook | Außenwelt | Stripe sendet payment_succeeded an Sie |
2. Einfache Übersicht: wo sind hier ChatGPT, wo MCP, wo der Webhook
Schematisch sieht der Ablauf so aus:
sequenceDiagram
participant User as Nutzer in ChatGPT
participant GPT as ChatGPT + Modell
participant App as GiftGenius (MCP/App)
participant PSP as Zahlungsdienst (Stripe/ACP)
User->>GPT: "Ich möchte ein Geschenk kaufen"
GPT->>App: callTool(create_checkout)
App->>PSP: POST /checkout_sessions
PSP-->>App: 200 OK + checkout_session_id
App-->>GPT: ToolOutput (Checkout-Informationen)
PSP-->>App: POST /webhooks/payment_succeeded
App-->>PSP: 200 OK (Ereignis empfangen)
App->>DB: Bestellung als bezahlt markieren
Der erste Teil sind normale ausgehende Anfragen, die Sie bereits beherrschen. Der Webhook ist der untere Teil der Skizze, bei dem der Zahlungsdienst von sich aus bei Ihnen anklopft. Genau darum geht es heute.
3. Basis-Handler für Webhooks in Next.js (Gerüst)
Wir entwickeln unser Lernprojekt GiftGenius in Next.js 16 weiter. In der Vorlage gibt es app/ mit UI und app/mcp/route.ts mit dem MCP-Server.
Den Webhook-Handler legt man sinnvollerweise in einen separaten HTTP-Route aus, zum Beispiel: app/api/webhooks/commerce/route.ts.
Ein minimales Gerüst sieht so aus:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // 1. Den Body als String lesen
const headers = Object.fromEntries(req.headers); // 2. Header erfassen
// 3. TODO: Signaturvalidierung (fügen wir gleich hinzu)
// 4. TODO: JSON parsen und Ereignis verarbeiten
return new Response("ok", { status: 200 }); // 5. Schnell mit 2xx antworten
}
Hier stecken bereits mehrere wichtige Ideen drin.
Erstens lesen wir den Body als Text und nicht sofort mit await req.json(). Viele Anbieter signieren den „rohen“ Bytestrom des Bodys, und wenn Sie ihn vor der Signaturprüfung parsen (oder gar formatieren), passt die Signatur nicht mehr.
Zweitens denken wir von Anfang an an die schnelle 2xx-Antwort. Schwere Arbeit verlagern Sie besser entweder in einen separaten Worker oder zumindest in eine asynchrone Funktion nach dem Protokollieren des Ereignisses. Das steht in direktem Zusammenhang mit Timeouts und erneuten Zustellungen, über die wir gleich sprechen.
4. Webhook-Signatur: wie man „Stripe“ von „jemandem mit curl“ unterscheidet
Erinnern wir uns an das TODO im Gerüst des Webhook-Handlers – „Signaturvalidierung“. Schauen wir uns an, wie man den echten Stripe von „jemandem mit curl“ unterscheidet.
Die größte Naivität ist zu glauben, dass niemand eine URL findet, nur weil sie kompliziert ist (/api/webhooks/stripe/super-secret-abc123). URL-Geheimnisse sind im Grunde security through obscurity: der Versuch, sich hinter einer komplizierten URL zu verstecken, was nur sehr schwachen Schutz bietet. Die richtige Verteidigungslinie ist die kryptografische Signatur.
Praktisch alle ernstzunehmenden Anbieter (Stripe, ACP, viele CRMs) berechnen eine HMAC-Signatur über den Anfrage-Body und die Zeit und legen das Ergebnis in einen Header. Sie als Empfänger machen dasselbe und vergleichen. Wenn es auch nur geringfügig abweicht, verwerfen Sie die Anfrage als Fälschung.
Allgemeines Rezept:
- Sie haben ein Webhook-Secret, das Sie im Anbieter-Dashboard erhalten und in die Umgebungsgeheimnisse gelegt haben (z. B. STRIPE_WEBHOOK_SECRET in den Vercel-Env-Variablen).
- Der Anbieter berechnet beim Senden der Anfrage den HMAC über timestamp + '.' + rawBody.
- In einen Header, z. B. Stripe-Signature, schreibt er den timestamp und eine oder mehrere Signaturen.
- Sie entnehmen im Handler den timestamp, berechnen Ihren HMAC nach derselben Regel und vergleichen.
Mini-Beispiel in TypeScript unter Verwendung von crypto:
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // Algorithmus wählen
.update(payload, "utf8") // Rohtext des Bodys
.digest("hex"); // Hex-String
}
Beispiel zur Prüfung der Signatur und der Aktualität des Ereignisses:
const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });
const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
return new Response("timestamp too old", { status: 400 });
}
const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
process.env.STRIPE_WEBHOOK_SECRET!,
payload
);
if (!crypto.timingSafeEqual(
Buffer.from(expectedSig, "hex"),
Buffer.from(theirSig, "hex")
)) {
return new Response("invalid signature", { status: 400 });
}
Beachten Sie timingSafeEqual – das schützt vor Timing-Angriffen, bei denen ein Angreifer versucht, die Signatur anhand der Vergleichsdauer zu erraten.
Nach erfolgreicher Signaturprüfung können Sie beruhigt JSON.parse(rawBody) oder await req.json() verwenden, in dem Wissen, dass dies vom echten Anbieter kam.
Zusätzliche Verteidigungsebenen wie IP-Allowlist (nur Anfragen von Anbieter-Adressen zulassen) und eine eigene Domain für Webhooks schaden nicht, aber die Kryptosignatur gibt Ihnen die Sicherheit über die Echtheit.
5. Timeouts, schnelle Antwort und asynchrone Verarbeitung
Webhooks mögen schnelle Antworten. Die meisten Zahlungs- und Commerce-Plattformen erwarten, dass Ihr Endpoint innerhalb weniger Sekunden mit 2xx antwortet (oft bis zu 10 Sekunden, manchmal weniger). Wenn Sie zu lange „nachdenken“, gilt der Aufruf als fehlgeschlagen und die Plattform beginnt, die Anfragen zu wiederholen.
Naiv könnte das so aussehen: Sie prüfen die Signatur, gehen in die DB, rufen noch drei externe APIs auf, erstellen einen Bericht, generieren ein PDF und geben erst dann 200 OK zurück. Wenn irgendetwas davon nur kurz hängt, hält der Zahlungsanbieter den Webhook für fehlgeschlagen und sendet ihn erneut. Am Ende erzeugen Sie zweimal die Bestellung, senden zweimal eine E-Mail, rufen zweimal irgendein GPT-Tool auf – und dürfen das Chaos aufräumen.
Das richtige Muster lautet: „annehmen, protokollieren, aufschieben“:
- Signatur und grundlegende Invarianten prüfen (Ereignistyp, Pflichtfelder).
- Ereignis schnell in eine Tabelle/Warteschlange schreiben (minimale DB-Operationen).
- 2xx zurückgeben.
- Das Ereignis im Hintergrund, mit einem separaten Worker, verarbeiten.
Vereinfachtes Beispiel eines „halbkorrekten“ Handlers ohne separate Queue, aber mit schneller Persistierung:
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
if (!verifySignature(headers, rawBody)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
await saveWebhookEvent(event); // schneller DB-Schreibvorgang
// Hier könnte man die Aufgabe in den Hintergrund legen (setImmediate/Queue),
// aber im Lernbeispiel belassen wir es vorerst bei der Persistierung:
// ohne await aufrufen, damit die 200-Antwort sofort rausgeht.
processWebhookEventLater(event).catch(console.error);
return new Response("ok", { status: 200 });
}
Beachten Sie: Wir machen kein await processWebhookEventLater(...). Der Handler stellt die Aufgabe in den Hintergrund und gibt sofort 200 zurück, um nicht an Webhook-Timeouts zu scheitern.
Im echten Produktivbetrieb steht hier häufig eine Warteschlange (z. B. eine separate Tabelle webhook_jobs oder ein externer Dienst), und Worker verarbeiten die Ereignisse sauber, ohne den Empfang neuer zu blockieren.
6. Idempotenz und Deduplizierung: wie man Geld nicht zweimal abbucht
In Lernbeispielen zeichnet man gern perfekte Pfeile: ein Ereignis → eine Verarbeitung → glückliche Bestellung. In der Realität kommen Webhooks paketweise und mehrfach hintereinander.
Die Gründe sind einfach: Das Netzwerk ist unzuverlässig, Timeouts passieren, und viele Anbieter senden Ereignisse bewusst erneut, bis sie sicher ein 2xx erhalten. Gerade bei Zahlungen ist das wichtig: Lieber payment_succeeded doppelt schicken als es für immer verlieren.
Ihre Geschäftslogik muss deshalb idempotent sein: Eine wiederholte Verarbeitung desselben Ereignisses darf das Ergebnis nicht verändern (oder zumindest das System nicht beschädigen).
Typisches Muster:
- Das Ereignis hat einen stabilen Bezeichner, z. B. event.id oder checkout_session_id.
- Sie speichern ihn in einer Tabelle verarbeiteter Ereignisse und versehen dieses Feld mit einem eindeutigen Index.
- Bei jedem Webhook prüfen Sie zuerst: Wenn bereits ein Eintrag mit dieser ID und dem Status „verarbeitet“ existiert, antworten Sie einfach mit 200 und tun nichts.
Mini-Beispiel mit Pseudo-ORM:
async function handlePaymentSucceeded(event: any) {
const existing = await db.webhookEvents.findUnique({
where: { providerId: event.id },
});
if (existing?.processedAt) {
return; // bereits erledigt
}
await db.$transaction(async (tx) => {
await tx.webhookEvents.upsert({
where: { providerId: event.id },
update: { processedAt: new Date() },
create: {
provider: "stripe",
providerId: event.id,
type: event.type,
payload: event,
processedAt: new Date(),
},
});
await tx.orders.update({
where: { checkoutSessionId: event.data.object.id },
data: { status: "PAID" },
});
});
}
Wichtig ist der Transaktionsaspekt: Sie markieren das Ereignis gleichzeitig als verarbeitet und ändern die Bestellung. Wenn mittendrin etwas fehlschlägt, wird die Transaktion zurückgerollt, und bei der nächsten erneuten Zustellung verarbeiten Sie es erneut – ohne doppelte Einträge.
Eine gute Praxis ist auch, die Operation selbst idempotent zu gestalten, zum Beispiel:
- „Bestellstatus auf PAID setzen“ statt „Kontostand um +100 erhöhen“;
- „Eintrag erstellen, falls nicht vorhanden“ statt „noch eine Zeile hinzufügen“.
7. Validierung von Webhook-Daten und PII: die Signatur ist nicht der einzige Filter
Auch wenn ein Webhook signiert ist und von einem echten Dienst kommt, sollten Sie mit seinen Daten genauso vorsichtig umgehen wie mit Benutzereingaben oder Tool-Argumenten. In der letzten Vorlesung haben wir bereits besprochen: Schemata und Normalisierung sind Ihre Firewall.
Ein Schema für ein Ereignis kann zum Beispiel so aussehen (auf TypeScript/Zod-Ebene):
import { z } from "zod";
const paymentSucceededSchema = z.object({
id: z.string(),
type: z.literal("payment_succeeded"),
data: z.object({
object: z.object({
id: z.string(), // checkout_session_id
amount_total: z.number(),
currency: z.string(),
metadata: z.record(z.string(), z.string()).optional(),
}),
}),
});
Im Handler validieren Sie:
const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// danach arbeiten Sie nur noch mit parsed
So schützen Sie sich vor Überraschungen wie „der Anbieter hat das Format geändert“, „im Testsystem wurde ein Feld plötzlich nullable“ usw. Wenn etwas nicht stimmt, protokollieren Sie den Fehler und geben 400 zurück; der Anbieter wiederholt später oder schickt einen Alarm.
An PII sollten Sie ebenfalls denken: Webhook-Bodys enthalten oft E-Mail, Lieferadresse, manchmal sogar Teile der Zahlungsdaten (tokenisiert). Diese in Logs zu maskieren und nicht roh an externe APM/Logging-Dienste zu senden, ist Pflicht – darüber haben wir beim Thema Secrets und vertrauliche Daten gesprochen.
Und ganz sicher sollten Sie nicht den kompletten JSON des Webhooks ungefiltert zurück an ChatGPT als ToolOutput schicken – das Modell muss nicht alles sehen, was der Zahlungsanbieter gesendet hat, besonders wenn es für die UX nicht nötig ist.
8. GiftGenius in der Praxis: Zahlungs-Webhook für ACP/Instant Checkout
Zurück zu unserem GiftGenius. Im Modul über Commerce und ACP haben wir bereits besprochen, wie der Agent eine Checkout-Session erstellt und wie anschließend über Instant Checkout die Zahlung erfolgt. Aus Sicht unseres Backends bleibt danach, auf den Webhook order.paid (oder checkout.session.completed in Stripe-Begriffen) zu warten, um:
- den Bestellstatus festzuschreiben;
- die Kette „E-Mail senden“ / „Versand vorbereiten“ zu starten;
- dem Agenten eine verlässliche Antwort „Zahlung erfolgt“ zu geben.
Beispiel eines einfachen Handlers in Next.js:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
if (!verifyCommerceSignature(headers, rawBody)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
if (event.type === "payment_succeeded") {
// Idempotenter Handler aus dem vorherigen Abschnitt
await handlePaymentSucceeded(event);
}
return new Response("ok", { status: 200 });
}
Die Funktion verifyCommerceSignature implementiert die HMAC-Signaturlogik analog zu dem, was wir oben betrachtet haben. In einem realen Projekt lohnt sich ein eigenes Modul pro Anbieter (verifyStripeSignature, verifyACPCheckoutSignature), um Schemata nicht zu vermischen.
In handlePaymentSucceeded:
- validieren Sie das Objekt gegen das Schema (Zod);
- markieren Sie in einer Transaktion das Ereignis als verarbeitet und aktualisieren die Bestellung;
- stellen Sie optional Aufgaben für „langsame“ Aktionen in die Warteschlange: E-Mails, Analytics, zusätzliche API-Aufrufe.
Dieser Ansatz macht die Kette „ACP → Webhook → GiftGenius“ robust gegenüber wiederholten Ereignissen, temporären Ausfällen und merkwürdigen Daten.
9. Wo Webhooks mit MCP, ChatGPT und Tools zusammenkommen
Auf den ersten Blick scheinen Webhooks getrennt von der ChatGPT App zu leben: irgendein HTTP-Route im Backend und fertig. In Wirklichkeit sind sie ein wichtiger Teil der Gesamtarchitektur.
Typischerweise sieht die Kopplung so aus:
- Das MCP-Tool create_checkout wird vom Modell in ChatGPT aufgerufen.
- Der MCP-Server spricht den Zahlungsdienst an, erstellt die Checkout-Session und gibt im ToolOutput Informationen zur Bestellung und den Status „Zahlung ausstehend“ zurück.
- Der Nutzer schließt die Zahlung im UI ab (Instant Checkout erledigt das direkt in ChatGPT).
- Der Zahlungsdienst sendet den Webhook an Ihr Backend.
- Das Backend ändert über die DB den Bestellstatus; beim nächsten Toolaufruf oder Follow-up des Modells kann man dann ehrlich sagen: „Bestellung bezahlt, hier sind die Details“.
Mitunter kann das Backend ein Follow-up indirekt initiieren – zum Beispiel über ein Widget oder eine Realtime-Integration, die auf Signal vom Server selbst sendFollowUpMessage aufruft. Aber selbst wenn es das nicht gibt, liegt die Information über die Zahlung bei Ihnen, und beim nächsten Toolaufruf liest das Backend den neuen Status aus der DB und liefert dem Modell aktualisierte Daten für die Antwort.
Wichtig ist, dass der Webhook ein Eintrittspunkt auf derselben Ebene wie der MCP-Server ist und dieselben Services nutzt (DB, Queues, Secrets). Die Sicherheitslogik ist im Wesentlichen dieselbe: minimale Rechte, validierte Eingabedaten, sorgfältiges Logging.
10. Typische Fehler bei der Arbeit mit Webhooks und externen Integrationen
Fehler Nr. 1: keine Prüfung der Webhook-Signatur.
Manchmal begnügen sich Entwickler mit einer „geheimen“ URL oder einem simplen Bearer my-secret im Header. Wenn dieses Secret irgendwo leakt, kann jeder Ihnen Webhooks schicken, Bestellungen erzeugen, Zahlungsstatus ändern und überhaupt alles Mögliche treiben. Der richtige Ansatz ist die kryptografische Signatur des Bodys (HMAC) und die Prüfung des timestamp. Das macht eine Fälschung erheblich schwieriger als „eine URL zu erraten“.
Fehler Nr. 2: schwere Verarbeitung innerhalb der Webhook-Anfrage.
Im Webhook-Handler „Bestellung erstellen, zwei externe APIs aufrufen, PDF generieren, ein GPT-Modell anrufen, 5 E-Mails senden“ – das ist ein sicherer Weg zu Timeouts und Wiederholungsversuchen. Am Ende erzeugen Sie selbst Duplikate, die Sie später aufdröseln müssen. Viel zuverlässiger ist es, den Empfang des Ereignisses schnell zu bestätigen (2xx), es in DB oder Queue zu schreiben und im Hintergrund zu verarbeiten.
Fehler Nr. 3: nicht-idempotente Geschäftslogik.
Häufig sieht man Code wie „bei jedem payment_succeeded den Kontostand um den Betrag erhöhen“. Kommt der Webhook zweimal, ist der Kontostand doppelt so hoch. Andere Variante – eine Bestellung doppelt erzeugen oder dem Nutzer zweimal eine E-Mail senden. Idempotenz erreicht man über einen stabilen Ereignis-Identifier, eine Tabelle verarbeiteter Ereignisse, Transaktionen und Operationen nach dem Muster „Status setzen“ statt „noch etwas addieren“.
Fehler Nr. 4: fehlende Schemata und Validierung der Webhook-Daten.
Selbst ein signierter Webhook kann nicht das sein, was Sie erwarten: Der Anbieter ändert das Format, Sie kopieren JSON aus der Doku und im Testsystem heißt ein Feld anders, oder Sie haben sich einfach in den Typen vertan. Wenn man solches JSON ohne Schema und Prüfungen verarbeitet, brechen Fehler still Bestellungen oder lösen Ausnahmen mitten in der Kette aus. Der Einsatz von Zod/JSON Schema am Eingang erleichtert die Diagnose und erlaubt es, ungültige Ereignisse klar abzuweisen.
Fehler Nr. 5: Logging roher Webhook-Bodys mit PII.
Im Eifer des Debuggens ist schnell ein console.log(rawBody) gesetzt – und wird vergessen. In Produktion führt das zu Logs voller E-Mails, Adressen und weiterer PII, die an externe Log-Dienste gehen. Im Hinblick auf Privatsphäre und Regulierung (GDPR-ähnliche Themen) ist das ein Schuss ins Knie. Implementieren Sie besser von Anfang an PII-Scrubbing – sensible Felder maskieren und nur das loggen, was wirklich für die Diagnose nötig ist.
Fehler Nr. 6: Vermischung von Test- und Live-Webhooks.
Typische Situation – derselbe Endpoint nimmt Ereignisse sowohl aus der Test- als auch aus der Live-Umgebung des Anbieters an. Am Ende ändert eine Testzahlung plötzlich den Status einer echten Bestellung oder umgekehrt. Zuverlässiger ist es, die URLs zu trennen (z. B. /webhooks/commerce/test und /webhooks/commerce/live) oder zumindest den „Modus“ in der Konfiguration zu speichern und am Eingang zu prüfen.
Fehler Nr. 7: vollständige Abhängigkeit des ChatGPT-Szenarios vom synchronen Webhook.
Manchmal möchte man, dass das Modell unmittelbar nach dem Toolaufruf und dem Erstellen der Checkout-Session das Zahlungsergebnis kennt. Aber Webhooks sind per Definition asynchron, und eine Zahlung kann dauern. Das Szenario so zu bauen, als würde alles sofort passieren, ist eine schlechte Idee. Besser ist es, Dialoge und Tools so zu entwerfen, dass sie mit verzögerten Ereignissen umgehen: Bestellzustand speichern, dem Nutzer erlauben, zum Chat zurückzukehren und später aktuelle Informationen zu erhalten.
GO TO FULL VERSION