How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?
It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.
Проверяйте необработанный payload до разбора JSON
Каждый платёжный провайдер подписывает точную последовательность байтов, отправляемую по сети. Если обработчик маршрута вызывает await request.json() и заново сериализует объект перед проверкой подписи, различия в порядке ключей или пробелах сделают легитимные вебхуки недействительными — или подтолкнут разработчиков отключить проверку на стейджинге.
Прочитайте необработанное тело один раз через await request.text() в обработчике POST Next.js App Router, проверьте криптографическую подпись с помощью серверного секрета вебхука и отклоняйте любые несовпадения с кодом HTTP 400 до выполнения первого запроса к базе. Руководство по модулю биллинга показывает, как supastack изолирует проверку подписей за единой точкой входа BFF.
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
await handleNormalizedBillingEvent(event);
return Response.json({ received: true });
}- Читайте необработанное тело запроса через
request.text()— никогда не сериализуйте разобранный JSON повторно. - Храните
STRIPE_WEBHOOK_SECRETи секреты подписи Lemon Squeezy исключительно в серверной конфигурации. - Используйте сравнение за постоянное время (
crypto.timingSafeEqual) при ручной проверке подписей HMAC.
Дедуплицируйте на границе базы данных, а не в памяти
Stripe и Lemon Squeezy повторяют отправку вебхуков при перезапуске деплоя, таймауте бессерверной функции или потере ответа в сети. Локальные для процесса коллекции или короткоживущие ключи кэша теряются между инстансами и не помешают двум одновременным повторам выдать дубликат лицензии или отправить два письма с квитанцией.
Записывайте ID события провайдера или статус оплаты заказа в той же транзакции Postgres, которая изменяет доступ по подписке. Атомарный INSERT ... ON CONFLICT DO NOTHING или условное обновление по status != 'paid' превращает параллельный повтор в безопасную пустую операцию. При проектировании тарифов, которые активируют эти вебхуки, генератор страницы тарифов SaaS помогает заранее смоделировать уровни и правила расчёта мест.
const settled = await db.transaction(async (tx) => {
const [inserted] = await tx
.insert(billingWebhookEvents)
.values({ providerEventId: event.id, provider: "stripe" })
.onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
.returning({ id: billingWebhookEvents.id });
if (!inserted) return false; // Already processed
await applyWorkspaceEntitlement(tx, event);
return true;
});Обрабатывайте события вне очереди и пересекающиеся подписки
Вебхуки не обязаны приходить в том порядке, в котором нажимал пользователь. customer.subscription.updated может прийти раньше checkout.session.completed, а клиент, случайно открывший две вкладки оплаты, может на короткое время получить две подписки в одном воркспейсе.
Привязывайте отмены и ошибки оплаты к конкретному ID подписки, вызвавшему событие. Прежде чем отзывать платный доступ воркспейса, проверьте, не покрывает ли этот воркспейс другая активная подписка — иначе отмена дублирующей подписки тихо лишит клиента доступа, за который он продолжает платить.
- Храните состояние подписки по ключу
providerSubscriptionId, а не только поworkspaceId. - Сохраняйте платный доступ воркспейса активным, пока хотя бы одна подписка находится в статусе
activeилиtrialing. - Проверяйте совпадение оплаченной суммы и валюты с ожидаемым SKU перед выполнением разовых заказов.
Частые вопросы о платёжных вебхуках в SaaS
Эти вопросы возникают всякий раз, когда команда переводит биллинг из песочницы на реальный продакшен-трафик.
- Можно ли открывать платные функции сразу при редиректе на страницу успешной оплаты? — Нет. Ссылку редиректа можно сохранить в закладки, переслать или открыть до завершения асинхронного платежа. Используйте страницу успеха для опроса состояния сервера, пока проверенный вебхук выдаёт доступ.
- Какой код ответа должен возвращать дублирующийся вебхук? — Возвращайте
200 OK, как только база данных подтвердит, что событие уже обработано. Ответ4xxили5xxзаставит провайдера продолжать повторные попытки. - Как локально тестировать повторную доставку вебхуков? — Напишите детерминированные модульные тесты, которые дважды отправляют один и тот же подписанный payload, и проверьте, что создаётся только одна лицензия, одно право доступа или одно транзакционное письмо.
- Как мобильные встроенные покупки вписываются в ту же модель? — Направляйте серверные уведомления RevenueCat в ту же идемпотентную таблицу прав доступа, чтобы веб- и мобильные покупки никогда не перезаписывали друг друга — список проверок перед релизом есть в чек-листе запуска.