
Об оплате Stripe сообщает магазину вебхуком. Вебхук может не пройти проверку подписи, прийти дважды, обогнать предыдущий или не прийти вообще. Для магазина это оплаченный заказ, который висит неоплаченным, два письма покупателю на одну покупку или заказ, который после оплаты откатился назад. Разобрал все 4 случая на магазине с NestJS и React, код открыт на GitHub.
Привет, Хабр!
В 2020 году эта статья тоже была про прием платежей, только через другой шлюз. Он с тех пор устарел, поэтому тема осталась, а текст и заголовок переписаны целиком.
Что не так с официальным примером
У Stripe есть репозиторий stripe-samples/checkout-one-time-payments, у него больше 1000 звезд. Обработчик вебхука там проверяет подпись по телу запроса до разбора JSON, и это правильно. Но если переменная STRIPE_WEBHOOK_SECRET не задана, он берет событие из тела запроса без всякой проверки. Дальше на checkout.session.completed пишет в консоль Payment received! и отвечает 200. На этом все. Какие события уже обработаны, он не запоминает, статуса заказа нет, со Stripe не сверяется, тестов тоже нет.
Для примера это нормально, он показывает API, а не магазин. Но что нужно дописать, Stripe сам пишет в документации по вебхукам. Подпись надо проверять по телу запроса в том виде, как оно пришло, до разбора JSON. Одно событие может прийти дважды, а события могут прийти не по порядку. И Stripe повторяет доставку не вечно, в live до трех дней, в sandbox 3 раза за несколько часов. Если сервер лежал дольше, событие потеряно. В документации все это описано словами, а кода под это в примере нет.
Что в репозитории
Репо nmrcs/stripe-payments, монорепо на npm workspaces. Фронт на React и HeroUI, список из 6 товаров он берет из API, корзина открывается сбоку, количество меняется прямо в карточке. Бэкенд на NestJS 11, Prisma 7 и PostgreSQL 18. Оплата через Stripe Checkout, то есть форму оплаты рисует Stripe, а бэкенд только создает сессию и потом ждет вебхук.
# Команды для запуска тестов docker compose up -d npm install npm test
21 тест проходит за пару секунд. В тестах работает настоящий клиент Stripe из SDK, только его запросы в сеть подменены. А подпись проверяется по-настоящему, это обычный HMAC, он считается локально. Тест подписывает вебхук через stripe.webhooks.generateTestHeaderString и шлет его в приложение через supertest.
В примере используется sandbox, тестовая среда Stripe. Реальных денег в ней нет. Цитата из документации Stripe.
When testing in a sandbox, the payments you create aren’t processed by card networks or payment providers.
Платить там можно только тестовыми картами, например 4242 4242 4242 4242. Бэкенд в репо не стартует с ключом, который не начинается на sk_test_, поэтому живой ключ в него не подставить. Тесты можно гонять локально вообще без сети, после npm install и скачивания образа Postgres им не нужны ни интернет, ни аккаунт Stripe.
Чтобы заплатить руками, нужен аккаунт Stripe. Sandbox доступен сразу после регистрации, подтверждать бизнес нужно только для live, это есть в инструкции по настройке аккаунта. Stripe CLI я в систему ставить не стал, он крутится в контейнере stripe/stripe-cli и пересылает вебхуки на host.docker.internal. Запускается через npm run stripe:listen.
Руками в sandbox я проверил 3 сценария.
- Оплата картой 4242. Вебхук пришел раньше, чем покупатель вернулся со страницы Stripe, и заказ на странице сразу был paid.
- Повтор. Отправил то же событие еще раз через
stripe events resend. Ответ 200, в логеwebhooks.event.duplicate, в базе одна запись события и одна дата оплаты. - Пропуск. Остановил слушатель и оплатил заказ. Через 116 секунд сверка перевела его в paid, хотя ни одного события по нему в базе нет.
Это уже было
Если вы писали консюмер для Kafka или RabbitMQ, ничего нового тут не будет. Stripe доставляет вебхуки at-least-once и без гарантии порядка, и решается это теми же способами. Id обработанных событий пишутся в той же транзакции, что и изменение заказа, это паттерн inbox, он же idempotent consumer. Статус заказа двигается только вперед, это конечный автомат. А на случай потерь есть периодический опрос Stripe, это сверка, reconciliation.
Свое у Stripe другое, и в статьях про очереди этого нет. Фреймворк разбирает JSON раньше вашего кода, и после этого подпись не сходится. checkout.session.completed еще не значит, что заказ оплачен. А если оплата с задержкой не прошла, сессия навсегда остается в статусе complete, и отличить ее от оплаты, которая еще идет, можно только по PaymentIntent.
Как идет заказ
фронт корзина POST /checkout │ API заказ pending sessions.create │ Stripe страница оплаты покупатель платит │ ├── редирект на /orders/:id │ фронт опрашивает заказ │ └── POST /webhooks/stripe API проверяет подпись, пишет id события, двигает статус вперед API сверка раз в минуту sessions.retrieve по открытым заказам
Цену бэкенд берет из базы, а не из запроса. В тесте в запрос подкладывается priceCents: 1, а заказ все равно стоит 3600 центов. Заказ создается до сессии, его id уходит в metadata.orderId, и по нему вебхук потом находит заказ.
1. Подпись
Stripe подписывает ровно те байты, которые отправил. А Express, и Nest вместе с ним, разбирает JSON еще до обработчика. Если собрать тело обратно через JSON.stringify, получится другая строка. Stripe шлет JSON с отступами, JSON.stringify их выкидывает, и подпись не сходится ни на одном настоящем событии.
В Nest это решается одной опцией.
const app = await NestFactory.create(AppModule, { rawBody: true })
С ней у запроса появляется req.rawBody, это байты тела в том виде, как они пришли. Разобранный JSON в req.body тоже остается, он нужен остальным роутам. Контроллер вебхука отдает на проверку в constructEvent только rawBody.
Минус в том, что копия тела хранится у каждого JSON-запроса, а не только у вебхука. Можно было повесить express.raw() только на роут вебхука, до общего парсера. Но в API всего 6 роутов, и мне проще одна опция, чем следить за порядком middleware.
И в отличие от официального примера, без STRIPE_WEBHOOK_SECRET приложение просто не запустится. Схема env на Zod требует строку, которая начинается с whsec_, так что принять вебхук без проверки не получится.
Тест подписывает тело с отступами, потом отправляет тот же JSON, собранный заново без них. Приложение отвечает 400, заказ остается pending, в журнале событий пусто. Еще один тест шлет подпись из одних нулей.
2. Дубли
Stripe доставляет каждое событие минимум один раз, а иногда и больше. Сервер ответил медленно или упал после записи, но до ответа, и Stripe пришлет событие снова. Если на paid магазин отправляет письмо или списывает товар со склада, получится два письма и два списания.
Поэтому id каждого события я пишу в таблицу StripeEvent, это ее первичный ключ. Запись события и изменение заказа идут в одной транзакции.
const duplicate = await this.prisma.$transaction(async (tx) => { const { count } = await tx.stripeEvent.createMany({ data: [{ id: event.id, type: event.type, orderId }], skipDuplicates: true, }) if (count === 0) return true if (orderId && to) { await this.orders.transition(orderId, to, 'webhook', tx) } return false })
create с перехватом ошибки уникальности тут не подходит. В Postgres после любой ошибки транзакция сломана, и все следующие запросы в ней падают, пока не сделать откат. А skipDuplicates превращается в INSERT … ON CONFLICT DO NOTHING, ошибки нет, просто count будет 0 или 1.
Если две копии одного события пришли одновременно, второй INSERT ждет, пока закоммитится первая транзакция, и получает 0. На это есть отдельный тест, два запроса через Promise.all.
Запись в журнал сохраняется только вместе с изменением заказа. Если изменение упало, запись тоже откатывается, Stripe получает 500 и присылает событие снова. Если писать журнал отдельно, до обработки, то упавшее событие навсегда останется помеченным как обработанное.
Еще Stripe пишет, что иногда про один и тот же объект приходят два разных события с разными id. Журнал такие не поймает. Их отсекает то, что статус заказа двигается только вперед.
3. Порядок
С картой все просто, checkout.session.completed приходит с payment_status: 'paid'. С оплатой, которая проходит с задержкой, например SEPA Debit, сначала приходит completed с unpaid, а позже async_payment_succeeded или async_payment_failed. Stripe не гарантирует, что они придут в этом порядке. Если succeeded обгонит completed, а обработчик просто запишет статус из события, оплаченный заказ откатится в processing.
Отсортировать по полю created тоже не выйдет, у разных событий там бывает одна и та же секунда. Это Stripe пишет прямо в документации.
Поэтому статус заказа двигается только вперед, и вся машина состояний это одна таблица.
const NEXT: Record<OrderStatus, readonly OrderStatus[]> = { pending: ['processing', 'paid', 'failed', 'expired'], processing: ['paid', 'failed'], paid: [], failed: [], expired: [], }
Проверка и запись идут одним запросом.
const { count } = await db.order.updateMany({ where: { id: orderId, status: { in: allowedFrom(to) } }, data: { status: to, ...(to === 'paid' ? { paidAt: new Date() } : {}) }, })
Это один UPDATE … WHERE status IN (…), а не так, что сначала читаем заказ, проверяем в коде и потом пишем. Иначе два события, пришедшие одновременно, оба прочитают pending и оба запишут. А completed, который опоздал и пришел после paid, просто не найдет подходящую строку. count будет 0, в логе orders.transition.ignored.
Можно было бы на каждый вебхук запрашивать у Stripe свежую сессию и не верить телу события. Но это лишний запрос в Stripe на каждое событие. А статусы тут идут только вперед, из paid деваться некуда, так что таблицы переходов хватает. Минус в том, что таблица должна быть без ошибок. Ошибка в ней ломает сразу и дубли, и порядок, и сверку, поэтому на нее есть отдельный юнит-тест.
Вживую я это не проверял, только тестами. Чтобы Stripe прислал async_payment_succeeded, нужна оплата с задержкой, например SEPA Debit с тестовым IBAN, ее не подключал.
4. Пропущенный вебхук
Stripe повторяет доставку до трех дней в live и 3 раза за несколько часов в sandbox. Если сервер лежал дольше, эндпоинт отключили в дашборде или поменяли домен и забыли про вебхук, событие не придет уже никогда. Покупатель заплатил, а заказ висит в pending.
Для этого есть сверка. Раз в минуту она берет заказы в pending и processing старше 2 минут, забирает их сессию из Stripe и прогоняет через тот же transition, что и вебхук. Порог нужен, чтобы не дергать Stripe про заказ, за который покупатель прямо сейчас платит.

