Обработка Stripe webhooks: HMAC, идемпотентность и async-пайплайн
Паттерн: raw body → HMAC SHA-256 → проверить ключ идемпотентности → ответить 200 → запустить пайплайн с бизнес-логикой. Stripe не делает повторную попытку, дублей нет.
Last updated: 2026-04-20
Answer first
Direct answer
Обработка Stripe webhooks: HMAC, идемпотентность и async-пайплайн. Функция получает raw body в `event.body`, считает HMAC с секретом Stripe из переменных окружения, проверяет timing-safe, возвращает 200, вызывает `global.durable.startNew()`. Оркестрация делает бизнес-логику.
When it fits
- Обработка занимает больше 2–3 секунд
- Нужна идемпотентность при повторных доставках
Tradeoffs
- Без timing-safe сравнения HMAC timing-атака может обойти проверку подписи.
- Без ключа идемпотентности (`Stripe-Signature` header + event ID) повторная доставка создаёт дубль записи в базе.
Нагрузка и где ломается
Что ломается в простых Stripe webhook-обработчиках
- Parsing body before HMAC verification: signature mismatch on valid events
- Slow inline fulfillment: Stripe retries after 30s, causing double-charges
- Missing idempotency key: retry delivers event twice, two orders created
Если обработчик парсит body до проверки подписи — атакующий может отправить произвольные события. Stripe требует raw body для HMAC.
Где костыли не спасают
Где теряются гарантии
Без timing-safe сравнения HMAC timing-атака может обойти проверку подписи.
Без ключа идемпотентности (`Stripe-Signature` header + event ID) повторная доставка создаёт дубль записи в базе.
Как помогает Inquir
Как Inquir закрывает каждый риск
Функция получает raw body в `event.body`, считает HMAC с секретом Stripe из переменных окружения, проверяет timing-safe, возвращает 200, вызывает `global.durable.startNew()`. Оркестрация делает бизнес-логику.
Секрет endpoint хранится в переменных окружения — не в коде. Ротация без редеплоя.
Что получаете
Ключевые части Stripe webhook-обработчика
Raw body
Не парсить body до проверки подписи. `event.body` — строка, как она пришла от Stripe.
HMAC SHA-256
timing-safe сравнение с `stripe.webhooks.constructEvent()` или ручным crypto.timingSafeEqual.
Ключ идемпотентности
Event ID из payload: `INSERT … ON CONFLICT DO NOTHING` до обработки.
Async оркестрация
`global.durable.startNew()` до `return { statusCode: 200 }` — бизнес-логика вне HTTP-окна.
Что делать дальше
Как обработать Stripe webhook
Проверить подпись
`stripe.webhooks.constructEvent(event.body, sig, secret)` — выбрасывает при несовпадении.
Проверить идемпотентность
Записать event.id в базу; конфликт — вернуть 200 без работы.
Ответить 200 и запустить оркестрацию
`global.durable.startNew('fulfill', undefined, { eventId, type, data })` → `return { statusCode: 200 }`.
Пример кода
Stripe webhook handler
Complete pattern: raw body, signature verify, idempotency key, fast ACK, async fulfillment pipeline.
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 кроме записи в БД
FAQ
Вопросы и ответы
Почему нужен raw body?
Stripe считает подпись по raw-байтам тела. После JSON.parse/stringify байты могут измениться — подпись не совпадёт.
Как хранить webhook secret?
Переменная окружения `STRIPE_WEBHOOK_SECRET` в настройках функции. Не коммитить в git.