Вебхуки Stripe — это часть интеграции с платежами, которая выглядит простой в туториале и превращается в 3-часовое Slack-сообщение в продакшне. Официальная документация хорошо покрывает happy path. Она не покрывает шесть способов, которыми happy path ломается, когда ты процессишь реальные деньги.
Это полевой гайд из отгрузки Stripe на E7 Platform и нескольких других продуктах. Каждый режим отказа идёт с тем, как его обнаружить и как я его чиню.
1. Несовпадение подписи — ловушка с raw body
Что ломается: Вебхук-эндпоинт возвращает 400 Invalid signature на каждое реальное событие от Stripe. Ты ничего не менял. Тестовые события из Stripe CLI работают нормально.
Почему: Next.js (App Router особенно) и большинство Node-фреймворков парсят тело запроса как JSON по дефолту. Верификация подписи Stripe нуждается в сыром, немодифицированном байтовом представлении тела. Если фреймворк уже превратил его в распарсенный объект, байты не совпадают, и HMAC-подпись фейлится.
Фикс (Next.js App Router):
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text() // критично — .text(), не .json()
const signature = (await headers()).get('stripe-signature')
if (!signature) return new Response('No signature', { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response(`Invalid signature: ${(err as Error).message}`, {
status: 400,
})
}
await handleEvent(event)
return new Response(null, { status: 200 })
}
Строчка, которая решает — await request.text(). Парси JSON после успешной верификации подписи, а не до.
Обнаружение: Логируй и Invalid signature ошибки, и успешные верификации с таймстампом. Продакшн-эндпоинт должен показывать ~100% success rate для реальных событий. Что-то меньше — у тебя этот баг (или ротирующий секрет — см. #5).
2. Идемпотентность — ретраи вызывают двойную обработку
Что ломается: Клиент списывается один раз, но получает два подтверждающих письма, или подписка активируется дважды, или team seat создаётся дважды.
Почему: Stripe агрессивно ретраит вебхуки. Если эндпоинт медленный, или таймаутит, или возвращает 5xx, Stripe стукнет снова. До 3 дней экспоненциального backoff. Тот же event.id придёт несколько раз. Если обработчик не идемпотентный, каждый ретрай перезапускает сайд-эффекты.
Фикс: Персисти каждый обработанный event ID и проверяй его первым. Простая таблица Postgres, primary key на stripe_event_id:
CREATE TABLE processed_stripe_events (
stripe_event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
async function handleEvent(event: Stripe.Event) {
// Идемпотентность — INSERT зафейлится, если мы уже видели этот ID
try {
await db.processedStripeEvents.create({
data: { stripeEventId: event.id, eventType: event.type },
})
} catch (err) {
if (isUniqueViolation(err)) {
console.log(`Duplicate event ${event.id}, skipping`)
return
}
throw err
}
// Реальный обработчик выполняется ровно один раз на событие
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event)
break
// ...
}
}
Паттерн insert-first важен. Если вставлять после обработчика, краш между обработчиком и вставкой оставляет тебя в том же режиме отказа.
3. Порядок событий — неупорядоченные подписки
Что ломается: Ты получаешь customer.subscription.updated до customer.subscription.created. Или invoice.paid до того, как подписка существует в твоей базе. Обработчик фейлится, потому что не может найти родительскую запись.
Почему: Stripe не гарантирует порядок событий. Два события, сработавшие почти одновременно, могут прийти в любом порядке, особенно если одно ретраилось после транзиентного сбоя.
Фикс: Не полагайся на события для parent-child семантики. Вместо этого фетчи полное текущее состояние из Stripe, когда приходит зависимое событие, и реконсили.
async function handleSubscriptionUpdated(event: Stripe.Event) {
const sub = event.data.object as Stripe.Subscription
// Не доверяй событию; фетчи текущее состояние
const current = await stripe.subscriptions.retrieve(sub.id, {
expand: ['customer'],
})
// Upsert — создаёт, если нет (обрабатывает неупорядоченный .created)
await db.subscriptions.upsert({
where: { stripeId: current.id },
create: {
/* ... */
},
update: {
/* ... */
},
})
}
Два паттерна, которые помогают:
- Upsert, а не условный insert-or-update. Меньше race conditions.
- Рефетчи из Stripe на зависимых событиях. API Stripe — это source of truth; вебхук — уведомление об изменении, а не данные.
4. 10-секундный таймаут
Что ломается: Обработчик вебхука делает что-то медленное (отправляет письмо, вызывает внешний API, запускает ML-инференс) и занимает 12 секунд. Stripe таймаутит на 10 секундах, помечает вебхук как failed, ретраит. Теперь ты запускаешь два обработчика параллельно для одного события, что триггерит режим отказа #2, если ты пропустил идемпотентность.
Почему: Опубликованный таймаут Stripe — 10 секунд. На практике их балансировщик иногда сдаётся раньше. Любой вебхук, делающий значимую работу дольше 3–5 секунд, на тонком льду.
Фикс: Обработчик делает две вещи — верифицирует и ставит в очередь. Больше ничего.
async function handleEvent(event: Stripe.Event) {
// Идемпотентность (быстро: один DB insert)
await insertProcessedEvent(event.id)
// Ставим реальную работу в очередь (быстро: один queue insert)
await queue.enqueue({
type: `stripe.${event.type}`,
eventId: event.id,
payload: event.data.object,
})
// Возвращаем 200 немедленно
}
Фоновый воркер подбирает задачу из очереди и делает медленную работу — письма, синхронизация с внутренними API, Slack-уведомления. Вебхук-эндпоинт стабильно возвращает за ~100ms. Stripe доволен.
Используй любую очередь: Redis + BullMQ, Postgres + pg-boss, SQS, Inngest. Конкретный выбор менее важен, чем наличие очереди.
5. Несовпадение окружений — тестовые события попадают в прод
Что ломается: Логи показывают ошибки Invalid signature в странное время. При расследовании события — реальные от Stripe — но подписаны тестовым вебхук-секретом, не продакшн.
Почему: Обычно одно из:
- Разработчик оставил работающий CLI с
stripe listen --forward-to, указанным на продакшн URL - Стейджинг с общим секретом был неправильно настроен
- Ты ротировал вебхук-секрет в Stripe, но забыл обновить env-переменную в проде
Фикс: Две привычки спасают.
Привычка 1: используй разные пути эндпоинтов для теста и прода.
https://api.yourapp.com/webhooks/stripe/live <- только live-события
https://api.yourapp.com/webhooks/stripe/test <- только тест-события
Каждый путь верифицирует против своего секрета. Тестовое событие, попадающее на /live, фейлится чисто и шумно, и ты можешь алертить на это.
Привычка 2: логируй флаг livemode события и алерти на несовпадения.
if (event.livemode !== (process.env.NODE_ENV === 'production')) {
await alert(
`Stripe livemode mismatch: event=${event.livemode}, env=${process.env.NODE_ENV}`
)
return new Response(null, { status: 400 })
}
Одна строчка. Экономит длинный инцидент.
6. Молчаливая 200-ка — возвращаем success, не обработав событие
Что ломается: Дашборд Stripe показывает все события доставленными успешно. Твоя база не отражает их. Клиенты жалуются, что оплаченная подписка не активна. Логи не показывают ошибок, потому что обработчик их поймал и проглотил.
Почему: Где-то глубоко в обработчике await someAsync() выбрасывает. Catch-блок, который должен был логировать ошибку, на самом деле вернул success из-за того, как был написан. Или try/catch оборачивает только часть обработчика, а остальное молча фейлится. Или ты возвращаешь 200 слишком рано, до того, как обработчик реально сделал работу.
Фикс: Три правила.
Правило 1: никогда не возвращай 200 до завершения обработчика. Если используешь паттерн с очередью из #4, 200 — ок, потому что реальная работа передана надёжной очереди. Иначе — await обработчик полностью перед возвратом.
Правило 2: перебрасывай ошибки, не глотай. Пусть вебхук вернёт 500, пусть Stripe ретраит. Ретрай дёшев; молча сломанная подписка — дорогая.
try {
await handleEvent(event)
} catch (err) {
console.error(`Webhook handler failed for ${event.id}:`, err)
// НЕ возвращай 200 здесь — пусть Stripe ретраит
return new Response(`Handler failed: ${(err as Error).message}`, {
status: 500,
})
}
Правило 3: алерти на любой exception обработчика вебхуков в продакшне. Sentry, Rollbar, PostHog — что угодно, что пришлёт тебе письмо за 5 минут. Вебхук, фейлящийся молча — худший возможный режим отказа в продакшне, потому что ты узнаёшь о нём не раньше клиента.
Мета-урок
Каждый из этих багов попадает в прод с первого раза и ловится после первого реального инцидента. Со второго раза ты уже построил леса — логирование подписей, таблица идемпотентности, фоновая очередь, guard на livemode, Sentry на exceptions — и вся эта категория багов исчезает.
Если ты собираешься отгрузить Stripe в продакшн впервые, построй эти леса до включения live mode. Это день работы, который окупается десятикратно.
Я отгрузил интеграции Stripe в шести продакшн-продуктах. Если твой застрял в одном из этих режимов отказа — или хочешь вторую пару глаз перед выходом в live — я делаю аудиты вебхуков (€500, оборот 2 дня, письменный отчёт). Написать мне.