Вебхуки 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 дні, письмовий звіт). Написати мені.