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 дні, письмовий звіт). Написати мені.