工程2026年8月3日9 分钟阅读

在 Vercel 上接收 Webhook,不让事件丢失

签名验证莫名失败,waitUntil 的任务凭空消失,冷启动吃掉 Shopify 只给的 5 秒:Serverless 上接收 Webhook 会踩的坑和对策。

waitUntil · 这个 200 是你兑现不了的承诺验证返回 200waitUntil(work)只在内存中被回收 → 丢失,不会再重发先持久化 · 这个 200 是真的验证持久化写入返回 200处理可重试
一个 200 是事实,另一个 200 只是希望,区别就在这里。

Serverless 上的 Webhook 处理程序出错的方式就那么几种,固定且可以复现,而几乎每一种的表象都和真正的 bug 对不上。请求体可以证明是正确的,签名验证却不通过。安排在响应之后的任务从来没有执行。处理程序通过了所有本地测试,却只在生产环境、只在高负载时超时。

这些都不是 Vercel 故意刁难。原因在于:以请求为作用域的执行模型,遇上了一个默认服务器长期在线的协议。下面是最常见的五个问题,按你会遇到它们的顺序排列。

TL;DR

  • 在任何代码解析请求体之前先 await req.text(),否则每一次签名验证都会失败
  • 不要急着用 runtime = "edge"。Fluid Compute 上的 Node.js 才是正确的默认选择
  • waitUntil 能让函数在响应之后继续运行,但它不是持久化的。它不是队列
  • 冷启动耗掉的是平台的响应时限,而 Shopify 只给你 5 秒
  • 处理程序真正的响应时限由最严格的那个平台决定,而不是 Vercel

1. 原始请求体问题

这是每个人最先碰到的问题,而报错信息几乎帮不上忙,因为请求体在日志里看起来一点问题都没有。

每个平台签名的都是它发出的原始字节。Stripe 在原始请求体前加上时间戳前缀再计算 HMAC,Shopify 对原始请求体计算 HMAC,GitHub 也对原始请求体计算 HMAC。从网络到你的验证调用之间,只要有任何一层把 JSON 解析后再重新序列化,键的顺序或空白就会差出一个字节,HMAC 也就对不上了。数据完全相同,签名却不同。

HMAC 算的是字节,不是含义原始字节{"id":"evt_1","n":2}req.text()hmac匹配解析后重新序列化{ "n": 2, "id": "evt_1" }req.json()hmac不匹配数据相同数据相同。键的顺序不同,空白不同,而请求体在你的日志里看起来完全正常。

在 App Router 中,先读取文本,再从已经拿到的字符串解析:

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const raw = await req.text();                     // exact bytes, before any parsing
  const sig = req.headers.get("stripe-signature");
  if (!sig) return new Response("missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("invalid signature", { status: 400 });   // no details to the caller
  }

  await queue.publish(event);
  return new Response(null, { status: 200 });
}

有两点要分清楚。

绝不要先调用 await req.json()。那样你就再也拿不回原始字节:流已经被读完,而你重新序列化出来的内容,并不是被签名的那一份。

Pages Router 需要显式关闭 body parser。如果你还在用 pages/api,设置 export const config = { api: { bodyParser: false } },然后自己读取原始流。大多数“本地能用、部署后不行”的问题,查到最后都是少了这一行的某种写法。

各平台的签名格式(Stripe 的 t=/v1= 方案、GitHub 的 sha256= 前缀、Shopify 的 base64),见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。平台 SDK 提供了验证函数的,就用它,不要自己手写:它替你做了常量时间比较和时间戳容差,这两样都很容易在细节上写错。

2. 不要把 Webhook 处理程序放在 Edge 运行时上

很多人一直有这样一种直觉:Webhook 接收端必须快,所以应该用 runtime = "edge"。这是过时的建议。

Vercel 的默认配置是 Fluid Compute 上的 Node.js,它在并发请求之间复用实例,当初让 Edge 显得诱人的冷启动差距,已经基本被抹平。与此同时,Edge 运行时会让你失去 Webhook 处理程序实实在在需要的东西:完整的 node:crypto、多数平台 SDK 默认依赖的 Node API,以及更长的执行时间窗口。人们常提的另一个理由是流式响应,而它在 Node.js 上同样能正常工作,而且已经支持了一段时间。

具体会这样出错:调用 crypto.createHmac 的平台 SDK 在 Edge 上直接抛错,于是你用 WebCrypto 手写验证,而时序安全的比较就在这个手写版本里被悄悄丢掉了。你用一项实实在在的安全保障,换来了一个基本已经消失的冷启动差距。

保持默认配置。

如果你眼前已经有一个本地正常、生产环境出错的处理程序,在生产环境排查失败的 Webhook 里有一棵决策树,帮你缩小范围,找出是哪一层吞掉了请求。

3. waitUntil 不是队列

在 Vercel 上,要“先快速返回,再处理”,最自然的做法是 waitUntil:

import { waitUntil } from "@vercel/functions";

export async function POST(req: Request) {
  const event = await verify(req);
  waitUntil(syncToSalesforce(event));      // continues after the response
  return new Response(null, { status: 200 });
}

它确实有用,但它确实不等于持久化。这个区别比听上去更重要:

  • 实例如果在执行途中被回收或崩溃,任务就没了。没有重试,没有死信,没有记录
  • 你已经向平台返回了 200。它永远不会再发。这个事件从此在哪里都不存在
  • waitUntil 里的失败不会让请求失败,所以除非你显式捕获并上报,否则根本看不见

这是最糟的一类故障:罕见、无声,事后也无法还原,因为事件唯一的副本只在内存里。

