CodeGym /課程 /ChatGPT Apps /Webhook 與外部整合:簽名、逾時、冪等性

Webhook 與外部整合:簽名、逾時、冪等性

ChatGPT Apps
等級 15 , 課堂 3
開放

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 簽名,並將結果放入標頭。你作為接收端用相同規則計算並比對;只要有一點不符,就把請求當作偽造而丟棄。

一般做法:

  1. 你有在供應商後台取得的 webhook secret,並放在執行環境的秘密變數中(例如 Vercel env 的 STRIPE_WEBHOOK_SECRET)。
  2. 供應商送出請求時,會對 timestamp + '.' + rawBody 計算 HMAC。
  3. 在標頭(例如 Stripe-Signature)中寫入 timestamp 與一個或多個簽名。
  4. 你的處理器取出 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 工具兩次——最後只好回頭救火。

正確模式可概括為「接收、記錄、延後處理」:

  1. 驗證簽名與基本不變條件(事件類型、必要欄位)。
  2. 快速把事件寫入表或佇列(盡量少的 DB 操作)。
  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/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,也不要讓它遺失。

因此,你的業務邏輯必須是冪等的:重複處理同一事件不應改變結果(至少不會把系統弄壞)。

典型做法:

  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 已簽名且確實來自真實服務,也應像對待使用者輸入或工具參數一樣地審慎對待它的內容。上一講我們談過,結構化的 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 簽名邏輯。 在實際專案中,建議對每個供應商拆成獨立模組(如 verifyStripeSignatureverifyACPCheckoutSignature),避免混淆不同 schema。

handlePaymentSucceeded 中你會:

  • 用 schema(Zod)驗證物件;
  • 在交易中同時標記事件為已處理並更新訂單;
  • (可選)把「慢動作」任務丟進佇列:寄信、分析、額外 API 呼叫。

這種做法讓「ACP → Webhook → GiftGenius」這條鏈在面對重送事件、暫時性故障與怪異資料時更為穩健。

9. Webhook 如何與 MCP、ChatGPT 與工具銜接

乍看之下,Webhook 似乎獨立於 ChatGPT App:就是後端的一個 HTTP 路由而已。其實它是整體架構的重要一環。

常見的串接方式如下:

  1. 模型在 ChatGPT 中呼叫 MCP 工具 create_checkout
  2. MCP 伺服器呼叫金流、建立 checkout 會話,並在 ToolOutput 中返回訂單資訊與「等待付款」的狀態。
  3. 使用者在 UI 完成付款(Instant Checkout 可直接在 ChatGPT 內完成)。
  4. 金流把 webhook 發送到你的後端。
  5. 後端透過資料庫更新訂單狀態;下一次工具呼叫或模型的追問時,就能如實回覆:「訂單已付款,以下是詳細資訊」。

有時後端會以間接方式發起追問——例如透過 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 本質上是非同步的,付款也可能需要時間。假設一切都會即時完成是個壞主意。更好的做法是設計能與延遲事件和平共處的對話與工具:保存訂單狀態、允許使用者回到對話稍後取得最新資訊。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION