Главный источник сломанных обработчиков вебхуков в Next.js App Router — это сырое тело. App Router в порядке. Route handler в порядке. Но соглашения по получению неразобранного тела запроса изменились между Pages Router и App Router, а большинство интеграций Stripe / GitHub / Shopify копируют сниппеты из старого мира. Эта статья — про новый мир.
Правило двух строк
В route handler App Router получайте сырое тело через await request.text(). Не используйте request.json() до проверки подписи. Не пытайтесь отключить bodyParser, как в Pages Router — этого флага больше нет.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text(); // raw, not parsed
const signature = (await headers()).get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return NextResponse.json(
{ error: 'invalid signature' },
{ status: 400 },
);
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
// ...
break;
}
return NextResponse.json({ received: true });
}
Три строки заслуживают внимания: request.text() даёт сырую строку, которую подписал Stripe. headers() в App Router асинхронна — заметьте await. И проверка подписи должна происходить до любого эквивалента JSON.parse.
Привычка Pages Router, от которой надо отучиться
Если вы мигрировали с Pages Router, вы помните, как писали это:
// pages/api/webhooks/stripe.ts (OLD)
export const config = { api: { bodyParser: false } };
Этой config нет в App Router. Тело читается по требованию из объекта Request. Всё, что читаете вторым, пусто — request.text() после request.json() ничего не вернёт. Выберите одно, и для вебхуков всегда выбирайте text().
Настройка туннеля для dev Next.js
- Запустите
next dev(илиnpm run dev) — обычно порт 3000. - В другом терминале
npx portpreview 3000. - Вставьте HTTPS-URL в конфигурацию вебхука провайдера. Для Stripe: Developers → Webhooks → Add endpoint.
- Запустите тестовое событие. Запрос попадает в ваш route handler со всеми заголовками нетронутыми.
Если используете next dev --turbo, это всё равно работает. Turbopack меняет бандлер, а не модель request/response.
Edge runtime: не надо
Если вы поставили export const runtime = 'edge' на route handler вебхука, вы потеряете доступ к Node-only crypto-API, которые используют некоторые SDK провайдеров. Stripe Node SDK работает на edge в недавних версиях, но проверка подписи с произвольными провайдерами — как повезёт. Держите маршруты вебхуков на Node runtime, если нет конкретной причины.
export const runtime = 'nodejs'; // explicit, safe default for webhooks
Подводные камни App Router, на которые мы постоянно натыкаемся
Возврат неправильного типа
Если вы возвращаете new Response(JSON.stringify(...)) вместо NextResponse.json(...), заголовок content-type может не установиться. Некоторым провайдерам это важно. Используйте NextResponse.json() или устанавливайте заголовок вручную.
Стриминг и вебсокеты
Обработчики вебхуков — короткоживущие HTTP-запросы, но если вы туннелируете приложение Next.js, у которого также есть WebSocket-маршруты (например, с socket.io на кастомном сервере), убедитесь, что туннель сохраняет апгрейды WebSocket. Большинство сохраняет; проверьте документацию.
Middleware, читающий тело
Если у вас есть middleware.ts в корне, который вызывает request.text() для логирования или авторизации, ваш маршрут вебхука никогда не видит тело. Пропустите путь вебхука в matcher или читайте тела только в маршрутах, которым они нужны.
Деплои на Vercel вас не спасут
Баги вебхуков, зависящие от разбора сырого тела, ведут себя одинаково на Vercel и на localhost. Как только ваш локальный тест через туннель проходит проверку подписи, развёрнутая версия тоже пройдёт — если только вы не упираетесь в лимит памяти или таймаута на serverless-стороне, тогда симптом другой (5xx, не 401).
По специфике Stripe см. руководство по локальному тестированию вебхуков Stripe. По математике подписи в целом — руководство по проверке подписи. Запишитесь в лист ожидания PortPreview, чтобы получить дружественный к Next.js туннель с захватом.