waitUntil 适合那些丢了也无妨、发出就不管的任务:统计上报、缓存预热、一条 Slack 通知。凡是丢了客户会察觉的事情,都不该交给它。

waitUntil · 这个 200 是你兑现不了的承诺验证返回 200waitUntil(work)只在内存中被回收 → 丢失,不会再重发先持久化 · 这个 200 是真的验证持久化写入返回 200处理可重试

让你远离麻烦的规则是:先持久化,再应答。把事件写进 Postgres、Redis 或真正的队列,然后返回 200,再去处理。这时 200 才是一句真话,因为事件确实在你手里,而处理过程可以失败,也可以按需要重试任意多次。

export async function POST(req: Request) {
  const event = await verify(req);
  await db.insert(events).values({ id: event.id, payload: event }); // durable
  waitUntil(processNow(event.id));   // fast path; a cron sweeps anything left behind
  return new Response(null, { status: 200 });
}

快速路径处理正常情况。定时扫描处理中途挂掉的实例。两者都不会丢事件。

4. 冷启动花的是平台给你的时间

Vercel 默认的执行时长上限是 300 秒,听起来超时根本不成问题。事实并非如此,因为给你计时的不是 Vercel。给你计时的是调用你的那一方,而它们的时限要紧得多:

平台响应时限
Slack、Discord3 秒
Shopify5 秒
GitHub10 秒
Twilio15 秒
Stripe未公布

处理程序真正的超时,取决于你接收的平台里最严格的那一个,所以值得弄清你面对的每一个时限,而不只是你记得的那一个。我们的 Webhook 平台参考列出了它们。面对 Shopify 的五秒,一次 2 秒的冷启动在你的第一行代码执行之前,就已经用掉了 40% 的时限。超时检测工具会向你的路由发两次请求并分别计时,冷启动的那次和热的那次并排显示;如果慢在模型调用上,你的 Webhook 处理程序调用了大模型(LLM)讲了 after() 的用法和它管不到的地方。

真正有用的做法:

  • 让路由的导入图保持精简。一个只负责验证和入队的 Webhook 路由,不应该间接导入你的 ORM、admin SDK 和邮件客户端。它们每一个都要在冷启动时解析
  • 把重量级的依赖放进真正需要它们的分支里懒加载,而不是在模块顶层导入
  • 给 Webhook 路由设一个较低的 maxDuration。export const maxDuration = 10 不会让你变快,但它会把失控的处理程序变成一次快速失败,平台会重试;否则就是挂起 300 秒,白白消耗算力,问题却一点没有解决

5. 凌晨 3 点,并发不是你的朋友

Serverless 会横向扩展。限时抢购期间你要的正是这个,而它恰恰也打破了大多数幂等代码赖以成立的假设。

一百个并发实例意味着一百个并发的处理程序,每个都在打开数据库连接。Postgres 有连接数上限,而且比你以为的要低。平台上的连接池就是为此准备的;跳过它,一次流量高峰就会让每个请求都报 too many connections,包括真实用户的请求。

更隐蔽的一个问题:同一个事件的两次重试,可能在同一毫秒落到两个实例上。先读后写的幂等逻辑在这里存在竞态,顺序执行的测试永远发现不了。这件事必须交给唯一约束来做,而不是一个 if:

// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);

// Safe: the database arbitrates
const { rowCount } = await db.query(
  `INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
  [event.id],
);
if (rowCount === 0) return ok();
await doWork(event);

更多内容(包括乱序投递)见同一个 Webhook 收到了两次。

AnyHook 能做什么

上面除了原始请求体那一条,其余说到底都是同一个问题:一个以请求为作用域的运行时,被要求做出它自己兑现不了的持久化承诺。

AnyHook 把这个承诺从函数里移了出去。平台把请求 POST 到 in.anyhook.net/you/app,然后:

  • 发送方约 1 秒就能收到 200,由边缘节点上一个只负责持久化和应答的 Worker 返回。Shopify 的五秒不再是你的约束
  • 事件在我们应答之前就已持久化保存,并做了静态加密,所以“实例挂了”只意味着一次重试,而不是一次丢失
  • 投递到你的 Vercel 函数失败后,会在 5 分钟、30 分钟、3 小时、18 小时后重试,之后每天一次,视套餐共 3 到 10 次尝试;仍然失败的事件留在日志里,可以重放
  • 一个 AnyHook-Signature 请求头取代各平台各自的验证方案,每次投递尝试都用新的时间戳重新签名,所以重试不会像转发过来的平台签名那样,因超出时间容差而验证失败
  • 可以按时间范围重放,应对一次部署窗口吞掉一小时事件的情况

你的处理程序仍然必须幂等。这一点什么都替代不了。但它不再需要同时做到持久、快速、始终保持热启动。

不注册任何东西也能先看看效果:一条命令就能拿到一个可用的接收 URL,无需账号:

curl -X POST https://anyhook.net/api/v1/quickstart

响应里有一个 inbound_url,填到平台那边;一个 api_key,用来通过 API 读取事件日志;还有一个 claim_url。这个端点是临时的:7 天后它和其中的事件都会被删除,除非你打开认领 URL 并登录,认领后 URL 保持不变。

总结

先读原始请求体,留在 Node.js 运行时上,把 waitUntil 当作尽力而为的机制,而不是队列。如果只从本文带走一个结构上的原则,那就是:对还没有持久写下来的事件,永远不要返回 200。清单上的其他问题都是性能问题。这一个是数据丢失问题,也是唯一一个事后无法调试的问题。

Workers 版本的清单差别很大,所以我们单独写了一篇:在 Cloudflare Workers 上接收 Webhook。

全部文章2026年8月3日 · 9 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。