1. Webhooki w ChatGPT App: kto w ogóle do kogo puka
W klasycznym świecie HTTP wszystko jest proste: wy — klient, robicie POST /api/..., serwer odpowiada i świat jest szczęśliwy. Z webhookami jest odwrotnie: zewnętrzny serwis sam inicjuje żądanie HTTP do waszego backendu, kiedy na zewnątrz coś się wydarzy.
W ekosystemie ChatGPT Apps to pojawia się w kilku typowych scenariuszach. Na przykład, GiftGenius po utworzeniu checkoutu przez ACP/Instant Checkout dostaje od dostawcy płatności powiadomienie payment_succeeded przez webhook. Albo backendowy serwis renderowania podglądów obrazków do prezentów wysyła wam image_ready, gdy render się zakończy. W takich przypadkach ChatGPT i wasz serwer MCP już zrobiły swoje, piłka jest po stronie trzeciego serwisu i to on przekazuje wynik przez webhook.
Kluczowa cecha: inicjatywa jest poza waszym systemem. Żądanie może przyjść w dowolnym momencie i dowolną liczbę razy. Dlatego o handlerze webhooka trzeba myśleć jak o potencjalnie najbardziej wrażliwym punkcie — puka tam cały internet.
Mała tabelka dla kontrastu:
| Typ wywołania | Kto zaczyna | Przykład w GiftGenius |
|---|---|---|
| Zwykłe żądanie API | Wy | Serwer MCP wywołuje Stripe API |
| Webhook | Świat zewnętrzny | Stripe wysyła payment_succeeded do was |
2. Prosty schemat: gdzie tu ChatGPT, gdzie MCP, gdzie webhook
Schematycznie droga wygląda tak:
sequenceDiagram
participant User as Użytkownik w ChatGPT
participant GPT as ChatGPT + model
participant App as GiftGenius (MCP/App)
participant PSP as Dostawca płatności (Stripe/ACP)
User->>GPT: "Chcę kupić prezent"
GPT->>App: callTool(create_checkout)
App->>PSP: POST /checkout_sessions
PSP-->>App: 200 OK + checkout_session_id
App-->>GPT: ToolOutput (informacje o checkout)
PSP-->>App: POST /webhooks/payment_succeeded
App-->>PSP: 200 OK (zdarzenie przyjęte)
App->>DB: oznaczyć zamówienie jako opłacone
Pierwsza część — zwykłe żądania wychodzące, które już umiecie robić. Webhook to dolna część schematu, gdzie dostawca płatności sam puka do was. I to ona nas dziś interesuje.
3. Podstawowy handler webhooka w Next.js (szkielet)
Kontynuujemy rozwój naszego edukacyjnego GiftGenius na Next.js 16. W szablonie mamy app/ z UI i app/mcp/route.ts z serwerem MCP.
Handler webhooka logicznie wydzielić do osobnego endpointu HTTP, na przykład: app/api/webhooks/commerce/route.ts.
Minimalny szkielet wygląda tak:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // 1. Odczytujemy body jako tekst
const headers = Object.fromEntries(req.headers); // 2. Pobieramy nagłówki
// 3. TODO: weryfikacja podpisu (dodamy za chwilę)
// 4. TODO: parsowanie JSON i obsługa zdarzenia
return new Response("ok", { status: 200 }); // 5. Szybko odpowiadamy 2xx
}
Kryje się tu kilka ważnych idei.
Po pierwsze, odczytujemy body jako tekst, a nie od razu await req.json(). Wielu dostawców podpisuje właśnie „surowy” strumień bajtów body i jeśli sparsujecie go (a tym bardziej sformatujecie) przed weryfikacją podpisu, podpis już się nie zepnie.
Po drugie, od razu myślimy o szybkiej odpowiedzi 2xx. Cięższą pracę lepiej wynieść do osobnego workera albo przynajmniej do funkcji async po zalogowaniu zdarzenia. To bezpośrednio wiąże się z timeoutami i powtórnymi wysyłkami, o których za chwilę.
4. Podpis webhooków: jak odróżnić „Stripe” od „gościa z curl”
Przypomnijmy TODO ze szkicu handlera webhooka — „weryfikacja podpisu”. Zobaczmy, jak konkretnie odróżnić prawdziwego Stripe’a od „gościa z curl”.
Największa naiwność to myśleć, że jeśli URL jest skomplikowany (/api/webhooks/stripe/super-secret-abc123), to nikt go nie znajdzie. Sekrety w URL to w gruncie rzeczy security through obscurity: próba schowania się za złożonym adresem, która daje bardzo słabą ochronę. Prawidłowa linia obrony — kryptograficzny podpis.
Praktycznie wszyscy poważni dostawcy (Stripe, ACP, wiele CRM) liczą podpis HMAC po body żądania i czasie, a następnie wkładają wynik do nagłówka. Wy, jako odbiorca, robicie to samo i porównujecie. Jeśli choć odrobinę się nie zgadza — odrzucacie żądanie jako fałszywkę.
Ogólny przepis:
- Macie sekret webhooka, który otrzymaliście w panelu dostawcy i włożyliście do sekretów środowiska (na przykład STRIPE_WEBHOOK_SECRET w Vercel env).
- Dostawca przy wysyłce żądania liczy HMAC po timestamp + '.' + rawBody.
- W nagłówku, na przykład Stripe-Signature, umieszcza timestamp oraz jeden lub kilka podpisów.
- Wy w handlerze bierzecie timestamp, liczycie własny HMAC według tej samej reguły i porównujecie.
Mały przykład w TypeScript z użyciem crypto:
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // wybieramy algorytm
.update(payload, "utf8") // surowy tekst body
.digest("hex"); // łańcuch hex
}
Przykład sprawdzania podpisu i świeżości zdarzenia:
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 });
}
Zwróćcie uwagę na timingSafeEqual — to ochrona przed atakami czasowymi, kiedy napastnik próbuje zgadywać podpis na podstawie czasu porównania.
Po pomyślnej weryfikacji podpisu można już spokojnie zrobić JSON.parse(rawBody) albo await req.json(), wiedząc, że to przyszło od prawdziwego dostawcy.
Dodatkowe poziomy obrony, jak allowlista IP (zezwalanie na żądania tylko z adresów dostawcy) i osobna domena dla webhooków, nie zaszkodzą, ale to właśnie kryptograficzny podpis daje pewność co do autentyczności.
5. Timeouty, szybka odpowiedź i asynchroniczne przetwarzanie
Webhooki lubią tych, którzy odpowiadają szybko. Większość platform płatniczych i commerce oczekuje, że wasz endpoint odpowie 2xx w ciągu kilku sekund (często do 10 sekund, czasem mniej). Jeśli „myślicie” zbyt długo, uznają wywołanie za nieudane i zaczynają ponawiać żądania.
Wprost wygląda to tak: sprawdziliście podpis, poszliście do bazy, uderzyliście w jeszcze trzy zewnętrzne API, policzyliście raport, wygenerowaliście PDF i dopiero potem zwróciliście 200 OK. Jeśli coś się chociaż odrobinę zawiesi, dostawca płatności uzna, że webhook upadł, i wyśle go ponownie. W efekcie dwa razy stworzycie zamówienie, dwa razy wyślecie maila, dwa razy wywołacie jakiś GPT‑tool — i pobiegniecie sprzątać chaos.
Prawidłowy wzorzec brzmi: „przyjąć, zapisać, odłożyć”:
- Zweryfikować podpis i podstawowe inwarianty (typ zdarzenia, wymagane pola).
- Szybko zapisać zdarzenie do tabeli/kolejki (minimum operacji w bazie).
- Zwrócić 2xx.
- Przetwarzać zdarzenie w tle, osobnym workerem.
Uproszczony przykład „półpoprawnego” handlera bez osobnej kolejki, ale z szybką fiksacją:
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); // szybki zapis do bazy
// Tutaj można wysłać zadanie w tło przez setImmediate/kolejkę,
// ale w przykładzie edukacyjnym na razie ograniczymy się do zapisu:
// wywołujemy bez await, aby odpowiedź 200 poszła od razu.
processWebhookEventLater(event).catch(console.error);
return new Response("ok", { status: 200 });
}
Zwróćcie uwagę: nie robimy await processWebhookEventLater(...). Handler odkłada zadanie w tło i natychmiast zwraca 200, żeby nie uderzać w timeouty webhooka.
W prawdziwym produkcyjnym środowisku w tym miejscu często pojawi się kolejka (np. osobna tabela webhook_jobs lub zewnętrzny serwis), a workery będą spokojnie przerabiać zdarzenia, nie blokując przyjmowania nowych.
6. Idempotencja i deduplikacja: jak nie pobrać pieniędzy dwa razy
W przykładach edukacyjnych lubi się rysować idealne strzałki: jedno zdarzenie → jedno przetworzenie → szczęśliwe zamówienie. W prawdziwym życiu webhooki przychodzą jak puchate koty — paczkami i po kilka razy z rzędu.
Powody są proste: sieć jest zawodna, timeouty się zdarzają i wielu dostawców z założenia ponawia wysyłkę zdarzeń, dopóki nie otrzyma pewnego 2xx. Szczególnie ważne jest to przy płatnościach: lepiej ponownie wysłać payment_succeeded, niż zgubić go na zawsze.
Dlatego wasza logika biznesowa musi być idempotentna: ponowne przetworzenie tego samego zdarzenia nie powinno zmieniać rezultatu (albo przynajmniej nie powinno psuć systemu).
Typowy wzorzec:
- Zdarzenie ma trwały identyfikator, np. event.id albo checkout_session_id.
- Przechowujecie go w tabeli przetworzonych zdarzeń i zakładacie na to pole unikalny indeks.
- Przy każdym webhooku najpierw sprawdzacie: jeśli istnieje już wpis z takim id i statusem „przetworzone”, po prostu odpowiadacie 200 i nic nie robicie.
Mały przykład na pseudo‑ORM:
async function handlePaymentSucceeded(event: any) {
const existing = await db.webhookEvents.findUnique({
where: { providerId: event.id },
});
if (existing?.processedAt) {
return; // już wszystko zrobione
}
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" },
});
});
}
Tu ważny jest moment z transakcją: jednocześnie oznaczacie zdarzenie jako przetworzone i zmieniacie zamówienie. Jeśli wszystko padnie w połowie, transakcja się wycofa i przy następnej retransmisji webhooka spróbujecie ponownie, już bez podwójnych zapisów.
Dobrą praktyką jest także uczynić idempotentną samą operację, na przykład:
- „ustawić status zamówienia na PAID” zamiast „zwiększyć balans o +100”;
- „utworzyć wpis, jeśli go nie ma” zamiast „dodać kolejny wiersz”.
7. Walidacja danych webhooka i PII: podpis to nie jedyny filtr
Nawet jeśli webhook jest podpisany i przyszedł z prawdziwego serwisu, do jego danych warto podchodzić z taką samą ostrożnością jak do danych użytkownika czy argumentów narzędzi. W poprzednim wykładzie mówiliśmy już, że schemy i normalizacja to wasz firewall.
Schema dla zdarzenia może wyglądać tak (na poziomie TypeScript/Zod):
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(),
}),
}),
});
W handlerze walidujecie:
const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// dalej operujecie wyłącznie na parsed
Tak bronicie się przed niespodziankami w stylu „dostawca zmienił format”, „w środowisku testowym pole stało się nullable” itd. Jeśli coś jest nie tak — zapisujecie błąd w logach i zwracacie 400, dostawca potem ponowi lub wyśle alert.
Pamiętajcie też o PII: body webhooków często zawiera e‑mail, adres dostawy, czasem nawet fragmenty danych płatniczych (w ztokenizowanej formie). Maskowanie ich w logach i niewysyłanie surowych danych do zewnętrznych usług APM/logujących to absolutna praktyka obowiązkowa, o której mówiliśmy w temacie o sekretach i danych poufnych.
I na pewno nie trzeba bez filtra wysyłać pełnego JSON‑a webhooka z powrotem do ChatGPT jako ToolOutput — model nie powinien widzieć wszystkiego, co przysłał dostawca płatności, zwłaszcza jeśli nie jest to potrzebne dla UX.
8. GiftGenius w praktyce: webhook płatności dla ACP/Instant Checkout
Wróćmy do naszego GiftGenius. W module o commerce i ACP omawialiśmy już, jak agent tworzy sesję checkout i jak dalej przez Instant Checkout następuje pobranie płatności. Z punktu widzenia naszego backendu po tym pozostaje poczekać na webhook order.paid (albo checkout.session.completed w terminologii Stripe), aby:
- utrwalić status zamówienia;
- uruchomić łańcuch „wyślij e‑mail” / „przygotuj wysyłkę”;
- dać agentowi pewną odpowiedź „płatność przeszła”.
Przykład prostego handlera w 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") {
// Idempotentny handler z poprzedniej sekcji
await handlePaymentSucceeded(event);
}
return new Response("ok", { status: 200 });
}
Funkcja verifyCommerceSignature implementuje logikę podpisu HMAC analogiczną do tego, co omawialiśmy wyżej. W prawdziwym projekcie warto zrobić moduł dla każdego dostawcy (verifyStripeSignature, verifyACPCheckoutSignature), żeby nie mieszać schem.
Wewnątrz handlePaymentSucceeded:
- weryfikujecie obiekt według schemy (Zod);
- w transakcji oznaczacie zdarzenie jako przetworzone i aktualizujecie zamówienie;
- opcjonalnie odkładacie zadanie do kolejki dla „wolnych” działań: maile, analityka, dodatkowe wywołania API.
Takie podejście czyni łańcuch „ACP → webhook → GiftGenius” odpornym na powtórne zdarzenia, chwilowe awarie i dziwne dane.
9. Gdzie webhooki stykają się z MCP, ChatGPT i narzędziami
Na pierwszy rzut oka wydaje się, że webhooki żyją osobno od ChatGPT App: jakiś endpoint HTTP w backendzie i tyle. W rzeczywistości to ważna część wspólnej architektury.
Zazwyczaj powiązanie wygląda tak:
- Narzędzie MCP create_checkout jest wywoływane przez model w ChatGPT.
- Serwer MCP zwraca się do dostawcy płatności, tworzy sesję checkout i w ToolOutput zwraca informacje o zamówieniu oraz status „oczekiwanie na płatność”.
- Użytkownik kończy płatność w UI (Instant Checkout robi to bezpośrednio w ChatGPT).
- Dostawca płatności wysyła webhook do waszego backendu.
- Backend przez bazę zmienia status zamówienia; przy następnym wywołaniu narzędzi albo follow‑upie od modelu można już uczciwie powiedzieć: „Zamówienie opłacone, oto szczegóły”.
Czasami backend może zainicjować follow‑up pośrednio — np. przez widżet albo integrację Realtime, która na sygnał z serwera sama wywołuje sendFollowUpMessage. Ale nawet jeśli tego nie ma, fakt opłacenia jest u was i przy następnym wywołaniu narzędzia backend odczyta nowy status z bazy i zwróci modelowi zaktualizowane dane do odpowiedzi.
Ważne, że webhook to punkt wejścia, który żyje na tym samym poziomie co serwer MCP i korzysta z tych samych usług (baza, kolejki, sekrety). Logika bezpieczeństwa jest w zasadzie taka sama: minimalne uprawnienia, walidowane dane wejściowe, ostrożne logowanie.
10. Typowe błędy przy pracy z webhookami i zewnętrznymi integracjami
Błąd nr 1: brak weryfikacji podpisu webhooka.
Czasem deweloperzy ograniczają się do „tajnego” URL‑a lub prostego Bearer my-secret w nagłówku. Jeśli przy tym sekret gdzieś wycieknie, każdy może wam wysyłać webhooki, tworzyć zamówienia, zmieniać statusy płatności i robić, co chce. Prawidłowe podejście — kryptograficzny podpis body (HMAC) i sprawdzanie timestampu. To czyni podrobienie istotnie trudniejszym niż „odgadnąć URL”.
Błąd nr 2: ciężkie przetwarzanie wewnątrz żądania webhooka.
Pisanie w handlerze webhooka „utwórz zamówienie, uderz w dwa zewnętrzne API, wygeneruj PDF, zawołaj model GPT, wyślij 5 maili” to pewna droga do timeoutów i retrajów. W rezultacie sami wygenerujecie duplikaty, które potem trzeba będzie odkręcać. Znacznie pewniej jest szybko potwierdzić przyjęcie zdarzenia (2xx), zapisać je do bazy lub kolejki i przetwarzać w tle.
Błąd nr 3: nieidempotentna logika biznesowa.
Często można zobaczyć kod typu „przy każdym payment_succeeded zwiększ balans o kwotę”. Jeśli webhook przyjdzie dwa razy, balans wzrośnie dwukrotnie. Inna wersja — dwukrotne utworzenie tego samego zamówienia lub dwukrotne wysłanie maila. Idempotencję osiąga się przez trwały identyfikator zdarzenia, tabelę przetworzonych zdarzeń, transakcje i operacje typu „ustaw status” zamiast „dodaj jeszcze”.
Błąd nr 4: brak schem i walidacji danych webhooka.
Nawet podpisany webhook może nie być tym, czego się spodziewaliście: dostawca zmienił format, kopiujecie JSON z dokumentacji, a w środowisku testowym pole nazywa się inaczej albo po prostu pomyliliście typy. Jeśli taki JSON przetwarzać bez schem i weryfikacji, błędy po cichu będą psuć zamówienia albo powodować wyjątki w środku łańcucha. Użycie Zod/JSON Schema na wejściu upraszcza diagnostykę i pozwala jasno odrzucać nieprawidłowe zdarzenia.
Błąd nr 5: logowanie surowych body webhooków zawierających PII.
W ferworze debugowania łatwo wstawić console.log(rawBody) i o tym zapomnieć. W produkcji zamienia się to w logi najeżone adresami e‑mail, adresami dostawy i innymi danymi osobowymi, które jadą do zewnętrznych usług logujących. Z punktu widzenia prywatności i regulacji (historie typu GDPR) to strzał w stopę. Lepiej od razu wdrożyć PII‑scrub — maskować pola wrażliwe i logować tylko to, co naprawdę potrzebne do diagnostyki.
Błąd nr 6: mieszanie testowych i produkcyjnych webhooków.
Typowa sytuacja — ten sam endpoint przyjmuje zdarzenia i ze środowiska testowego, i z produkcyjnego dostawcy. W efekcie testowa płatność niespodziewanie zmienia status prawdziwego zamówienia albo odwrotnie. Pewniej jest rozdzielić URL‑e (np. /webhooks/commerce/test i /webhooks/commerce/live) albo przynajmniej trzymać w konfiguracji „tryb” i sprawdzać go na wejściu.
Błąd nr 7: pełna zależność scenariusza ChatGPT od synchronicznego webhooka.
Czasem kusi, żeby po wywołaniu narzędzia i utworzeniu sesji checkout model od razu znał wynik płatności. Ale webhooki z definicji są asynchroniczne i płatność może trwać. Budowanie scenariusza tak, jakby wszystko miało zajść natychmiast, to zły pomysł. Lepiej projektować dialogi i narzędzia tak, by poprawnie żyły z odłożonymi zdarzeniami: zapisywać stan zamówienia, pozwalać użytkownikowi wrócić do czatu i później otrzymać aktualną informację.
GO TO FULL VERSION