1. ChatGPT App 中的 Webhook:到底是誰呼叫誰
在典型的 HTTP 世界,一切很簡單:你是客戶端,你發出 POST /api/...,伺服器回應,皆大歡喜。Webhook 則相反:當外部發生某事時,外部服務會主動向你的後端發起 HTTP 請求。
在 ChatGPT Apps 生態中,這會出現在幾個常見情境。例如,GiftGenius 透過 ACP/Instant Checkout 建立結帳後,會從金流供應商收到 payment_succeeded 的 webhook 通知。或者為禮物產生預覽圖片的後台服務在渲染完成時發送 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 處理器(骨架)
我們延續教學專案 GiftGenius,使用 Next.js 16。樣板中有 app/(UI)與 app/mcp/route.ts(MCP 伺服器)。
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,或至少在記錄事件後以非同步方式處理。這與逾時與重試直接相關,稍後會談。
4. Webhook 簽名:如何分辨「Stripe」與「拿 curl 的人」
回到處理器骨架中的 TODO——「簽名驗證」。讓我們看看要如何分辨真正的 Stripe 與「拿 curl 的人」。
最大的天真是假設只要 URL 足夠複雜(/api/webhooks/stripe/super-secret-abc123),就不會被找到。這類 URL 秘密本質上是 security through obscurity:試圖以複雜 URL 隱匿,防護力很弱。正確的防線是密碼學簽名。
幾乎所有嚴肅的供應商(Stripe、ACP、許多 CRM)都會依據請求本文與時間戳計算 HMAC 簽名,並將結果放入標頭。你作為接收端用相同規則計算並比對;只要有一點不符,就把請求當作偽造而丟棄。
一般做法:
- 你有在供應商後台取得的 webhook secret,並放在執行環境的秘密變數中(例如 Vercel env 的 STRIPE_WEBHOOK_SECRET)。
- 供應商送出請求時,會對 timestamp + '.' + rawBody 計算 HMAC。
- 在標頭(例如 Stripe-Signature)中寫入 timestamp 與一個或多個簽名。
- 你的處理器取出 timestamp,依相同規則計算 HMAC 並比對。
TypeScript 極簡範例(使用 crypto):
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // 選擇演算法
.update(payload, "utf8") // 原始本文(字串)
.digest("hex"); // hex 字串
}
簽名與事件新鮮度(timestamp)檢查範例:
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 allowlist,只允許來自供應商的位址,或 Webhook 專用網域)也不錯,但真正提供真偽保證的是密碼學簽名。
5. 逾時、快速回應與非同步處理
Webhook 喜歡「回應快」的人。多數金流與電商平台都期望你的端點能在數秒內回 2xx(通常不超過 10 秒,甚至更短)。如果你「想太久」,它們會視為失敗並開始重試。
直覺上你可能會:驗簽、查 DB、再打三個外部 API、計算報表、產 PDF,最後才回 200 OK。只要其中任何一步稍微卡住,金流就會認為 webhook 掛了,接著再送一次。結果就是你可能建立兩次訂單、寄兩封信、叫用某個 GPT 工具兩次——最後只好回頭救火。
正確模式可概括為「接收、記錄、延後處理」:
- 驗證簽名與基本不變條件(事件類型、必要欄位)。
- 快速把事件寫入表或佇列(盡量少的 DB 操作)。
- 回傳 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/queue 丟到背景執行,
// 教學示例先只做記錄:不 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 已簽名且確實來自真實服務,也應像對待使用者輸入或工具參數一樣地審慎對待它的內容。上一講我們談過,結構化的 schema 與正規化就是你的防火牆。
事件的 schema 例如可這樣定義(以 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——模型不該看到所有金流供應商送來的內容,尤其在不影響 UX 的情況下。
8. GiftGenius 實作:ACP/Instant Checkout 的付款 webhook
回到我們的 GiftGenius。在商務與 ACP 模組中,我們已說明代理如何建立 checkout 會話,之後由 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),避免混淆不同 schema。
在 handlePaymentSucceeded 中你會:
- 用 schema(Zod)驗證物件;
- 在交易中同時標記事件為已處理並更新訂單;
- (可選)把「慢動作」任務丟進佇列:寄信、分析、額外 API 呼叫。
這種做法讓「ACP → Webhook → GiftGenius」這條鏈在面對重送事件、暫時性故障與怪異資料時更為穩健。
9. Webhook 如何與 MCP、ChatGPT 與工具銜接
乍看之下,Webhook 似乎獨立於 ChatGPT App:就是後端的一個 HTTP 路由而已。其實它是整體架構的重要一環。
常見的串接方式如下:
- 模型在 ChatGPT 中呼叫 MCP 工具 create_checkout。
- MCP 伺服器呼叫金流、建立 checkout 會話,並在 ToolOutput 中返回訂單資訊與「等待付款」的狀態。
- 使用者在 UI 完成付款(Instant Checkout 可直接在 ChatGPT 內完成)。
- 金流把 webhook 發送到你的後端。
- 後端透過資料庫更新訂單狀態;下一次工具呼叫或模型的追問時,就能如實回覆:「訂單已付款,以下是詳細資訊」。
有時後端會以間接方式發起追問——例如透過 widget 或 Realtime 整合,由前端在伺服器訊號下自行呼叫 sendFollowUpMessage。 即便沒有這些,付款事實仍保留在你這裡;下次工具被呼叫時,後端會從資料庫讀到最新狀態,並把更新後的資料回給模型用於回覆。
重點是:Webhook 是與 MCP 伺服器同層級的入口,並共用相同的服務(資料庫、佇列、秘密)。安全性邏輯也相同:最低權限、驗證過的輸入、謹慎的日誌。
10. 使用 Webhook 與外部整合的常見錯誤
錯誤 №1:未驗證 Webhook 的簽名。
有時開發者只用「秘密」URL 或簡單的 Bearer my-secret 標頭。若這個 secret 外洩,任何人都能對你亂丟 webhook,建立訂單、改變付款狀態,甚至做任何事。正確做法是對本文做密碼學簽名(HMAC),並驗證 timestamp。這比「猜 URL」難得多。
錯誤 №2:在 webhook 請求中做繁重處理。
在 webhook 處理器裡做「建立訂單、打兩個外部 API、產 PDF、呼叫 GPT 模型、寄 5 封信」——這是自找逾時與重試。結果就是自己製造重複動作,事後還得收拾。更可靠的方式是快速確認收到事件(2xx)、寫入資料庫或佇列,並在背景處理。
錯誤 №3:業務邏輯不具冪等性。
常見的寫法是「每逢 payment_succeeded 就把餘額加上金額」。若 webhook 來兩次,餘額就會翻倍。另一種是同一張訂單被建立兩次、同一封郵件寄兩次。冪等性可藉由穩定的事件識別碼、已處理事件表、交易,以及「設定狀態」而非「累加」這類操作來達成。
錯誤 №4:缺少 webhook 資料的 schema 與驗證。
即便 webhook 已簽名,內容也可能不是你預期:供應商更動了格式、你從文件複製的 JSON 與實際不符、或你在型別上出錯。若不做 schema 與驗證就處理,很容易在流程中途出錯或悄悄地搞壞訂單。使用 Zod/JSON Schema 能簡化診斷,並讓你清楚地拒絕不合法的事件。
錯誤 №5:把含 PII 的 webhook 本文原樣寫入日誌。
除錯時很容易加上 console.log(rawBody) 然後忘掉它。在生產環境,日誌就會充滿 email、地址與其他 PII,還會送到第三方日誌服務。就隱私與法規(類 GDPR)而言,這是自傷。最好一開始就實作 PII scrub——遮罩敏感欄位,只記錄診斷所需的最少資訊。
錯誤 №6:把測試與正式的 webhook 混在一起。
常見情況是同一個端點同時接收測試與正式環境的事件。結果可能是測試付款改到了正式訂單狀態,或反之。更可靠的是分開 URL(例如 /webhooks/commerce/test 與 /webhooks/commerce/live),或至少在設定中保存「模式」並在入口檢查。
錯誤 №7:讓 ChatGPT 劇本完全依賴同步 webhook。
有時你會希望在呼叫工具並建立 checkout 會話後,模型立刻知道付款結果。但 webhook 本質上是非同步的,付款也可能需要時間。假設一切都會即時完成是個壞主意。更好的做法是設計能與延遲事件和平共處的對話與工具:保存訂單狀態、允許使用者回到對話稍後取得最新資訊。
GO TO FULL VERSION