Skip to main content
Назад до блогу
5 хв читання

Чому мій Stripe тест-режим пройшов, а продакшн зламався

Все працювало в Stripe test mode. Потім ми вийшли в прод — і платежі мовчки зависли. Коренева причина: комбінація підпису вебхуків, idempotency keys та поля метаданих, яке тест-режим ігнорує.

stripesecurity

Стейджинг був зелений. Кожен тестовий сценарій пройшов: успішна оплата, відхилена карта, 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 тест-режим не може зловити:

  1. Неправильний webhook-секрет — тест-режим використовує тестовий секрет, який коректний для тестового ендпоінту. Не можна симулювати невідповідність секрету.
  2. Реальна поведінка карткових мереж — 3D Secure challenges в тест-режимі миттєві. У продакшні деякі банки додають 10-секундні затримки. Наш таймаут був 5 секунд.
  3. Edge cases метаданих — тестові дані зазвичай мінімальні. Продакшн-дані брудні, довгі і з Unicode.
  4. Rate limits — Stripe тест-режим має щедрі rate limits. Продакшн-ліміти нижчі для нових акаунтів. Наш запуск продажу квитків впертся в ліміт на першій хвилині.
  5. Currency edge cases — тест-режим приймає будь-яку валюту. Продакшн енфорсить увімкнені валюти вашого Stripe-акаунту. У нас був користувач з браузерною локаллю, що тригернула непідтримувану валюту.

Чекліст, який я проганяю перед кожним Stripe go-live

  1. Перевір, що webhook-секрет відповідає оточенню. Логуй перші 8 символів API-ключа та webhook-секрету при старті. Якщо вони з різних оточень — падай голосно.
  2. Тестуй з реалістичними об'ємами даних. Якщо користувачі можуть обрати 20 елементів — тестуй з 20. Якщо імена можуть бути 200 символів — тестуй з 200.
  3. Тестуй idempotency з швидкими ретраями. Відкрий дві вкладки, відправ один і той самий checkout одночасно. Переконайся, що обидві вкладки розв'язуються коректно.
  4. Налаштуй моніторинг продакшн-вебхуків. Sentry-алерт на будь-який вебхук, що повернув не-200. Не лише помилки підпису — будь-які помилки.
  5. Зроби одну реальну транзакцію. Купи свій власний продукт реальною картою до відкриття для користувачів. Тестове списання в €1 ловить більше проблем, ніж 100 транзакцій у тест-режимі.

Детальніше про режими збоїв Stripe-вебхуків — той пост покриває 6 патернів, що ламаються після перших 1,000 заходів.


Випускаєш Stripe-інтеграцію в продакшн і хочеш перевірити на адекватність? Напиши — я випустив Stripe в 4 продакшн-системи і можу ревьюнути твою інтеграцію до того, як через неї потечуть реальні гроші.