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 продакшн-системы и могу ревьюнуть твою интеграцию до того, как через неё потекут реальные деньги.