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