Skip to main content
Назад к блогу
7 мин чтения

Вебхуки Stripe в продакшне: 6 режимов отказа, о которых никто не предупреждает

Паттерны отказов, которые всплывают только после первых 1000 оплаченных событий — баги подписей, штормы ретраев, молчаливые 200-ки, и фикс для каждого.

stripenext.jsdebugging

Вебхуки 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: {
      /* ... */
    },
  })
}

Два паттерна, которые помогают:

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 — но подписаны тестовым вебхук-секретом, не продакшн.

Почему: Обычно одно из:

Фикс: Две привычки спасают.

Привычка 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 дня, письменный отчёт). Написать мне.