Стейджинг був зелений. Кожен тестовий сценарій пройшов: успішна оплата, відхилена карта, 3D Secure challenge, частковий повернення, апгрейд підписки. У нас було 47 інтеграційних тестів на Stripe-флоу. Усі пройшли.
Ми випустили в продакшн у вівторок зранку. До вівторка після обіду 3 з перших 8 реальних платежів застрягли в статусі «обробка». Гроші у клієнтів списалися, але наша система не підтвердила.
Ось що пішло не так — три проблеми, усі невидимі в тест-режимі.
Проблема 1: Верифікація підпису вебхука з неправильним секретом
У нас було два webhook-ендпоінти: один для стейджингу, один для продакшну. Кожен отримує свій signing secret з дашборду Stripe. Наш конфіг виглядав так:
// .env.production
STRIPE_WEBHOOK_SECRET = whsec_live_xxxxx
Окрім того, що ні. Під час міграції стейджинг→продакшн хтось (я) скопіював стейджинговий .env файл і оновив API-ключі, але пропустив webhook-секрет. Продакшн-сервер верифікував підписи вебхуків по стейджинговому секрету.
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET // ← стейджинговий секрет у продакшні
)
У тест-режимі це «працювало», бо ми тестували стейджинговий ендпоінт зі стейджинговим секретом. У продакшні Stripe підписує продакшн-секретом. Кожен вебхук приходив, провалював верифікацію підпису і мовчки відкидався.
Мовчазність — ось що нас підвело. Наша обробка помилок ловила StripeSignatureVerificationError і повертала 400 — Stripe ретраїв 3 рази, потім здавався. Жодного алерту з нашого боку, бо помилка була «очікувана» (ми мали catch-блок, що логував на рівні warn, не error).
Фікс: Валідація env-змінних на старті, що перевіряє відповідність webhook-секрету оточенню API-ключа. І промоутити помилки підпису вебхуків до рівня error з алертом.
// Валідація при запуску
if (
process.env.STRIPE_SECRET_KEY?.startsWith('sk_live_') &&
!process.env.STRIPE_WEBHOOK_SECRET?.startsWith('whsec_live_')
) {
throw new Error('Production Stripe key with non-production webhook secret')
}
Проблема 2: Idempotency keys, що збіглися
Наш checkout-флоу використовував idempotency keys для запобігання подвійним списанням:
const session = await stripe.checkout.sessions.create(
{
// ...конфіг сесії
},
{
idempotencyKey: `checkout_${userId}_${eventId}`,
}
)
У тест-режимі ми вільно створювали і видаляли тестові дані. Одна й та сама комбінація користувач-захід перевикористовувалась між тестовими прогонами. Без проблем — Stripe тест-режим скидає idempotency keys через 24 години.
У продакшні реальний користувач спробував купити квитки, отримав мережевий таймаут, оновив сторінку і спробував знову протягом 30 секунд. Idempotency key був ідентичний. Stripe повернув оригінальну сесію (коректна поведінка), але наш фронтенд створив новий UI-стейт, очікуючи новий URL сесії. Користувач побачив сторінку «сесія закінчилась».
Глибинна проблема: наш idempotency key не включав таймстамп чи лічильник спроб. Дві легітимні спроби протягом однієї хвилини отримували однаковий ключ.
Фікс: Включити лічильник спроб checkout в idempotency key:
const session = await stripe.checkout.sessions.create(
{
// ...конфіг сесії
},
{
idempotencyKey: `checkout_${userId}_${eventId}_${attemptNumber}`,
}
)
Де attemptNumber зберігається в Redis з TTL 5 хвилин. Один користувач + один захід протягом 5 хвилин інкрементує лічильник.
Проблема 3: Ліміт розміру метаданих
Наші checkout-сесії включали метадані про покупку:
metadata: {
userId: user.id,
eventId: event.id,
seats: JSON.stringify(selectedSeats), // ← проблема
locale: currentLocale,
referralCode: referralCode ?? '',
}
У тест-режимі ми тестували з 1–4 місцями. selectedSeats було щось типу ["A1","A2","A3"] — 20 байт. Нормально.
У продакшні групова покупка обрала 12 місць. Значення метаданих seats було 180 символів. Ліміт значення метаданих Stripe — 500 символів, тож влізло. Але загальний розмір метаданих перевищив ліміт Stripe в поєднанні з іншими полями та довгими UUID. Stripe відхилив запит з дженерік помилкою валідації.
Тест-режим не зловив це, бо ми ніколи не тестували більше 4 місць. Ліміт метаданих не енфорситься по-різному в тест-режимі — ми просто ніколи його не досягали.
Фікс: Не зберігати списки місць у метаданих. Зберігати посилання:
metadata: {
userId: user.id,
eventId: event.id,
seatCount: String(selectedSeats.length),
bookingRef: bookingId, // шукаємо місця в нашій БД, не в Stripe
}
Stripe-метадані — для крос-посилань, не для зберігання даних.
Що тест-режим не тестує
Після цього інциденту я склав список речей, які Stripe тест-режим не може зловити:
- Неправильний webhook-секрет — тест-режим використовує тестовий секрет, який коректний для тестового ендпоінту. Не можна симулювати невідповідність секрету.
- Реальна поведінка карткових мереж — 3D Secure challenges в тест-режимі миттєві. У продакшні деякі банки додають 10-секундні затримки. Наш таймаут був 5 секунд.
- Edge cases метаданих — тестові дані зазвичай мінімальні. Продакшн-дані брудні, довгі і з Unicode.
- Rate limits — Stripe тест-режим має щедрі rate limits. Продакшн-ліміти нижчі для нових акаунтів. Наш запуск продажу квитків впертся в ліміт на першій хвилині.
- Currency edge cases — тест-режим приймає будь-яку валюту. Продакшн енфорсить увімкнені валюти вашого Stripe-акаунту. У нас був користувач з браузерною локаллю, що тригернула непідтримувану валюту.
Чекліст, який я проганяю перед кожним Stripe go-live
- Перевір, що webhook-секрет відповідає оточенню. Логуй перші 8 символів API-ключа та webhook-секрету при старті. Якщо вони з різних оточень — падай голосно.
- Тестуй з реалістичними об'ємами даних. Якщо користувачі можуть обрати 20 елементів — тестуй з 20. Якщо імена можуть бути 200 символів — тестуй з 200.
- Тестуй idempotency з швидкими ретраями. Відкрий дві вкладки, відправ один і той самий checkout одночасно. Переконайся, що обидві вкладки розв'язуються коректно.
- Налаштуй моніторинг продакшн-вебхуків. Sentry-алерт на будь-який вебхук, що повернув не-200. Не лише помилки підпису — будь-які помилки.
- Зроби одну реальну транзакцію. Купи свій власний продукт реальною картою до відкриття для користувачів. Тестове списання в €1 ловить більше проблем, ніж 100 транзакцій у тест-режимі.
Детальніше про режими збоїв Stripe-вебхуків — той пост покриває 6 патернів, що ламаються після перших 1,000 заходів.
Випускаєш Stripe-інтеграцію в продакшн і хочеш перевірити на адекватність? Напиши — я випустив Stripe в 4 продакшн-системи і можу ревьюнути твою інтеграцію до того, як через неї потечуть реальні гроші.