Stripe webhook 幂等的三种写法,以及我踩过的四个坑
Stripe 的 webhook 是至少投递一次,不是恰好一次。重复投递会让你的用户收到两份货、两张收据、两次扣款。三种解法从糙到稳,附可直接用的代码。
如果你接过 Stripe 收款,一定写过这样的代码:
case "checkout.session.completed":
await deliverProduct(session.metadata.order_id);
break;
这行代码在生产环境里迟早会出问题。不是"可能",是"迟早"。
为什么
Stripe 的 webhook 投递模型是 at-least-once(至少一次),不是恰好一次。官方文档写得很清楚,但很容易被略过。
会重复投递的情况:
- 你的接口超过超时时间(Stripe 等 20 秒左右)才返回 200
- 返回了 5xx
- 网络抖动导致响应没送达
- 你手动在 Dashboard 里「重发」某个事件
- Stripe 自己重试:默认会重试 3 天,间隔递增
结果是:同一笔订单,deliverProduct 可能被调用两次、三次。
对虚拟商品来说,这意味着用户收到两封交付邮件、两个下载链接、两次 AI 生成(真金白银的 token 成本)。
对实物商品来说,意味着发两次货。
解法一:事件 ID 去重表(最通用)
Stripe 每个事件有一个稳定的 event.id(形如 evt_1Nxxx)。建一张表存它,插入失败就说明处理过。
create table processed_events (
event_id text primary key,
type text not null,
created_at timestamptz default now()
);
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const sig = req.headers.get("stripe-signature");
if (!sig) return new NextResponse("no signature", { status: 400 });
// ⚠️ 必须拿原始文本,不能先 req.json()
const raw = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return new NextResponse("invalid signature", { status: 400 });
}
// 去重:主键冲突 = 已处理过
const { error } = await supabase
.from("processed_events")
.insert({ event_id: event.id, type: event.type });
if (error?.code === "23505") {
// duplicate key —— 直接确认,不重复干活
return NextResponse.json({ received: true, duplicate: true });
}
if (error) {
// 写库失败要让 Stripe 重试,别吞掉
return new NextResponse("db error", { status: 500 });
}
switch (event.type) {
case "checkout.session.completed":
await deliverProduct(event.data.object.metadata.order_id);
break;
// ...
}
return NextResponse.json({ received: true });
}
优点:通用,任何事件类型都能用;去重和业务逻辑解耦。
缺点:多一张表;如果插入了去重记录但业务处理失败,重试时会被判为"已处理"而跳过。
最后这一点很关键。两种处理方式:
- 先插入、后处理:安全但会漏(处理失败也不重试)
- 先处理、后插入:可能重复处理(但不会漏)
对"发货"这种事,漏比重复更糟。所以推荐顺序是:处理成功 → 再写入去重记录。失败时不写,让 Stripe 重试。
解法二:条件更新(最简单,单表就够)
如果你的订单本身有状态机,根本不需要额外的表。
关键在 SQL 的 WHERE:
// 只有 pending 的订单才能变成 paid
const { data, error } = await supabase
.from("orders")
.update({
status: "paid",
paid_at: new Date().toISOString(),
})
.eq("id", orderId)
.eq("status", "pending") // ← 这一行是幂等的全部
.select();
if (error) return new NextResponse("db error", { status: 500 });
if (!data || data.length === 0) {
// 没有行被更新 → 要么不存在,要么已经是 paid
return NextResponse.json({ received: true, alreadyProcessed: true });
}
// 只有第一次会走到这里
await deliverProduct(orderId);
原理:UPDATE ... WHERE status = 'pending' 是原子的。两个并发请求同时到达,数据库会串行化,只有一个能匹配到行。
优点:零额外表,逻辑清晰,并发安全。
缺点:只适用于"状态单向流转"的场景。
解法三:幂等键 + 唯一索引(适合外部副作用)
如果副作用发生在你的系统之外(发邮件、调第三方 API、发币),状态机救不了你。这时用业务幂等键。
create table deliveries (
order_id uuid not null,
kind text not null, -- 'email' | 'download' | 'refund'
created_at timestamptz default now(),
unique (order_id, kind) -- 同一订单同一种交付只能有一次
);
const { error } = await supabase
.from("deliveries")
.insert({ order_id: orderId, kind: "email" });
if (error?.code === "23505") {
return NextResponse.json({ received: true, skipped: true });
}
await sendDeliveryEmail(orderId); // 只有抢到唯一键的请求能执行
这个模式叫「先占位、后执行」。比解法一更精确,因为它按业务动作去重,而不是按事件。
四个坑
坑 1:先 req.json() 再验签
// ❌ 错的
const body = await req.json();
const event = stripe.webhooks.constructEvent(
JSON.stringify(body), sig, secret
);
JSON.stringify 不会还原原始字节(空格、转义、键顺序都可能变),签名验证必然失败。
必须用 await req.text() 拿原始字符串。
坑 2:在 webhook 里做耗时操作
Stripe 超时大约是 20 秒。如果你在里面调 AI 生成内容(我的场景是 800–1200 字的解读),很容易超时。
超时 → Stripe 重试 → 又触发一次生成。
正确做法:webhook 只负责落库 + 标记,实际交付交给异步任务(队列、后台 cron、或者带 after() 的延迟执行)。
// webhook 里只做这些
await markOrderPaid(orderId);
await enqueueDelivery(orderId); // 立刻返回
return NextResponse.json({ received: true });
坑 3:假设事件顺序
Stripe 不保证事件到达顺序。
你的代码可能会先收到 payment_intent.succeeded,再收到 checkout.session.completed。如果你在前者里假设"订单已存在",而订单是在后者里创建的,就会报空指针。
对策:不要依赖顺序。每个 handler 都要能独立处理"前置数据不存在"的情况,或者用订单 ID 而不用 session 对象做关联。
坑 4:吞掉所有错误
try {
await handle(event);
} catch (e) {
console.log(e); // ❌ 然后返回 200
}
return NextResponse.json({ received: true });
返回 200 = 告诉 Stripe「处理成功了,别再发了」。如果你在 catch 里返回 200,这笔订单就永久丢了。
正确做法:能重试的错误返回 5xx,让 Stripe 重试。只有确定"再试也没用"的(比如订单确实不存在、签名无效)才返回 2xx 或 4xx。
生产环境的检查清单
□ 验签用 req.text(),不是 req.json()
□ 有去重机制(表 / 条件更新 / 唯一键,三选一)
□ 去重逻辑覆盖并发(不能靠内存 Set)
□ webhook 内不做耗时操作,超过 3 秒的交给异步
□ 不假设事件顺序
□ 处理失败返回 5xx,不吞错
□ 在 Stripe Dashboard 用「重发」按钮手动测三次,确认只有一次副作用
□ 有一个对账脚本:Stripe 的 charges 列表 vs 数据库订单表
最后一条我是吃过亏才加的。跑一次对账脚本,你才知道 webhook 有没有悄悄漏事件——因为漏掉的事件不会报错,只会让订单永远停在 pending。
我的站上线半年,76 条订单里 44 条停在 pending。绝大多数是自己测试时点开支付页又关掉了(正常),但如果不做对账,就分不清"用户放弃"和"webhook 没收到"。
一句话总结
幂等的关键不是"防止重复执行",是让重复执行的结果和执行一次相同。
前者要靠外部状态(去重表),后者可以靠数据本身(条件更新)。优先用后者,因为它不需要额外机制就天然并发安全。