Как надёжно обрабатывать вебхуки Stripe в serverless
Проверяйте stripe-signature по сырому телу, быстро отвечайте 2xx, дедуплицируйте по event id и выполняйте фулфилмент асинхронно — четыре шага для надёжных вебхуков Stripe в serverless.
Вебхук Stripe выглядит как самый простой эндпоинт, который вы когда-либо напишете. Stripe присылает POST с JSON, вы возвращаете 200, готово. А потом с клиента списывают деньги дважды, или оплаченный заказ так и не уходит в доставку, и выясняется, что вся надёжность платёжной интеграции держится на четырёх шагах, которые вы не сделали.
Эти четыре шага: проверить подпись по сырому телу, быстро ответить, дедуплицировать по event id и выполнять сам фулфилмент асинхронно. Сделаете правильно — повторные доставки, ретраи и попытки replay превращаются в no-op. Сделаете неправильно — каждый из них станет тикетом в поддержку или chargeback.
Это прагматичная версия ответа на вопрос «как надёжно обрабатывать вебхуки Stripe в serverless»: конкретные режимы отказа, готовый обработчик вебхуков Stripe, который можно скопировать, и честный список того, что платформа гарантирует, а что нет. Всё здесь — именно про serverless-вебхуки Stripe, где короткие таймауты и доставка at-least-once делают дисциплину обязательной.
Три способа сломать обработчик вебхуков Stripe
Почти любая сломанная интеграция со Stripe — это один из трёх режимов отказа, и каждый даёт свой отдельный, дорогой симптом.
1. Пропущенная или сломанная проверка. Вы парсите тело до проверки или пропускаете проверку вовсе. Теперь любой, кто может достучаться до вашего URL, может отправить поддельный payment_intent.succeeded и бесплатно запустить фулфилмент. Более коварный вариант хуже: вы проверяете подпись, но по повторно сериализованному JSON, поэтому валидные события не проходят проверку, а реальные платежи молча остаются без фулфилмента.
2. Медленный ответ. Обработчик списывает деньги, выполняет фулфилмент и шлёт письмо прямо внутри запроса, а 200 возвращает через восемь секунд. Окно доставки у Stripe короткое; он перестаёт ждать и делает ретрай. Теперь одно и то же событие обрабатывается дважды, и если на первом шаге не было идемпотентности, обе копии отработают.
3. Дублирующая доставка. Stripe гарантирует доставку at-least-once, а не exactly-once. Ретраи, сетевые сбои и повторная отправка на стороне Stripe означают, что один и тот же event id может прийти два и более раз — иногда с интервалом в секунды, иногда в часы. Без ключа идемпотентности это два заказа, два письма, две строки в ledger.
Лечение у всех трёх — один и тот же костяк из четырёх шагов. Остаток статьи — про этот костяк.
Проверяйте stripe-signature по сырому телу
Stripe подписывает каждый вебхук по HMAC-SHA256 и кладёт результат в заголовок stripe-signature. Это не голый хеш — это список через запятую вида t=1699999999,v1=abc123..., где t — Unix-таймстамп, на момент которого Stripe сформировал подпись, а v1 — сама подпись. Подписывается не распарсенный JSON-объект, а буквальная строка ${t}.${rawBody} — таймстамп, точка и точные байты тела запроса.
Именно эта последняя деталь подводит всех, кто пытается вручную проверить подпись вебхука Stripe. HMAC считается по сырым байтам. Если вы сделаете JSON.parse тела и заново его сериализуете, порядок ключей и пробелы могут измениться, байты перестанут совпадать, и проверка провалится на совершенно валидных событиях. Правило абсолютно: читайте event.body как строку и проверяйте до парсинга.
В Inquir Compute на горячем пути это не нужно писать руками. Задайте на маршруте шлюза webhookMode: 'stripe', а в webhookSecret — ваш подписывающий секрет whsec_.... Шлюз проверит HMAC по сырому телу ещё до того, как будет вызвана ваша функция, и вернёт 403 { error: { code: 'BAD_SIGNATURE' } } при любом несовпадении. Ваш код выполняется только на уже проверенных запросах, а тело по-прежнему приходит нераспарсенной строкой, так что ничего ниже по потоку не ломается.
Режим stripe понимает и таймстамп t=, поэтому поддерживает допуск на replay: отклонять подпись, чей таймстамп слишком стар. Именно это не даёт атакующему перехватить один валидный подписанный запрос и повторить его через несколько часов на вашем эндпоинте. SDK самого Stripe делает то же самое внутри constructEvent, с окном допуска по умолчанию.
Если вы не используете режим шлюза — например, хотите типизированный объект события из SDK Stripe или проверяете другого провайдера — проверяйте в обработчике. Контракт идентичен: на вход строка сырого тела, заголовок с подписью и секрет.
// Verify in-handler when you are NOT using the gateway's webhookMode: 'stripe'.
// The raw body must be the exact bytes Stripe signed — never JSON.parse first.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function handler(event) {
const rawBody = event.body ?? ''; // string, as delivered
const sig = event.headers['stripe-signature'] ?? '';
let evt;
try {
// constructEvent recomputes HMAC over `${t}.${rawBody}` and checks the
// t= timestamp for replay within Stripe's default tolerance.
evt = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return { statusCode: 400, body: `Webhook Error: ${err.message}` };
}
// ...then the same idempotency + fast ACK + async handoff as below.
return { statusCode: 200, body: JSON.stringify({ received: true }) };
}
Возможность шлюза готова к работе для двух провайдеров плюс кастомный вариант: github (схема x-hub-signature-256), stripe (см. выше) и custom (обычный HMAC в hex по телу, имя заголовка настраивается через signatureHeader). Slack сознательно не является встроенным режимом — его схема v0:timestamp:body отличается, — поэтому проверка Slack остаётся кодом в обработчике.
Отвечайте быстро: Stripe ждёт быстрый 2xx
Stripe ждёт быстрый 2xx. Ответьте примерно за 30 секунд, иначе Stripe считает доставку неудачной и делает ретраи — до 72 часов. Каждая секунда, которую обработчик тратит на списание, запись в медленную базу или вызов почтового API, — это секунда ближе к ретраю, который вам не нужен, и к дублю, который потом придётся разгребать.
Поэтому ответ и работу нужно развязать. Единственная задача обработчика вебхука: проверить (это делает шлюз), дедуплицировать, зафиксировать намерение, вернуть 200. Ничего медленного не происходит до того, как этот 200 ушёл.
Serverless делает эту дисциплину структурной, а не благим пожеланием. В Inquir таймаут функции по умолчанию — 5 секунд, поднимается до жёсткого максимума в 15 минут на функцию или шаг. Эти 5 секунд по умолчанию здесь — фича, а не ограничение: это постоянное напоминание, что обработчик вебхука — не место для фулфилмента. Если обработчику нужно больше нескольких сотен миллисекунд, работа должна жить в другом месте — об этом два последних раздела.
Делайте идемпотентно: дедуплицируйте по Stripe event id
Поскольку доставка идёт at-least-once, самая важная строка в обработчике вебхука Stripe — та, что записывает event id до любых действий с побочными эффектами. Это и есть идемпотентность вебхуков, и она не опциональна.
У каждого события Stripe есть стабильный id вида evt_.... Делайте upsert в таблицу с уникальным ограничением сразу, как только получили событие. Если вставка прошла — вы видите это событие впервые, продолжайте. Если произошёл конфликт — эту доставку вы уже обработали, верните 200 и остановитесь. Это весь механизм, и именно он делает ретраи и дублирующие доставки безвредными.
Важен порядок: делайте запись идемпотентности до фулфилмента, а не после. Если сначала выполнить фулфилмент, а записать потом, то падение в промежутке гарантирует дубль на следующем ретрае.
Inquir даёт второй, дополняющий слой бесплатно. Когда вы отдаёте работу durable-задаче через global.durable.startNew(name, id, payload), переданный id заодно работает как ключ идемпотентности, дедуплицирующий startNew в окне 24 часа. Передайте Stripe event id в качестве этого instance id — и дублирующая доставка в пределах 24 часов не поставит в очередь вторую задачу фулфилмента, даже если ваша собственная запись в базу каким-то образом проскользнула. Пояс и подтяжки, и оба слоя ничего не стоят.
Реалистичный serverless-обработчик вебхуков Stripe
Вот весь костяк в одной функции. Маршрут настроен с webhookMode: 'stripe', поэтому проверка уже произошла на шлюзе; обработчик парсит, дедуплицирует, отдаёт работу и отвечает.
// webhooks/stripe.mjs
//
// Gateway route config: { method: 'POST', webhookMode: 'stripe',
// webhookSecret: 'whsec_...' }. The gateway verifies the stripe-signature
// header against the RAW body and returns 403 { error: { code: 'BAD_SIGNATURE' } }
// before this handler runs. event.body still arrives as an unparsed string.
export async function handler(event) {
// Trust boundary already crossed at the gateway — safe to parse now.
const evt = JSON.parse(event.body ?? '{}');
// Webhook idempotency: dedupe on the Stripe event id. Delivery is
// at-least-once, so this write — before any side effect — is what makes
// a duplicate delivery a no-op.
const isNew = await db.webhookEvents.upsert({ id: evt.id, type: evt.type });
if (!isNew) {
return { statusCode: 200, body: JSON.stringify({ received: true, duplicate: true }) };
}
// Offload slow fulfillment to a durable, Postgres-backed job and return
// immediately. The event id is passed as the instance id, so it also acts
// as a 24h idempotency key on startNew.
if (evt.type === 'payment_intent.succeeded') {
await global.durable.startNew('stripe-fulfill', evt.id, {
intentId: evt.data.object.id,
amount: evt.data.object.amount,
});
}
// Fast 2xx so Stripe marks the delivery done and stops retrying.
return { statusCode: 200, body: JSON.stringify({ received: true }) };
}
Смотрите на форму, а не на число строк. Парсить — только после границы доверия на шлюзе. Дедупликация — по evt.id. Всё медленное — в durable-задачу, чей instance id и есть event id. Быстрый 2xx. Всё дорогое — сверка списания, фулфилмент, письмо с чеком — происходит в задаче, вне запроса.
Фулфилмент как durable-задача в фоне
global.durable.startNew('stripe-fulfill', evt.id, {...}) ставит в очередь durable-задачу в фоне, персистентную и хранящуюся в Postgres. Она переживает рестарты: как только строка записана, работа не теряется, даже если контейнер под ней переработается (recycle). И выполняется она вне HTTP-запроса, то есть полностью отвязана от окна доставки Stripe — задача может занять секунды или минуты, не создавая обратного давления ретраев на вебхук.
Для такой задачи доступны ретраи с экспоненциальным backoff, а задача, исчерпавшая попытки, уходит в dead-letter с записанной последней ошибкой, а не исчезает. Но будьте точны насчёт значения по умолчанию: обычная async-задача выполняется один раз, если вы не включили больше попыток; встроенный путь возобновления (resume) использует до пяти. Считайте ретраи тем, что вы включаете для конкретной задачи, а не гарантией, что любая задача бесконечно ретраится сама. Отдельно reaping по visibility timeout подбирает задачи, чей воркер умер на середине.
Поскольку каждая функция и задача работает в своём изолированном контейнере, ваш фулфилмент Stripe получает ту же наблюдаемость, что и обычный маршрут API: трассы выполнения показывают каждую доставку с телом (в редактированном виде), заголовками, таймингами и числом ретраев. Когда финансовый отдел спросит «мы вообще обработали evt_1abc?», это будет поиск по трассам, а не grep по лог-файлам.
Одно намеренное упущение: не пытайтесь моделировать весь фулфилмент как один длинный, спящий, с human-gate воркфлоу, который делает вид, что HTTP-запрос всё ещё открыт. Держите durable-единицу простой задачей — или пайплайном из коротких шагов, каждый в пределах 15-минутного лимита, — которую можно ретраить и о которой можно рассуждать. Цепочка коротких идемпотентных шагов лучше одной хитрой конструкции, которая считает, что вызывающая сторона всё ещё ждёт.
Тестируйте через Stripe CLI
Чтобы всё это прогнать, реальный трафик по картам не нужен. Stripe CLI перенаправляет реальные формы событий на любой эндпоинт, на который вы его нацелите.
stripe listen --forward-to <url>стримит живые события из вашего аккаунта на локальный или задеплоенный обработчик и печатает подписывающий секрет, который вы используете какSTRIPE_WEBHOOK_SECRET.stripe trigger payment_intent.succeededформирует реалистичное событие и отправляет его, так что можно наблюдать весь путь проверка → дедупликация → ответ → задача от начала до конца.
Два теста стоит написать явно. Первый: отправьте одно и то же событие дважды и проверьте, что второй вызов возвращает ваш 200 про дубль и ничего не ставит в очередь — это доказывает, что идемпотентность работает. Второй: отправьте POST с неверным или отсутствующим stripe-signature и проверьте, что от шлюза приходит 403 BAD_SIGNATURE — это доказывает, что проверка работает. Держите локально тот же контракт event.body в виде строки, который шлюз отдаёт в продакшене, чтобы тестировать ровно то, что отгружаете.
Чего это НЕ делает — прочитайте перед выкаткой
Честность — часть надёжности. Платформа не заметает эти вещи под ковёр, и ваша ментальная модель не должна.
- Нет exactly-once и нет гарантии порядка. Доставка идёт at-least-once, и события могут прийти не по порядку. Ваш ключ идемпотентности — не украшение, а механизм, который делает at-least-once безопасным. Не убирайте его на основании «шлюз ведь уже проверяет». Проверка и дедупликация решают разные задачи.
- Допуск на replay — это не идемпотентность. Проверка таймстампа отклоняет устаревшие подписи; она ничего не делает с валидным дублем, доставленным на пару секунд позже. Нужны и окно по таймстампу, и запись по event id.
- Slack — не встроенный
webhookMode. Встроены GitHub, Stripe и кастомный HMAC-режим. Схема Slackv0:timestamp:bodyотличается, поэтому Slack проверяйте кодом в обработчике. - Таймауты реальны. 5 секунд по умолчанию, 15 минут жёсткого максимума на функцию или шаг. Ничто не выполняется вечно; долгая работа — это цепочка задач или шагов, каждый в пределах лимита, а не один бесконечный удерживаемый запрос.
- Холодные старты уменьшены, но не устранены. Горячие пулы контейнеров их сокращают, но первый вызов или вызов после простоя всё ещё может быть холодным. Щедрое окно ретраев Stripe поглощает случайный холодный старт; просто не проектируйте так, будто задержка всегда нулевая.
- Шлюз проверяет, но не дедуплицирует и не выполняет фулфилмент.
webhookMode: 'stripe'— это только шаг подписи. Запись идемпотентности по event id и асинхронная передача работы — по-прежнему ваш код и ваша ответственность.
Вывод
Надёжные вебхуки Stripe — это не фреймворк, который ставится, а четыре шага по порядку: проверить по сырому телу, быстро ответить, дедуплицировать по event id, выполнить фулфилмент асинхронно. В Inquir Compute первый шаг — это настройка маршрута webhookMode: 'stripe', и неверная подпись становится 403 BAD_SIGNATURE ещё до запуска вашего кода. Быстрый 2xx и запись идемпотентности по event id — это несколько строк. Медленная часть превращается в durable-задачу в Postgres с ретраями и трассами. Свяжите эти четыре шага по порядку — и повторные доставки, ретраи и replay перестанут быть инцидентами и станут no-op.