CodeGym /课程 /ChatGPT Apps /Webhook 与外部集成:签名、超时、幂等性

Webhook 与外部集成:签名、超时、幂等性

ChatGPT Apps
第 15 级 , 课程 3
可用

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 签名并放入请求头。你作为接收方,需要按同样规则计算并比对。只要有一点不一致,就应当丢弃为伪造请求。

通用步骤:

  1. 你手里有一个 webhook 密钥,从提供方控制台获取,并放入环境机密(例如 Vercel env 中的 STRIPE_WEBHOOK_SECRET)。
  2. 提供方发送请求时按 timestamp + '.' + rawBody 计算 HMAC。
  3. 把 timestamp 和一个或多个签名写入请求头(如 Stripe-Signature)。
  4. 在你的处理器中取出 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 工具,然后花时间收拾残局。

正确的模式是“接收—落库—延后处理”:

  1. 校验签名与基本不变量(事件类型、必填字段)。
  2. 快速把事件写入表/队列(尽量少的数据库操作)。
  3. 返回 2xx
  4. 在后台由独立 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,也不能丢。

因此,业务逻辑必须具备幂等性:对同一事件的重复处理不应改变最终结果(至少不能把系统弄坏)。

典型做法:

  1. 事件具备稳定标识,例如 event.idcheckout_session_id
  2. 在“已处理事件”表中保存该标识,并对该字段加唯一索引。
  3. 每次收到 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 签名逻辑。在真实项目里,最好针对每个提供方做独立模块(verifyStripeSignatureverifyACPCheckoutSignature),避免混淆不同的规则。

handlePaymentSucceeded 中你需要:

  • 按模式(Zod)校验对象;
  • 在事务中标记事件已处理并更新订单;
  • 可选:把“较慢”的动作放入队列,例如邮件、分析、额外 API 调用。

这一做法能让“ACP → webhook → GiftGenius”这条链路在重复事件、临时故障和异常数据面前依旧稳健。

9. Webhook 与 MCP、ChatGPT、工具的衔接点

乍看之下,webhook 像是独立于 ChatGPT App 的:后端上的某个 HTTP 路由而已。实际上它是整体架构的重要一环。

典型链路如下:

  1. MCP 工具 create_checkout 由 ChatGPT 中的模型调用。
  2. MCP 服务器调用支付方,创建结账会话,并在 ToolOutput 中返回订单信息与“等待支付”的状态。
  3. 用户在 UI 中完成支付(Instant Checkout 可直接在 ChatGPT 内完成)。
  4. 支付方向你的后端发送 webhook。
  5. 后端通过数据库更新订单状态;在下一次工具调用或模型的后续对话中,就可以如实回复:“订单已支付,详情如下”。

有时后端会以间接方式触发 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 天然是异步的,支付可能需要时间。按“会立刻发生”来设计流程是个坏主意。更好的方式是让对话和工具能适应延时事件:保存订单状态,允许用户回到对话稍后获取最新信息。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION