PAYATHON 2026

Next.js webhook 为何返回 301 而不是 200?

支付小周

结论

301 不是 webhook 处理函数返回的。请求还没进入 WebhookHandler,就已经被 Next.js 配置、Middleware、反向代理、CDN 或域名规则重定向了。

先看响应头中的 Location,它会告诉你请求被重定向到了哪里。常见原因有:

  • webhook 地址的结尾斜杠不一致,例如 /api/webhook/ 被跳转到 /api/webhook
  • http 被强制跳转到 https
  • 裸域名与 www 域名之间存在跳转
  • next.config.js 配置了 redirects() 或 trailingSlash
  • Middleware、Nginx、Cloudflare 或托管平台设置了重定向
  • webhook 填写的是页面地址,不是真正的 API Route 地址

支付平台不一定会跟随重定向。即便跟随,也可能把 POST 改成 GET,导致 webhook 失败。因此,支付平台中配置的 URL 应直接返回 200,不要依赖重定向。

先检查 Location 响应头

使用支付平台中配置的完整 URL 进行测试:

curl -i -X POST 'https://example.com/api/webhook' \
  -H 'Content-Type: application/json' \
  --data '{"status":"paid"}'

如果响应类似:

HTTP/2 301
Location: https://www.example.com/api/webhook

问题出在域名规范化。

如果响应是:

HTTP/2 301
Location: https://example.com/api/webhook/

通常是结尾斜杠规则造成的。

测试时不要先加 -L 自动跟随跳转,否则可能看不到真正的问题。确认跳转目标后,把支付平台中的 webhook URL 改成最终地址。

检查 Next.js 重定向配置

查看 next.config.js 或 next.config.mjs 中是否配置了 redirects():

module.exports = {
  async redirects() {
    return [
      {
        source: '/api/:path*',
        destination: '/:path*',
        permanent: true,
      },
    ];
  },
};

这类规则可能误伤 /api/webhook。API Route 通常不应经过针对页面设置的规范化跳转。

还要检查 trailingSlash:

module.exports = {
  trailingSlash: true,
};

启用后,应用可能要求 URL 以 / 结尾。未启用时,带 / 的地址也可能被规范化。

问题不在于必须开启还是关闭这项配置,而在于支付平台中的地址必须与应用最终接受的地址完全一致:

https://example.com/api/webhook

或:

https://example.com/api/webhook/

应使用无需重定向就能返回响应的那个地址。

检查 Middleware

如果项目中有 middleware.ts 或 middleware.js,需要确认登录、语言、域名或路径规则没有重定向 API Route。

例如,下面的认证逻辑会把 webhook 一并跳转到登录页:

import { NextResponse } from 'next/server';

export function middleware(request) {
  const authenticated = false;

  if (!authenticated) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

可以通过 matcher 排除 webhook:

export const config = {
  matcher: ['/dashboard/:path*'],
};

也可以在逻辑中明确放行:

import { NextResponse } from 'next/server';

export function middleware(request) {
  if (request.nextUrl.pathname === '/api/webhook') {
    return NextResponse.next();
  }

  // 其他认证或重定向逻辑
  return NextResponse.next();
}

国际化 Middleware 也需要检查。将 /api/webhook 重定向到 /zh/api/webhook 通常是错误的,因为 API 路径不应参与页面语言跳转。

检查部署平台和反向代理

如果本地请求直接返回 200,线上却返回 301,应重点检查 Next.js 之外的配置:

  • 托管平台的域名跳转设置
  • Cloudflare Redirect Rules 或 Page Rules
  • Nginx、Apache、负载均衡器配置
  • http 到 https 的强制跳转
  • example.com 到 www.example.com 的规范域名跳转
  • 平台配置文件中的 redirects 或 rewrites

例如,支付平台配置的是:

http://example.com/api/webhook

服务器则会强制跳转到:

https://example.com/api/webhook

这种情况不需要取消 HTTPS,直接把 webhook 地址改成 HTTPS 即可。

如果生产环境使用的规范域名是 www.example.com,支付平台也应直接使用这个域名。

修正请求体处理代码

301 通常与下面这行代码没有直接关系,因为发生重定向时,请求很可能还没进入处理函数:

const requestData = JSON.parse(req);

不过,这行代码本身也有问题。req 是请求对象,不是 JSON 字符串。在 Pages Router 的 API Route 中,Next.js 默认会根据 Content-Type 解析请求体,通常直接读取 req.body:

export default async function WebhookHandler(req, res) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', ['POST']);
    return res.status(405).end('Method not allowed.');
  }

  try {
    const requestData = req.body;

    if (requestData.status === 'paid') {
      // Verify the webhook signature.
      // Update the database idempotently.
    }

    return res.status(200).json({ received: true });
  } catch (error) {
    console.error('Webhook processing failed:', error);
    return res.status(400).json({ received: false });
  }
}

