Сценарий · Stripe

Обработка вебхуков Stripe на serverless с ретраями и логами

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

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

Суть ответа

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

Когда подходит

  • События payment_intent.succeeded, charge.refunded, subscription.updated, invoice.paid
  • Любое событие Stripe, которое запускает фулфилмент, письма или изменение остатков

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

  • Смешение пользовательской авторизации, платёжного вебхука и фулфилмента в одном эндпоинте делает логику заголовка stripe-signature хрупкой и связывает измерения масштабирования, которые лучше держать раздельно.
  • Фулфилмент внутри запроса (списание → выполнение → письмо за один запрос) означает, что повторные доставки Stripe бьют по самой медленной операции.

Три типичных сбоя вебхуков Stripe

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

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

Почему монолитные API-обработчики не справляются со Stripe

Смешение пользовательской авторизации, платёжного вебхука и фулфилмента в одном эндпоинте делает логику заголовка stripe-signature хрупкой и связывает измерения масштабирования, которые лучше держать раздельно.

Фулфилмент внутри запроса (списание → выполнение → письмо за один запрос) означает, что повторные доставки Stripe бьют по самой медленной операции.

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

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

Пайплайн фулфилмента работает вне HTTP-окна — таймаут Stripe больше не давит. Ретраи действуют на каждый шаг; в истории выполнений видно каждое обработанное событие.

Чеклист реализации вебхука Stripe

Сырое тело для HMAC

event.body приходит строкой — никакого JSON.parse до stripe.webhooks.constructEvent.

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

Делайте upsert evt.id до любой мутации. Если upsert нашёл существующую запись — пропустите и верните 200.

Быстрый ACK

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

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

После ACK запускайте асинхронный пайплайн для выполнения заказа, писем и обновления остатков.

Поток обработки вебхука Stripe

1

Проверить HMAC по сырому телу

Читайте event.body как строку. Вызовите stripe.webhooks.constructEvent с сырым телом, заголовком stripe-signature и секретом вебхука.

2

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

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

3

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

Вызовите global.durable.startNew с типом и данными события, затем верните 200 в 30-секундном окне Stripe. Ретраи фулфилмента выполняются в пайплайне, а не в обработчике вебхука.

Обработчик вебхука Stripe

Полный паттерн: сырое тело, проверка подписи, ключ идемпотентности, быстрый ACK, асинхронный пайплайн фулфилмента.

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 }) };
}

Используйте этот паттерн для

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

  • События payment_intent.succeeded, charge.refunded, subscription.updated, invoice.paid
  • Любое событие Stripe, которое запускает фулфилмент, письма или изменение остатков

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

  • События Stripe «только для чтения», где повторная доставка не имеет побочных эффектов

Частые вопросы

Какие события Stripe обрабатывать?

payment_intent.succeeded — для выполнения покупки; invoice.payment_failed — для напоминаний об оплате подписки; customer.subscription.deleted — для отзыва доступа.

Как тестировать без реального трафика Stripe?

Для локальной проверки используйте stripe listen --forward-to <local URL>, а stripe trigger payment_intent.succeeded отправит тестовое событие в задеплоенную функцию.