Все статьи
Next.jsApp Routerwebhook debugginglocal testing

Вебхуки Next.js на localhost — правильно

Главный источник сломанных обработчиков вебхуков в 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

  1. Запустите next dev (или npm run dev) — обычно порт 3000.
  2. В другом терминале npx portpreview 3000.
  3. Вставьте HTTPS-URL в конфигурацию вебхука провайдера. Для Stripe: Developers → Webhooks → Add endpoint.
  4. Запустите тестовое событие. Запрос попадает в ваш 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 туннель с захватом.

Часто задаваемые вопросы

Как получить сырое тело в вебхуке Next.js App Router?
Вызовите await request.text() внутри обработчика POST. Не вызывайте request.json() первым — как только тело прочитано, повторный вызов .text() или .json() ничего не вернёт.
Нужен ли bodyParser: false в Next.js App Router?
Нет. Эта config была паттерном Pages Router и не имеет эквивалента в App Router. Тело читается по требованию из объекта Request, и вы читаете его как сырой текст всякий раз, когда нужно проверить подпись.
Можно ли запустить маршрут вебхука на edge runtime Next.js?
Можно, но SDK проверки подписи иногда полагаются на Node crypto-API, недоступные на edge. Оставайтесь на стандартном Node runtime для вебхуков, если нет конкретной причины переключаться.