如果第三方发送的 Content-Type 不正确,req.body 可能是字符串,可以根据类型分别处理:

const requestData =
  typeof req.body === 'string' ? JSON.parse(req.body) : req.body;

不要对请求对象本身执行 JSON.parse(req)。

使用 App Router 时的写法

如果 endpoint 位于 app/api/webhook/route.js,应使用 Route Handler:

import { NextResponse } from 'next/server';

export async function POST(request) {
  try {
    const requestData = await request.json();

    if (requestData.status === 'paid') {
      // Verify the webhook signature.
      // Update the database idempotently.
    }

    return NextResponse.json(
      { received: true },
      { status: 200 }
    );
  } catch (error) {
    console.error('Webhook processing failed:', error);

    return NextResponse.json(
      { received: false },
      { status: 400 }
    );
  }
}

Pages Router 和 App Router 读取请求体的方式不同,不能混用。

验证签名时不要提前解析原始请求体

许多支付平台要求使用原始请求体计算 webhook 签名。遇到这种情况,Pages Router 可能需要关闭默认的 body parser:

export const config = {
  api: {
    bodyParser: false,
  },
};

随后读取原始字节并验证签名。具体实现必须遵循支付平台的签名算法。不要先解析 JSON 再重新序列化,因为空格、字段顺序或编码的变化都可能导致验签失败。

App Router 可以这样处理:

export async function POST(request) {
  const rawBody = await request.text();

  // Verify signature using rawBody.
  const requestData = JSON.parse(rawBody);

  return Response.json({ received: true });
}

为什么 webhook 不应该使用重定向

301 表示永久重定向,但不同 webhook 客户端的处理方式并不一致:

  • 有些客户端会直接将其视为投递失败
  • 有些客户端不会跟随重定向
  • 有些客户端跟随跳转后可能把 POST 改成 GET
  • 原始请求体或签名相关请求头可能无法按预期保留
  • 重试机制可能因此反复投递同一个事件

如果确实需要在代理层跳转并保留请求方法,307 或 308 的语义通常比 301 更合适。不过,对 webhook endpoint 来说,最稳妥的处理方式仍是不进行任何跳转。

推荐的排查顺序

  1. 使用 curl -i 请求支付平台中配置的完整 URL。
  2. 查看 Location 响应头,确认跳转目标。
  3. 将 webhook URL 直接改成最终的 HTTPS 地址,并使用规范域名和正确的斜杠形式。
  4. 检查 next.config.js 中的 redirects() 和 trailingSlash。
  5. 检查 Middleware 是否覆盖了 /api/webhook。
  6. 检查 CDN、反向代理和托管平台的域名重定向设置。
  7. 确认处理函数使用了 req.body、request.json() 或验签所需的原始请求体。
  8. 更新数据库前先验证签名,并按照事件 ID 或订单状态实现幂等处理。

只要原始 webhook URL 不经过重定向,处理函数也能正确读取请求体,该 endpoint 就应直接返回 200。

备注:内容仅供参考。