JavaRush /Курсы /ChatGPT Apps /Вебхуки и внешние интеграции: подпись, таймауты, идемпоте...

Вебхуки и внешние интеграции: подпись, таймауты, идемпотентность

ChatGPT Apps
15 уровень , 3 лекция
Открыта

1. Вебхуки в ChatGPT App: кто вообще к кому стучится

В классическом HTTP‑мире всё просто: вы — клиент, вы делаете POST /api/..., сервер отвечает, мир счастлив. С вебхуками наоборот: внешний сервис сам инициирует HTTP‑запрос к вашему backend’у, когда снаружи что‑то произошло.

В экосистеме ChatGPT Apps это всплывает в нескольких типичных сценариях. Например, GiftGenius после создания чекаута через ACP/Instant Checkout получает от платёжного провайдера уведомление payment_succeeded по вебхуку. Или фоновый сервис генерации превью‑картинок для подарков шлёт вам image_ready, когда рендер окончен. В таких случаях ChatGPT и ваш MCP‑сервер уже всё сделали, мяч на стороне третьего сервиса, и он сообщает вам результат через webhook.

Ключевая особенность: инициатива вне вашей системы. Запрос может прийти в любой момент и сколько угодно раз. Поэтому над обработчиком вебхука надо думать как над потенциально самой уязвимой точкой — туда стучится весь интернет.

Небольшая табличка для контраста:

Тип вызова Кто начинает Пример в 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/ с UI и 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. Тяжёлую работу лучше вынести либо в отдельный воркер, либо хотя бы в async‑функцию после логирования события. Это напрямую связано с таймаутами и повторными отправками, о которых поговорим чуть позже.

4. Подпись вебхуков: как отличить «Stripe» от «чувака с curl»

Вспомним TODO из скелета обработчика вебхука — «валидация подписи». Давайте разберёмся, как именно отличить реальный Stripe от «чувака с curl».

Самая большая наивность — думать, что если URL сложный (/api/webhooks/stripe/super-secret-abc123), то никто его не найдёт. URL‑секреты — это по сути security through obscurity: попытка спрятаться за сложным URL, которая даёт очень слабую защиту. Правильная линия обороны — криптографическая подпись.

Практически все серьёзные провайдеры (Stripe, ACP, многие CRM) рассчитывают HMAC‑подпись по телу запроса и времени, а затем кладут результат в заголовок. Вы, как получатель, делаете то же самое и сравниваете. Если хоть чуть‑чуть не совпало — отбрасываете запрос как подделку.

Общий рецепт:

  1. У вас есть секрет вебхука, который вы получили в кабинете провайдера и положили в секреты окружения (например, STRIPE_WEBHOOK_SECRET в Vercel env).
  2. Провайдер при отправке запроса считает HMAC по timestamp + '.' + rawBody.
  3. В заголовок, например Stripe-Signature, пишет timestamp и одну или несколько подписи.
  4. Вы в обработчике берёте 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 — и побежите чинить хаос.

Правильный паттерн звучит как «принял, записал, отложил»:

  1. Проверить подпись и базовые инварианты (тип события, обязательные поля).
  2. Быстро записать событие в таблицу/очередь (минимум БД‑операций).
  3. Вернуть 2xx.
  4. Обрабатывать событие в фоне, отдельным воркером.

Упрощённый пример «полуправильного» обработчика без отдельной очереди, но с быстрой фиксацией:

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, чем потерять его навсегда.

Поэтому ваша бизнес‑логика должна быть идемпотентной: повторная обработка того же события не должна менять результат (или хотя бы не должна ломать систему).

Типичный паттерн:

  1. У события есть устойчивый идентификатор, например event.id или checkout_session_id.
  2. Вы храните его в таблице обработанных событий и накладываете на это поле уникальный индекс.
  3. При каждом вебхуке сначала проверяете: если уже есть запись с таким 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: подпись — не единственный фильтр

