1. ChatGPT App 中的 Webhook:到底是谁先发起请求
在经典的 HTTP‑世界里很简单:你是客户端,你发起 POST /api/...,服务器响应,皆大欢喜。Webhook 则相反:外部服务在外部事件发生时,会主动向你的后端发起 HTTP 请求。
在 ChatGPT Apps 生态中,这会出现在几个典型场景里。例如,GiftGenius 通过 ACP/Instant Checkout 创建结账后,会通过 webhook 收到支付服务商发来的 payment_succeeded 通知。再比如,生成礼物预览图的后台服务在渲染完成时会向你发送 image_ready。此类场景下,ChatGPT 和你的 MCP 服务器已经完成了动作,球在第三方服务一边,它会通过 webhook 把结果告诉你。
关键特点:主导权不在你的系统内。请求可能在任何时刻到达,而且可能重复多次。因此,应该把 webhook 处理器视为潜在的最脆弱入口——整个互联网都会来敲这扇门。
做个小表格对比:
| 调用类型 | 谁先发起 | GiftGenius 中的示例 |
|---|---|---|
| 常规 API 请求 | 你方 | MCP 服务器调用 Stripe API |
| Webhook | 外部服务 | Stripe 向你发送 payment_succeeded |
2. 简易示意:ChatGPT、MCP 与 Webhook 分别在哪
流程示意如下:
sequenceDiagram
participant User as ChatGPT 中的用户
participant GPT as ChatGPT + 模型
participant App as GiftGenius (MCP/App)
participant PSP as 支付方(Stripe/ACP)
User->>GPT: "我想买礼物"
GPT->>App: callTool(create_checkout)
App->>PSP: POST /checkout_sessions
PSP-->>App: 200 OK + checkout_session_id
App-->>GPT: ToolOutput (结账信息)
PSP-->>App: POST /webhooks/payment_succeeded
App-->>PSP: 200 OK (已接收事件)
App->>DB: 标记订单已支付
上半部分是常见的出站请求,你已经很熟悉。Webhook 是图底部那一段:支付方主动来找你。今天我们关注的就是它。
3. Next.js 中的基础 Webhook 处理器(骨架)
我们继续基于 Next.js 16 打造教学项目 GiftGenius。模板中有用于 UI 的 app/,以及承载 MCP 服务器的 app/mcp/route.ts。
把 webhook 处理器拆到单独的 HTTP 路由是合理的,例如:app/api/webhooks/commerce/route.ts。
最小骨架如下:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // 1. 以字符串读取原始请求体
const headers = Object.fromEntries(req.headers); // 2. 获取请求头
// 3. TODO: 校验签名(稍后补上)
// 4. TODO: 解析 JSON 并处理事件
return new Response("ok", { status: 200 }); // 5. 快速返回 2xx
}
这里已经藏着几个关键点。
首先,我们把请求体当作文本读取,而不是直接 await req.json()。很多提供方对请求体的“原始”字节流进行签名,如果在校验签名前你先解析(甚至格式化)了它,签名就对不上了。
其次,要优先考虑尽快返回 2xx。重活儿应当移到独立的 worker,或者至少在记录事件后用 async 函数处理。这直接关系到超时与重试,我们稍后会谈。
4. Webhook 签名:如何区分“Stripe”和“拿着 curl 的人”
回到 TODO 中的“校验签名”。我们来看看如何把真正的 Stripe 与随手 curl 的请求区分开。
最大的天真是假设复杂的 URL(/api/webhooks/stripe/super-secret-abc123)就不会被发现。所谓 URL“密钥”本质上是掩耳盗铃(security through obscurity),防护力很弱。正确的防线是加密签名。
几乎所有严肃的提供方(Stripe、ACP、很多 CRM)都会基于请求体和时间戳计算 HMAC 签名并放入请求头。你作为接收方,需要按同样规则计算并比对。只要有一点不一致,就应当丢弃为伪造请求。
通用步骤:
- 你手里有一个 webhook 密钥,从提供方控制台获取,并放入环境机密(例如 Vercel env 中的 STRIPE_WEBHOOK_SECRET)。
- 提供方发送请求时按 timestamp + '.' + rawBody 计算 HMAC。
- 把 timestamp 和一个或多个签名写入请求头(如 Stripe-Signature)。
- 在你的处理器中取出 timestamp,按同样规则计算 HMAC 并进行比较。
使用 crypto 的 TypeScript 小示例:
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // 选择算法
.update(payload, "utf8") // 原始文本负载
.digest("hex"); // 以十六进制输出
}
签名与事件“新鲜度”的校验示例:
const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });
const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
return new Response("timestamp too old", { status: 400 });
}
const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
process.env.STRIPE_WEBHOOK_SECRET!,
payload
);
if (!crypto.timingSafeEqual(
Buffer.from(expectedSig, "hex"),
Buffer.from(theirSig, "hex")
)) {
return new Response("invalid signature", { status: 400 });
}
注意 timingSafeEqual——它用于防御时序攻击,防止攻击者通过比较耗时来猜测签名。
签名校验通过后,就可以放心地做 JSON.parse(rawBody) 或 await req.json(),因为它确实来自真实的提供方。
额外的防线如 IP 白名单(仅允许来自提供方的地址)和使用独立域名接收 webhook 也有帮助,但真正保证真实性的还是加密签名。
5. 超时、快速响应与异步处理
Webhook 喜欢“回得快”的端点。多数支付/电商平台期望你的端点在几秒内返回 2xx(常见不超过 10 秒,有时更短)。如果你“思考”太久,它们会把调用视为失败并开始重试。
直给的做法是:你校验签名,访问数据库,再调三个外部 API,生成报表,渲染 PDF,最后才返回 200 OK。只要其中任何一步卡顿,支付方就会判定 webhook 失败并再次发送。结果就是你会创建两次订单、发送两封邮件、调用两次某个 GPT 工具,然后花时间收拾残局。
正确的模式是“接收—落库—延后处理”:
- 校验签名与基本不变量(事件类型、必填字段)。
- 快速把事件写入表/队列(尽量少的数据库操作)。
- 返回 2xx。
- 在后台由独立 worker 处理事件。
不引入独立队列的“半正确”示例:先快速落库:
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
if (!verifySignature(headers, rawBody)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
await saveWebhookEvent(event); // 快速写入数据库
// 这里可以通过 setImmediate/队列把任务丢到后台,
// 但在教学示例里先只做记录:调用时不使用 await,
// 以便立刻返回 200。
processWebhookEventLater(event).catch(console.error);
return new Response("ok", { status: 200 });
}
注意:我们没有对 await processWebhookEventLater(...)。处理器把任务放到后台,立刻返回 200,以避免 webhook 超时。
在真实生产中,通常会引入队列(例如独立的 webhook_jobs 表或外部服务),由 worker 有序消费事件,不阻塞新请求的接收。
6. 幂等性与去重:如何避免重复扣款
教材里常把流程画得很完美:一个事件 → 一次处理 → 皆大欢喜。现实里 webhook 像“猫咪”一样——成批并且会连着来。
原因很简单:网络并不可靠,会超时;许多提供方设计上就会在未确定收到 2xx 前反复重发。对支付尤为重要:宁可重复发送 payment_succeeded,也不能丢。
因此,业务逻辑必须具备幂等性:对同一事件的重复处理不应改变最终结果(至少不能把系统弄坏)。
典型做法:
- 事件具备稳定标识,例如 event.id 或 checkout_session_id。
- 在“已处理事件”表中保存该标识,并对该字段加唯一索引。
- 每次收到 webhook 先检查:若存在相同 id 且状态为“已处理”,直接返回 200,什么也不做。
伪 ORM 的小示例:
async function handlePaymentSucceeded(event: any) {
const existing = await db.webhookEvents.findUnique({
where: { providerId: event.id },
});
if (existing?.processedAt) {
return; // 已处理,直接返回
}
await db.$transaction(async (tx) => {
await tx.webhookEvents.upsert({
where: { providerId: event.id },
update: { processedAt: new Date() },
create: {
provider: "stripe",
providerId: event.id,
type: event.type,
payload: event,
processedAt: new Date(),
},
});
await tx.orders.update({
where: { checkoutSessionId: event.data.object.id },
data: { status: "PAID" },
});
});
}
关键在于使用事务:同时把事件标记为已处理并更新订单。一旦中途失败,事务会回滚;下次重发的 webhook 到来时会再次尝试,而不会产生双写。
还可以让具体操作具备幂等性,例如:
- 把“设置订单状态为 PAID”替代“把余额增加 +100”;
- 使用“若不存在则创建”替代“再插入一行”。
7. Webhook 数据校验与 PII:签名并非唯一过滤器
即便 webhook 已签名且来自真实服务,也要像对待用户输入或工具参数那样谨慎。在上一讲我们已经讨论过:模式与规范化就是你的 firewall。
事件的模式可以这样定义(以 TypeScript/Zod 为例):
import { z } from "zod";
const paymentSucceededSchema = z.object({
id: z.string(),
type: z.literal("payment_succeeded"),
data: z.object({
object: z.object({
id: z.string(), // checkout_session_id
amount_total: z.number(),
currency: z.string(),
metadata: z.record(z.string(), z.string()).optional(),
}),
}),
});
在处理器中进行校验:
const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// 后续只使用 parsed
这样可以防止各种意外:例如“提供方更改了格式”、“测试环境字段变成了可空”等。如果有问题——记录错误日志并返回 400,提供方随后会重发或发送告警。
同时别忘了 PII:webhook 的请求体里经常包含 email、收货地址,甚至(代币化的)支付数据。在日志中进行脱敏处理,且不要把原始内容发送到第三方 APM/日志服务——这是我们在“机密与敏感数据”主题中强调过的基本实践。
另外,绝不要把完整 webhook JSON 不加筛选地回发到 ChatGPT 作为 ToolOutput——模型没必要看到支付方发送的一切信息,尤其当它与用户体验无关时。
8. GiftGenius 实战:ACP/Instant Checkout 的支付 webhook
回到我们的 GiftGenius。在电商与 ACP 模块里我们已经讨论过:智能体如何创建结账会话,以及如何通过 Instant Checkout 完成扣款。 从后端视角,接下来就是等待 webhook order.paid(或在 Stripe 术语中为 checkout.session.completed),以便:
- 落定订单状态;
- 启动“发送邮件”/“准备发货”的后续链路;
- 让智能体能明确回复“支付已完成”。
Next.js 中的简单处理器示例:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const headers = Object.fromEntries(req.headers);
if (!verifyCommerceSignature(headers, rawBody)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody);
if (event.type === "payment_succeeded") {
// 来自上一节的幂等处理器
await handlePaymentSucceeded(event);
}
return new Response("ok", { status: 200 });
}
函数 verifyCommerceSignature 实现了与上文相同的 HMAC 签名逻辑。在真实项目里,最好针对每个提供方做独立模块(verifyStripeSignature、verifyACPCheckoutSignature),避免混淆不同的规则。
在 handlePaymentSucceeded 中你需要:
- 按模式(Zod)校验对象;
- 在事务中标记事件已处理并更新订单;
- 可选:把“较慢”的动作放入队列,例如邮件、分析、额外 API 调用。
这一做法能让“ACP → webhook → GiftGenius”这条链路在重复事件、临时故障和异常数据面前依旧稳健。
9. Webhook 与 MCP、ChatGPT、工具的衔接点
乍看之下,webhook 像是独立于 ChatGPT App 的:后端上的某个 HTTP 路由而已。实际上它是整体架构的重要一环。
典型链路如下:
- MCP 工具 create_checkout 由 ChatGPT 中的模型调用。
- MCP 服务器调用支付方,创建结账会话,并在 ToolOutput 中返回订单信息与“等待支付”的状态。
- 用户在 UI 中完成支付(Instant Checkout 可直接在 ChatGPT 内完成)。
- 支付方向你的后端发送 webhook。
- 后端通过数据库更新订单状态;在下一次工具调用或模型的后续对话中,就可以如实回复:“订单已支付,详情如下”。
有时后端会以间接方式触发 follow‑up——例如通过小部件或 Realtime 集成,在服务器信号下调用 sendFollowUpMessage。 但即便没有这些,付款事实也保存在你这边;下次工具调用时,后端会从数据库读取最新状态并返回给模型用于回答。
要点在于:webhook 是与 MCP 服务器处于同一层级的入口,复用同样的服务(数据库、队列、机密)。安全基线也类似:最小权限、对输入做校验、谨慎记录日志。
10. 处理 webhook 与外部集成的常见错误
错误 1:未校验 webhook 签名。
有时开发者只用一个“秘密” URL 或简单的 Bearer my-secret 放在请求头。一旦密钥泄漏,任何人都能给你发 webhook,创建订单、修改支付状态,甚至做任何事。正确做法是对请求体做加密签名(HMAC)并校验时间戳。这比“猜 URL”要难得多。
错误 2:在 webhook 请求内做重处理。
在 webhook 处理器里写“创建订单、调两个外部 API、生成 PDF、调用 GPT 模型、发送 5 封邮件”——几乎必然会遇到超时与重试。结果你自己制造了重复,随后还得回头清理。更可靠的方式是尽快确认接收(2xx),把事件写入数据库或队列,在后台处理。
错误 3:业务逻辑不具备幂等性。
常见写法是:每次收到 payment_succeeded 就把余额加上金额。若 webhook 来两次,余额就翻倍。其他情况包括重复创建相同订单或重复给用户发邮件。通过稳定的事件 ID、已处理事件表、事务,以及“设置状态”而非“累加”,来实现幂等。
错误 4:缺少模式与数据校验。
即使 webhook 已签名,它也可能不是你以为的样子:提供方改了格式;你从文档里复制的 JSON 在测试环境里字段名不同;或者你只是类型写错。如果不做模式与校验直接处理,这些错误会在链路中悄然破坏订单或在中途抛异常。使用 Zod/JSON Schema 作为入口能简化诊断,并能明确拒绝无效事件。
错误 5:把包含 PII 的原始 webhook 体写进日志。
调试时很容易写上 console.log(rawBody) 然后忘了删。在生产中这会变成充满 email、地址和其他 PII 的日志,流向第三方日志服务。从隐私和合规(GDPR 类似要求)的角度看,这是在自伤。应尽早做 PII 脱敏——只记录确有诊断价值的内容。
错误 6:混用测试与生产的 webhook。
典型情况是:同一个端点同时接收提供方测试与生产环境的事件。最终测试支付会意外改变真实订单,或反过来。更可靠的做法是分开 URL(例如 /webhooks/commerce/test 与 /webhooks/commerce/live),或者至少在配置中保存“模式”并在入口处校验。
错误 7:把 ChatGPT 场景完全依赖于同步 webhook。
有时我们希望在调用工具并创建结账会话后,模型立刻就知道支付结果。但 webhook 天然是异步的,支付可能需要时间。按“会立刻发生”来设计流程是个坏主意。更好的方式是让对话和工具能适应延时事件:保存订单状态,允许用户回到对话稍后获取最新信息。
GO TO FULL VERSION