1. Вебхуки в ChatGPT App: хто взагалі до кого «стукає»
У класичному HTTP‑світі все просто: ви — клієнт, ви робите POST /api/..., сервер відповідає — і все працює. З вебхуками навпаки: зовнішній сервіс сам ініціює HTTP‑запит до вашого бекенду щойно десь «зовні» стається певна подія.
В екосистемі ChatGPT Apps це трапляється в кількох типових сценаріях. Наприклад, GiftGenius після створення чекауту через ACP/Instant Checkout отримує від платіжного провайдера сповіщення payment_succeeded вебхуком. Або фоновий сервіс генерації превʼю‑картинок для подарунків надсилає вам image_ready, коли рендер завершено. У таких випадках ChatGPT і ваш MCP‑сервер уже зробили свою частину роботи: мʼяч на боці стороннього сервісу, і саме він повідомляє вам результат через вебхук.
Ключова особливість — ініціатива поза вашою системою. Запит може надійти будь‑коли й скільки завгодно разів. Тож про обробник вебхука варто думати як про потенційно найуразливішу точку: саме туди «стукає» весь інтернет.
Невелика таблиця для контрасту:
| Тип виклику | Хто ініціює | Приклад у GiftGenius |
|---|---|---|
| Звичайний API‑запит | Ви | MCP‑сервер викликає Stripe API |
| Вебхук | Зовнішній світ | Stripe надсилає payment_succeeded вам |
2. Проста схема: де тут ChatGPT, де MCP, де вебхук
Схематично шлях виглядає так:
sequenceDiagram
participant User as Користувач у ChatGPT
participant GPT as ChatGPT + модель
participant App as GiftGenius (MCP/App)
participant PSP as Платіжний провайдер (Stripe/ACP)
User->>GPT: "Хочу купити подарунок"
GPT->>App: callTool(create_checkout)
App->>PSP: POST /checkout_sessions
PSP-->>App: 200 OK + checkout_session_id
App-->>GPT: ToolOutput (checkout info)
PSP-->>App: POST /webhooks/payment_succeeded
App-->>PSP: 200 OK (подію прийнято)
App->>DB: позначити замовлення оплаченим
Перша частина — це звичайні вихідні запити, які ви вже вмієте робити. Вебхук — нижня частина схеми, де платіжний провайдер сам звертається до вас. Саме це нас сьогодні й цікавить.
3. Базовий обробник вебхука в Next.js (скелет)
Ми продовжуємо розвивати наш навчальний GiftGenius у Next.js 16. У шаблоні в нас є app/ з інтерфейсом і app/mcp/route.ts з MCP‑сервером.
Обробник вебхука логічно винести в окремий HTTP‑маршрут, наприклад: app/api/webhooks/commerce/route.ts.
Мінімальний каркас виглядає так:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // 1. Зчитуємо тіло як рядок
const headers = Object.fromEntries(req.headers); // 2. Беремо заголовки
// 3. TODO: валідація підпису (додамо трохи згодом)
// 4. TODO: парсинг JSON і обробка події
return new Response("ok", { status: 200 }); // 5. Швидко відповідаємо 2xx
}
Навіть у цьому короткому фрагменті заховано кілька важливих ідей.
По‑перше, ми читаємо тіло як текст, а не одразу await req.json(). Багато провайдерів підписують саме «сирий» байтовий потік тіла. І якщо ви розпарсите його (а тим паче переформатуєте) до перевірки підпису, підпис уже не зійдеться.
По‑друге, ми одразу закладаємо швидку відповідь 2xx. Важку роботу краще винести або в окремий фоновий обробник, або принаймні запустити асинхронно після того, як ви зафіксуєте подію в логах або БД. Це безпосередньо повʼязано з тайм‑аутами та повторними надсиланнями, про які поговоримо далі.
4. Підпис вебхуків: як відрізнити «Stripe» від «зловмисника з curl»
Згадаймо TODO зі скелета обробника вебхука — «валідація підпису». Розберімося, як на практиці відрізнити реальний Stripe від «зловмисника з curl».
Найбільша наївність — думати, що якщо URL складний (/api/webhooks/stripe/super-secret-abc123), то ніхто його не знайде. «Секретні URL» — це, по суті, «security through obscurity»: спроба сховатися за хитрим шляхом, яка дає дуже слабкий захист. Правильна лінія оборони — криптографічний підпис.
Практично всі серйозні провайдери (Stripe, ACP, багато CRM) обчислюють HMAC‑підпис за тілом запиту й часом, а потім кладуть результат у заголовок. Ви, як отримувач, робите те саме й порівнюєте. Якщо не збіглося бодай щось — відкидаєте запит як підробку.
Загальний рецепт:
- У вас є секрет вебхука, який ви отримали в кабінеті провайдера й поклали в секрети середовища (наприклад, STRIPE_WEBHOOK_SECRET у Vercel env).
- Провайдер під час надсилання запиту рахує HMAC за timestamp + '.' + rawBody.
- У заголовок, наприклад Stripe-Signature, записує timestamp і один або кілька підписів.
- Ви в обробнику берете timestamp, рахуєте свій HMAC за тим самим правилом і порівнюєте.
Міні‑приклад на TypeScript із використанням crypto:
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // обираємо алгоритм
.update(payload, "utf8") // сирий текст тіла
.digest("hex"); // hex-рядок
}
Приклад перевірки підпису і «свіжості» події:
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 });
}
Зверніть увагу на timingSafeEqual — це захист від атак за часом, коли зловмисник намагається вгадати підпис за тривалістю порівняння.
Після успішної перевірки підпису можна вже спокійно робити JSON.parse(rawBody) або await req.json(), знаючи, що запит прийшов від реального провайдера.
Додаткові рівні захисту на кшталт IP‑allowlist (дозволяти запити лише з адрес провайдера) й окремого домену для вебхуків не завадять. Але саме криптопідпис дає вам упевненість в автентичності.
5. Тайм‑аути, швидка відповідь і асинхронна обробка
Вебхуки «люблять» тих, хто відповідає швидко. Більшість платіжних і commerce‑платформ очікують, що ваш ендпойнт відповість 2xx за кілька секунд (часто до 10 секунд, інколи — ще швидше). Якщо ви «думаєте» надто довго, вони вважають виклик неуспішним і починають повторювати запити.
На практиці це виглядає так: ви перевірили підпис, сходили в БД, звернулися ще до трьох зовнішніх API, порахували звіт, згенерували PDF — і лише потім повернули 200 OK. Якщо щось із цього хоч трохи «зависне», провайдер вирішить, що вебхук «упав», і надішле його ще раз. У результаті ви двічі створите замовлення, двічі надішлете листа, двічі викличете якийсь GPT‑tool — і потім бігатимете, наводячи лад.
Правильний патерн звучить як «прийняв, записав, відклав»:
- Перевірити підпис і базові інваріанти (тип події, обовʼязкові поля).
- Швидко записати подію в таблицю або чергу (мінімум операцій із БД).
- Повернути 2xx.
- Обробляти подію у фоні, окремим воркером.
Спрощений приклад «напівправильного» обробника без окремої черги, але зі швидкою фіксацією:
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); // швидкий запис у БД
// Тут можна відправити завдання у фон через setImmediate/queue,
// але в навчальному прикладі поки обмежимось записом: викликаємо без await,
// щоб відповідь 200 пішла одразу.
processWebhookEventLater(event).catch(console.error);
return new Response("ok", { status: 200 });
}
Зверніть увагу: ми не робимо await processWebhookEventLater(...). Обробник ставить завдання у фон і відразу повертає 200, щоб не впиратися в тайм‑аути вебхука.
У реальному продакшені на цьому місці часто зʼявляється черга (наприклад, окрема таблиця webhook_jobs або зовнішній сервіс). А воркери акуратно розгрібають події, не блокуючи приймання нових.
6. Ідемпотентність і дедуплікація: як не списати гроші двічі
У навчальних прикладах люблять малювати ідеальні стрілочки: одна подія → одна обробка → щасливе замовлення. У реальному житті вебхуки приходять «пачками» — і по кілька разів поспіль.
Причини прості: мережа ненадійна, тайм‑аути трапляються, і багато провайдерів навмисно повторно надсилають події, доки не отримають упевнений 2xx. Особливо це важливо для платежів: краще повторно надіслати payment_succeeded, ніж втратити його назавжди.
Тому ваша бізнес‑логіка має бути ідемпотентною: повторна обробка тієї самої події не повинна змінювати результат (або принаймні не повинна ламати систему).
Типовий патерн:
- У події є стійкий ідентифікатор, наприклад event.id або checkout_session_id.
- Ви зберігаєте його в таблиці оброблених подій і накладаєте на це поле унікальний індекс.
- На кожен вебхук спочатку перевіряєте: якщо вже є запис із таким id і статусом «оброблено», просто відповідаєте 200 і нічого не робите.
Міні‑приклад на псевдо‑ORM:
async function handlePaymentSucceeded(event: any) {
const existing = await db.webhookEvents.findUnique({
where: { providerId: event.id },
});
if (existing?.processedAt) {
return; // уже все зроблено
}
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" },
});
});
}
Тут важливий момент із транзакцією: ви одночасно позначаєте подію як оброблену й змінюєте замовлення. Якщо все впаде посередині, транзакцію буде відкочено, і під час наступного повторного надсилання вебхука ви спробуєте знову — уже без подвійного запису.
Хороша практика — робити ідемпотентною й саму операцію. Наприклад:
- «встановити статус замовлення в PAID» замість «збільшити баланс на +100»;
- «створити запис, якщо його немає» замість «додати ще один рядок».
7. Валідація даних вебхука і PII: підпис — не єдиний фільтр
Навіть якщо вебхук підписано і він прийшов від реального сервісу, ставитися до його даних варто з тією ж підозрою, що й до користувацького введення або аргументів інструментів. У попередній лекції ми вже обговорювали: схеми й нормалізація — це ваш захисний фільтр.
Схема для події, наприклад, може виглядати так (на рівні 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(),
}),
}),
});
В обробнику ви валідуєте:
const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// далі працюєте лише з parsed
Так ви захищаєтеся від сюрпризів на кшталт «провайдер змінив формат», «у тестовому середовищі поле стало nullable» тощо. Якщо щось не так — фіксуєте помилку в логах і повертаєте 400, а провайдер потім повторить спробу або надішле сповіщення.
Про PII теж важливо памʼятати: тіла вебхуків часто містять електронні адреси, адресу доставки, інколи навіть фрагменти платіжних даних (у токенізованому вигляді). Маскувати їх у логах і не відправляти «сирими» в сторонні APM/лог‑сервіси — обовʼязкова практика, про яку ми говорили в темі про секрети й конфіденційні дані.
І вже точно не варто без фільтра надсилати повний JSON вебхука назад у ChatGPT як ToolOutput — модель не повинна бачити геть усе, що надіслав платіжний провайдер. Особливо якщо цього не потрібно для UX.
8. GiftGenius на практиці: вебхук оплати для ACP/Instant Checkout
Повернімося до нашого GiftGenius. У модулі про комерцію й ACP ми вже розбирали, як агент створює checkout‑сесію і як далі через Instant Checkout відбувається списання оплати. З погляду нашого бекенду після цього лишається дочекатися вебхука order.paid (або checkout.session.completed у термінах Stripe), щоб:
- зафіксувати статус замовлення;
- запустити ланцюжок «надіслати лист» / «підготувати відвантаження»;
- дати агентові впевнену відповідь «оплату проведено».
Приклад простого обробника в 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") {
// Ідемпотентний обробник з попереднього розділу
await handlePaymentSucceeded(event);
}
return new Response("ok", { status: 200 });
}
Функція verifyCommerceSignature реалізує логіку HMAC‑підпису, аналогічну до тієї, що ми розглядали вище. У реальному проєкті має сенс зробити модуль для кожного провайдера (verifyStripeSignature, verifyACPCheckoutSignature), щоб не змішувати схеми.
Усередині handlePaymentSucceeded ви:
- валідуєте обʼєкт за схемою (Zod);
- у транзакції позначаєте подію як оброблену й оновлюєте замовлення;
- опційно ставите завдання в чергу для «повільних» дій: листи, аналітика, додаткові API‑запити.
Такий підхід робить ланцюжок «ACP → вебхук → GiftGenius» стійким до повторних подій, тимчасових збоїв і неочікуваних даних.
9. Де вебхуки стикуються з MCP, ChatGPT і інструментами
На перший погляд може здатися, що вебхуки живуть окремо від ChatGPT App: якийсь HTTP‑маршрут у бекенді — і все. Насправді це важлива частина загальної архітектури.
Зазвичай звʼязка виглядає так:
- Інструмент MCP create_checkout викликається моделлю в ChatGPT.
- MCP‑сервер звертається до платіжного провайдера, створює checkout‑сесію й повертає в ToolOutput інформацію про замовлення та статус «очікування оплати».
- Користувач завершує оплату в інтерфейсі (Instant Checkout робить це просто в ChatGPT).
- Платіжний провайдер надсилає вебхук вашому бекенду.
- Бекенд через БД змінює статус замовлення; під час наступного виклику інструментів або follow‑up від моделі вже можна чесно сказати: «Замовлення оплачено, ось деталі».
Іноді бекенд може ініціювати follow‑up опосередковано — наприклад, через віджет або Realtime‑інтеграцію, яка за сигналом із сервера сама викликає sendFollowUpMessage. Але навіть якщо цього немає, факт оплати зберігається у вас. Тож під час наступного виклику інструмента бекенд прочитає новий статус із БД та поверне моделі оновлені дані для відповіді.
Важливо, що вебхук — це вхідна точка, яка живе на одному рівні з MCP‑сервером і використовує ті самі сервіси (БД, черги, секрети). Логіка безпеки, по суті, та сама: мінімальні права, валідовані вхідні дані, акуратне логування.
10. Типові помилки під час роботи з вебхуками і зовнішніми інтеграціями
Помилка № 1: відсутність перевірки підпису вебхука.
Інколи розробники обмежуються «секретним» URL або простим Bearer my-secret у заголовку. Якщо при цьому секрет десь витече, будь‑хто зможе «надсилати» вам вебхуки — створювати замовлення, змінювати статуси платежів і взагалі робити що завгодно. Правильний підхід — криптографічний підпис тіла (HMAC) і перевірка timestamp. Це робить підробку суттєво складнішою, ніж «підібрати URL».
Помилка № 2: важка обробка всередині запиту вебхука.
Писати в обробнику вебхука «створити замовлення, сходити в два зовнішні API, згенерувати PDF, покликати GPT‑модель, надіслати 5 листів» — вірний спосіб упіймати тайм‑аути й повторні спроби. У результаті ви самі породите дублікати, які потім доведеться «розмотувати». Значно надійніше швидко підтвердити приймання події (2xx), записати її в БД або чергу й обробляти у фоні.
Помилка № 3: неідемпотентна бізнес‑логіка.
Часто можна побачити код: «на кожен payment_succeeded збільшити баланс на суму». Якщо вебхук прийде двічі, баланс стане удвічі більшим. Інший варіант — двічі створити одне й те саме замовлення або двічі надіслати користувачеві листа. Ідемпотентність досягається через стійкий ідентифікатор події, таблицю оброблених подій, транзакції та операції на кшталт «встановити статус» замість «додати ще».
Помилка № 4: відсутність схем і валідації даних вебхука.
Навіть підписаний вебхук може бути не тим, що ви очікували: провайдер змінив формат, ви копіюєте JSON із документації, а в тестовому середовищі поле називається інакше, або ви просто помилилися в типах. Якщо обробляти такий JSON без схем і перевірок, помилки тихо ламатимуть замовлення або викликатимуть винятки посеред ланцюжка. Використання Zod/JSON Schema на вході спрощує діагностику й дозволяє чітко відкидати некоректні події.
Помилка № 5: логування сирих тіл вебхуків із PII.
У запалі відладки легко поставити console.log(rawBody) і забути про це. У продакшені це перетворюється на логи, набиті електронними адресами, адресами доставки та іншою PII, які їдуть у сторонні лог‑сервіси. З погляду приватності та регуляцій (історії на кшталт GDPR) це прямий постріл у ногу. Краще відразу впровадити PII‑scrub: маскувати чутливі поля й логувати лише те, що справді потрібно для діагностики.
Помилка № 6: змішування тестових і бойових вебхуків.
Типова ситуація — одна й та сама кінцева точка приймає події і з тестового, і з бойового середовища провайдера. У підсумку тестовий платіж раптово змінює статус реального замовлення — або навпаки. Надійніше розділяти URL (наприклад, /webhooks/commerce/test і /webhooks/commerce/live) або принаймні зберігати в конфігурації «режим» і перевіряти його на вході.
Помилка № 7: повна залежність сценарію ChatGPT від синхронного вебхука.
Іноді хочеться, щоб після виклику інструмента й створення checkout‑сесії модель одразу знала результат оплати. Але вебхуки, за означенням, асинхронні, і оплата може займати час. Будувати сценарій так, ніби все станеться миттєво, — погана ідея. Краще проєктувати діалоги та інструменти так, щоб вони коректно працювали з відкладеними подіями: зберігати стан замовлення, дозволяти користувачеві повернутися до чату й отримати актуальну інформацію пізніше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