Обработка вебхуков 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 бьют по самой медленной операции.
Как помогает Inquir
Паттерн вебхука 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
Проверить HMAC по сырому телу
Читайте event.body как строку. Вызовите stripe.webhooks.constructEvent с сырым телом, заголовком stripe-signature и секретом вебхука.
Записать ключ идемпотентности
Сделайте upsert evt.id в таблицу событий. Если ключ уже есть — сразу верните 200 и не запускайте фулфилмент.
Запустить оркестрацию и вернуть 200
Вызовите global.durable.startNew с типом и данными события, затем верните 200 в 30-секундном окне Stripe. Ретраи фулфилмента выполняются в пайплайне, а не в обработчике вебхука.
Пример кода
Обработчик вебхука Stripe
Полный паттерн: сырое тело, проверка подписи, ключ идемпотентности, быстрый ACK, асинхронный пайплайн фулфилмента.
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 отправит тестовое событие в задеплоенную функцию.