export function statusFromSession(session: Stripe.Checkout.Session): OrderStatus | null { if (session.status === 'expired') return 'expired' if (session.payment_status === 'paid') return 'paid' if (session.status !== 'complete') return null const intent = typeof session.payment_intent === 'object' ? session.payment_intent : null if (intent?.status === 'requires_payment_method' || intent?.status === 'canceled') { return 'failed' } return 'processing' }
Вебхук и сверка меняют статус через одну и ту же функцию. Если они придут к одному заказу одновременно, сработает один UPDATE, а второй изменит 0 строк.
Сверка крутится на setInterval внутри процесса. Для одного экземпляра это нормально. Если запустить два экземпляра, оба будут спрашивать Stripe про одни и те же заказы. Сломать они ничего не сломают, но запросов в Stripe станет вдвое больше. Тогда сверку надо выносить в отдельный cron или брать блокировку в базе.
Грабли
Ключ идемпотентности, который не защищал от двойного клика. При создании сессии я передаю idempotencyKey: checkout-${order.id} и сначала думал, что это защита от двойного клика. Но заказ создается на каждый POST /checkout. У второго клика другой заказ и другой ключ, и Stripe честно создаст вторую сессию. Ключ защищает только от повтора того же вызова к Stripe. От двойного клика сейчас спасает только кнопка, она блокируется, пока идет запрос. На сервере это не закрыто. Для этого ключ должен приходить от клиента в заголовке, этого я не делал.
Сессия живет сутки. По умолчанию сессия Checkout живет 24 часа. Покупатель ушел со страницы оплаты, а сверка сутки раз в минуту спрашивает Stripe про его заказ. Поставил expires_at на 31 минуту. Минимум у Stripe 30 минут, лишняя минута на расхождение часов между моим сервером и Stripe. В живом прогоне созданная сессия получила срок ровно 31 минуту.
Неудачная оплата с задержкой навсегда оставалась в processing. Первая версия сверки смотрела только на сессию. Если оплата с задержкой не прошла, сессия навсегда остается complete и unpaid, ровно как у оплаты, которая еще идет. Если вебхук async_payment_failed потерялся, сверка видела unpaid и оставляла processing. Отличить их можно только по PaymentIntent, после неудачи он возвращается в requires_payment_method. Теперь сверка запрашивает сессию вместе с PaymentIntent, через expand: ['payment_intent']. На оба случая есть тест, и я проверил, что без проверки PaymentIntent тест падает.
Чего нет
В примере нет аккаунтов, поэтому заказ видит любой, у кого есть его id. Нет возвратов, доставки и налогов. Checkout умеет все три, но это новые экраны, а не новые проблемы вебхуков. Нет live-режима, бэкенд не стартует с ключом, который не начинается на sk_test_. Нет очереди повторов. Упавший обработчик отвечает 500, дальше повторяет Stripe, а что не довез Stripe, довозит сверка.
Итог
Репо — github.com/nmrcs/stripe-payments, документация Stripe по вебхукам — docs.stripe.com/webhooks, официальный пример — stripe-samples/checkout-one-time-payments.

