Сценарий · Inquir Compute

Обработка вебхуков Stripe: HMAC, идемпотентность и async-пайплайн

Stripe ретраит вебхуки до 72 часов при сбое. Обрабатывайте их правильно: проверяйте HMAC-подпись по сырому телу, записывайте ключ идемпотентности до любой мутации, отвечайте 200 за 30 секунд и продолжайте фулфилмент в фоновом пайплайне.

Обновлено: 2026-06-28

Суть ответа

Обработка вебхуков Stripe: HMAC, идемпотентность и async-пайплайн. Функция получает raw body в event.body, считает HMAC с секретом Stripe из переменных окружения, проверяет timing-safe, возвращает 200, вызывает global.durable.startNew(). Оркестрация делает бизнес-логику.

Когда подходит и когда нет

  • Обработка занимает больше 2–3 секунд
  • Нужна идемпотентность при повторных доставках

На что обратить внимание

  • Без сравнения HMAC с постоянным временем (timing-safe) возможны атаки по времени ответа и обход проверки подписи.
  • Без ключа идемпотентности по event ID повторная доставка создаёт дубль записи в базе.

Что ломается в простых Stripe webhook-обработчиках

  • Парсинг body до проверки HMAC — подпись не сходится даже на валидных событиях
  • Синхронный фулфилмент: Stripe ретраит после 30 с → двойные списания
  • Нет ключа идемпотентности: повторная доставка создаёт второй заказ

Stripe подписывает вебхуки HMAC-SHA256 по сырому телу. Если JSON.parse до проверки — нормализация пробелов ломает подпись. Если фулфилмент синхронный и дольше 30 с, Stripe ретраит — без идемпотентности это дубли побочных эффектов.

Где теряются безопасность и идемпотентность

Без сравнения HMAC с постоянным временем (timing-safe) возможны атаки по времени ответа и обход проверки подписи.

Без ключа идемпотентности по event ID повторная доставка создаёт дубль записи в базе.

Паттерн Stripe-вебхука на Inquir

Функция получает raw body в event.body, считает HMAC с секретом Stripe из переменных окружения, проверяет timing-safe, возвращает 200, вызывает global.durable.startNew(). Оркестрация делает бизнес-логику.

Секрет endpoint хранится в переменных окружения — не в коде. Ротация без редеплоя.

Ключевые части Stripe webhook-обработчика

Raw body для HMAC

Не парсить body до проверки подписи. event.body — строка; HMAC через stripe.webhooks.constructEvent() или timing-safe сравнение.

Ключ идемпотентности

Event ID из payload: upsert до любой мутации. Повторная доставка — 200 без второго фулфилмента.

Быстрый ACK

Вернуть 200 до фулфилмента. Stripe ждёт ответ в пределах 30 секунд.

Пайплайн для фулфилмента

global.durable.startNew() до return { statusCode: 200 } — email, инвентарь и ретраи вне HTTP-окна.

Как обработать Stripe webhook

1

Проверить подпись

stripe.webhooks.constructEvent(event.body, sig, secret) — выбрасывает при несовпадении.

2

Записать ключ идемпотентности

Upsert evt.id в таблицу событий. Если ключ уже есть — сразу вернуть 200 без фулфилмента.

3

Запустить оркестрацию и вернуть 200

global.durable.startNew('fulfill', undefined, { eventId, type, data }), затем return { statusCode: 200 } в пределах 30 с Stripe.

Stripe webhook handler

Complete pattern: raw body, signature verify, idempotency key, fast ACK, async fulfillment pipeline.

webhooks/stripe.mjs
export async function handler(event) {
  const rawBody = event.body ?? '';
  const sig = event.headers['stripe-signature'] ?? '';
  let evt;
  try {
    evt = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return { statusCode: 400, body: `Webhook Error: ${err.message}` };
  }
  const isNew = await db.webhookEvents.upsert({ id: evt.id, type: evt.type });
  if (!isNew) return { statusCode: 200, body: 'duplicate' };
  if (evt.type === 'payment_intent.succeeded') {
    await global.durable.startNew('stripe-fulfill', undefined, { intentId: evt.data.object.id, amount: evt.data.object.amount });
  }
  return { statusCode: 200, body: JSON.stringify({ received: true }) };
}

Когда нужен async пайплайн для Stripe

Когда это уместно

  • Обработка занимает больше 2–3 секунд
  • Нужна идемпотентность при повторных доставках

Когда лучше выбрать другое

  • Лёгкий вебхук с мгновенным ответом, без side-effects кроме записи в БД

Вопросы и ответы

Почему нужен raw body?

Stripe считает подпись по raw-байтам тела. После JSON.parse/stringify байты могут измениться — подпись не совпадёт.

Как хранить webhook secret?

Переменная окружения STRIPE_WEBHOOK_SECRET в настройках функции. Не коммитить в git.