Даже если вебхук подписан и пришёл с реального сервиса, относиться к его данным стоит с той же подозрительностью, что и к пользовательскому вводу или аргументам инструментов. В прошлой лекции мы уже обсуждали, что схемы и нормализация — это ваш firewall.

Схема для события, например, может выглядеть так (на уровне 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 тоже важно помнить: тела вебхуков часто содержат email, адрес доставки, иногда даже куски платёжных данных (в токенизированном виде). Маскировать их в логах и не отправлять сырыми в сторонние APM/лог‑сервисы — обязательная практика, о которой мы говорили в теме про секреты и конфиденциальные данные.

И уж точно не нужно без фильтра слать полный JSON вебхука обратно в ChatGPT в качестве ToolOutput — модель не должна видеть вообще всё, что прислал платёжный провайдер, особенно если это не нужно для UX.

8. GiftGenius на практике: вебхук оплаты для ACP/Instant Checkout

Вернёмся к нашему GiftGenius. В модуле про коммерцию и ACP мы уже разбирали, как агент создаёт checkout‑сессию и как дальше через Instant Checkout происходит списание оплаты. С точки зрения нашего backend’а после этого остаётся дождаться вебхука 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‑роут в backend’е, и всё. На самом деле это важная часть общей архитектуры.

Обычно связка выглядит так:

  1. Инструмент MCP create_checkout вызывается моделью в ChatGPT.
  2. MCP‑сервер обращается к платёжке, создаёт checkout‑сессию и возвращает в ToolOutput информацию о заказе и статусе «ожидание оплаты».
  3. Пользователь завершает оплату в UI (Instant Checkout это делает прямо в ChatGPT).
  4. Платёжка шлёт вебхук вашему backend’у.
  5. Backend через БД меняет статус заказа; при следующем вызове инструментов или follow‑up от модели уже можно честно сказать: «Заказ оплачен, вот детали».

Иногда backend может инициировать follow‑up опосредованно — например, через виджет или Realtime‑интеграцию, которая по сигналу с сервера сама вызывает sendFollowUpMessage. Но даже если этого нет, факт оплаты хранится у вас, и при следующем вызове инструмента backend прочитает новый статус из БД и вернёт модели обновлённые данные для ответа.

Важно, что вебхук — это входная точка, которая живёт на одном уровне с 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) и забыть его. В продакшене это превращается в логи, набитые email‑ами, адресами и другой PII, которые уезжают в сторонние лог‑сервисы. С точки зрения приватности и регуляций (GDPR‑подобные истории) это прямой выстрел в ногу. Лучше сразу внедрить PII‑scrub — маскировать чувствительные поля и логировать только то, что действительно нужно для диагностики.

Ошибка №6: смешивание тестовых и боевых вебхуков.
Типичная ситуация — один и тот же эндпойнт принимает события и из тестового, и из боевого окружения провайдера. В итоге тестовый платёж внезапно меняет статус реального заказа или наоборот. Надёжнее разделять URL (например, /webhooks/commerce/test и /webhooks/commerce/live) или хотя бы хранить в конфиге «режим» и проверять его на входе.

Ошибка №7: полная зависимость ChatGPT‑сценария от синхронного вебхука.
Иногда хочется, чтобы после вызова инструмента и создания checkout‑сессии модель тут же знала результат оплаты. Но вебхуки по определению асинхронны, и оплата может занимать время. Строить сценарий так, будто всё произойдёт мгновенно, — плохая идея. Лучше проектировать диалоги и инструменты так, чтобы они корректно жили с отложенными событиями: сохранять состояние заказа, позволять пользователю вернуться к чату и получить актуальную информацию позже.

1
Задача
ChatGPT Apps, 15 уровень, 3 лекция
Недоступна
Минимальный signed-webhook (HMAC + timestamp)
Минимальный signed-webhook (HMAC + timestamp)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