跳至內容
2026 年 9 月 4 日.金流支付

拆解剖析:Stripe Webhook 酬載,逐欄位解讀

本文所描述的產品內容以發布當時為準。如需目前功能,請參閱 AI 建構器智慧代理團隊

拆解剖析:Stripe Webhook 酬載,逐欄位解讀

這是一份 Stripe 實際送出的 webhook payload,經過精簡與遮蔽處理,但結構完全未動——一個 checkout.session.completed 事件,也就是有人在 Checkout 頁面完成付款時觸發的那個。大多數開發者都只看過一次,抓出 data.object.customer data.object.amount_total就繼續往下做了。這在展示用途上通常沒問題,但也正是為什麼有些應用程式會重複出貨訂單、錯過訂閱續訂,並收到第一封抱怨退款「沒有生效」的客服信件。讓我們把整個結構拆解開來看看。

{
  "id": "evt_1P8xQ2K7z3n9lWqA00Ff2gLm",
  "object": "event",
  "api_version": "2024-06-20",
  "created": 1725456000,
  "type": "checkout.session.completed",
  "livemode": true,
  "pending_webhooks": 1,
  "request": { "id": "req_9F2mA1", "idempotency_key": null },
  "data": {
    "object": {
      "id": "cs_live_a1B2c3D4",
      "object": "checkout.session",
      "customer": "cus_Q3fZ8xLmN2",
      "customer_details": {
        "email": "[email protected]",
        "tax_ids": []
      },
      "payment_status": "paid",
      "payment_intent": "pi_3P8xQ2K7z3n9lWqA1gH4iJ5k",
      "subscription": "sub_1P8xQaK7z3n9lWqA",
      "amount_total": 2900,
      "currency": "usd",
      "metadata": {
        "app_user_id": "usr_38821",
        "plan": "pro_monthly"
      }
    }
  }
}

id,以及你因此免費繼承來的冪等性(idempotency)問題

evt_1P8xQ2K7z3n9lWqA00Ff2gLm 看起來像雜訊,直到你意識到它是唯一能防止重複訂單的關鍵。Stripe 傳送 webhook 是 至少一次,而不是保證恰好一次。如果你的伺服器接受了請求但在回傳 200 之前逾時,Stripe 會視為失敗並重新傳送同一個事件、同一個 id,有時是幾分鐘後,有時是隔天。如果你的處理程式每次看到 checkout.session.completed 就授予訂閱,卻沒先檢查是否已經處理過這個確切的 id,遲早會授予兩次。修法很簡單,只要在資料庫裡加一行:以此欄位為鍵、在 processed_webhook_events 資料表上建立唯一約束,並在做任何事之前先檢查。這是整個整合裡最不起眼的一行程式碼,卻也是真正重要的一行。

根本不在本文內容裡的簽章標頭

上面的 payload 是 Stripe 傳送的請求主體。它沒有顯示出來的,是隨之附上的 Stripe-Signature 標頭——由時間戳記加上以你的 webhook 簽署密鑰計算出的 HMAC-SHA256 簽章組成。如果跳過驗證,你的 webhook 端點就會變成一個公開的 POST 路由,任何人都能用手動偽造的「付款成功」JSON 資料來免費解鎖你的付費方案。這不是紙上談兵的攻擊——端點網址會外洩在前端 JS、日誌檔、Slack 貼文裡,掃描程式也專門在找這種形狀的路由。

我在正式環境中親眼見過這個錯誤兩次——兩次的修復方式都只花了五分鐘,但兩次都是已經上線好幾個月,才有人發現資料庫裡那些免費的「專業版」帳號。

驗證只需要用 Stripe SDK 加三行程式碼(stripe.webhooks.constructEvent(body, sig, secret)),而且必須作用在原始、未經解析的請求主體上——如果框架的 JSON 中介軟體在你的處理程式看到之前就已經把它解析成物件,簽章檢查會因為逐位元組不一致而失敗,這跟真正的攻擊完全無關。這正是 Stripe 自家論壇上回報最多的「為什麼我的 webhook 一直回傳 400」問題。

data.object.customer 對比 data.object.customer_details

這兩者看起來多餘,但其實不是。 customer 是 Stripe 客戶 ID——穩定、可重複使用,是你應該儲存為外鍵的東西。 customer_details 則是買家當下在結帳表單裡輸入內容的快照——電子郵件、稅務編號,有時還有姓名——即使 customer 為 null 時它也可能存在,這種情況會發生在你沒有要求 Stripe 建立 Customer 物件的一次性 Checkout session 中。如果你的引導流程邏輯讀取 customer 並假設它永遠有值,訪客結帳就會默默把它弄壞。

metadata:你自己實際放進去的那兩個欄位

app_user_id plan 並不是 Stripe 的欄位——它是你在建立 Checkout session 時自行附加的內容。這是整個整合中最重要的設計決策,也因為 Stripe 的快速入門文件沒有多加著墨而很容易被忽略。如果 metadata裡沒有放進你自己的使用者 ID,唯一能把這筆付款對應回資料庫某一列資料的方式就只剩比對電子郵件——而電子郵件會變更、會打錯字,或者屬於代替隊友付款的人。回頭看看,我寫過的每一個處理得不好的 webhook 處理程式,都是那些試圖從 customer_details.email 重建身分,而不是信任自己在三個步驟前就已經設好的 metadata 的那些。

payment_status:paid 並不是唯一的值

很容易誤以為這個事件的存在本身就代表付款成功,但事實並非總是如此—— payment_status 也可能是 unpaid (session 已完成,但像銀行轉帳這類延遲付款方式尚未入帳)或 no_payment_required (全額折扣結帳,或還沒扣款的免費試用)。如果不檢查這個欄位就依照 checkout.session.completed 出貨,等於是在款項尚未真正確認前就先出貨了。對於金額稍大一點的商品,請等待 payment_status: "paid" ,或者更好的做法是以 invoice.paid / payment_intent.succeeded 為出貨依據,而不是這個 session 事件。

事件類型觸發時機該如何處理
checkout.session.completed買家完成 Checkout 表單可以先記錄下來,但在出貨前務必確認 payment_status 在履行之前
payment_intent.succeeded款項實際入帳可安全出貨一次性購買商品的時機點
invoice.paid訂閱發票已付款(首次或續訂)延長使用權限、重設用量計數器
customer.subscription.updated方案變更、數量變更、期末取消切換同步權限,不要假設這代表取消訂閱
charge.refunded你或買家的銀行退回了一筆款項撤銷使用權限,這是最常被忽略的一項

這份 payload 裡沒有的欄位:接下來會發生什麼事

這份 JSON 完全沒告訴你:Stripe 對送達失敗的請求會依退避排程重試最多三天,而且連續失敗次數過多後會停用該端點並發信通知你。這個行為是設定在你的 Dashboard 裡,而不是 payload 裡——大多數開發者都是在端點因為部署改了路徑而默默失效一週後才發現這件事。

72 小時
Stripe 對失敗的 webhook 持續重試多久才放棄

在你接上 webhook 端點的當天,就針對它的成功率設好監控檢查,而不是等到第一次錯過續訂才做。Payload 能告訴你發生了什麼事,卻不會在你的處理程式默默停止監聽時提醒你。

付款
分享XLinkedInFacebookRedditQuoraWhatsAppTelegram電子郵件
← 所有文章