穩定幣支付 Webhook:如何防止重複履約
本文說明如何可靠處理穩定幣支付 Webhook:保留原始請求體完成簽名驗證,以事件標識去重,用資料庫約束和冪等鍵避免重複履約,並透過重試、死信與安全重放恢復失敗投遞,同時保留足夠的審計資訊用於排查問題。
可靠的加密支付 Webhook 接入,應把同一事件可能多次到達當作常態。至少一次投遞是正常的可靠性約定:回應超時會觸發重試,故障恢復時運維人員也可能手動重放投遞。因此,端點必須讓重複投遞無害,而不能把它當成異常情況。
持久化模式並不複雜:對收到的精確請求位元組驗籤,原子地記錄事件與業務狀態;只有最終事件才追加不可逆履約的下一步操作,隨後快速確認,再由可恢復的 worker 執行該外部履約。這樣能明確劃分 StableOps 的事件投遞與應用自身業務副作用的邊界。
兩道不同的保護共同防止重複履約
很容易想用一條資料庫唯一規則解決所有問題,但這樣會留下缺口。事件去重保護投遞流;履約冪等保護真實世界中的業務副作用。
| 保護措施 | 鍵與約束方式 | 防止的問題 | 單獨使用為何仍不夠 |
|---|---|---|---|
| 事件去重 | 對 X-Event-Id 建立唯一約束 | 同一 StableOps 事件因重試或重放而重複寫入狀態、重複入隊 | 兩個不同的有效事件仍可能關聯同一個內部訂單 |
| 外部履約冪等 | 以內部業務訂單 ID 為鍵,由履約系統或商戶側持久化記錄約束 | 同一訂單重複發貨、重複開通權益或重複入帳 | 不能保留每個收到事件的完整、可審計記錄 |
兩者都需要。前者回答“這個事件是否已經接受過”,後者回答“這個訂單是否已經產生過不可逆副作用”。更廣泛的請求級模式可見冪等性。
讓 Webhook 處理器短小且可恢復
端點應在返回成功前完成資料庫工作,但不應等待發貨、開通服務或其他遠端副作用。
收到 StableOps 投遞
|
v
讀取原始 body
|
v
驗證 X-Product-Signature
|
v
在一個事務中:持久化唯一事件 ID + 業務狀態;僅 `payment.finalized` 寫入不可逆履約的持久化 outbox
|
v
返回 2xx
|
v
提交後由 worker 僅領取 `payment.finalized` 的 outbox 記錄,並按內部訂單 ID 冪等履約下面是一個精簡的 Next.js Route Handler 示例。recordWebhookEventAndWriteOutbox 是商戶自有的持久化函式,並非 StableOps API 呼叫。它在同一事務內透過唯一事件 ID 與 INSERT ... ON CONFLICT DO NOTHING(或等價機制)判斷是否首次收到事件:每個首次收到且驗籤透過的相關事件都會持久化事件記錄並更新業務狀態;只有已驗籤、已去重的 payment.finalized 會額外寫入不可逆履約的持久化 outbox 記錄。payment.detected、payment.confirmed 及其他事件只更新狀態,或在適用時建立可逆任務,絕不寫入不可逆履約 outbox。重複的 X-Event-Id 是無副作用的 no-op,處理器仍返回 2xx。事務提交後,worker 只領取 payment.finalized 的 outbox 記錄並執行外部副作用。
import { EVENT_ID_HEADER, SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'
export async function POST(req: Request) {
const rawBody = await req.text()
const secrets = [
process.env.STABLEOPS_WEBHOOK_SECRET,
process.env.STABLEOPS_WEBHOOK_SECRET_PREVIOUS,
].filter((secret): secret is string => Boolean(secret))
const verification = verifySignature({
secrets,
header: req.headers.get(SIGNATURE_HEADER) ?? undefined,
rawBody,
})
if (!verification.ok) {
return new Response('invalid signature', { status: 400 })
}
const eventId = req.headers.get(EVENT_ID_HEADER)
if (!eventId) {
return new Response('missing event id', { status: 400 })
}
const event = JSON.parse(rawBody)
// 商戶側持久化:所有首次事件寫入狀態;只有 payment.finalized
// 寫入不可逆履約的持久化 outbox。重複事件 ID 有意保持無副作用。
await recordWebhookEventAndWriteOutbox({ eventId, event })
return new Response(null, { status: 204 })
}用 req.text() 恰好讀取一次 body,並在解析之前驗籤。解析後重新序列化 JSON 可能改變簽名所對應的表示形式。金鑰輪換的 24 小時重疊視窗中,應如示例一樣同時傳入目前金鑰與可選的舊金鑰,並過濾空值,使任一有效簽名都能透過。驗籤與簽名行為詳見Webhook 驗籤,端點運維細節見 Webhook 指南。
為崩潰、重試和重放而設計
已確認的請求不等於履約已經完成。應為每個邊界建模,讓重試仍得到安全結果。
| 失敗或恢復場景 | 會發生什麼 | 安全的恢復行為 |
|---|---|---|
| 程序在事務提交前崩潰 | 沒有持久化事件記錄、業務狀態或(僅最終事件才有的)履約 outbox 任務 | 投遞重試會再次執行事務,並只成功提交一次 |
| 事務已提交,但回應在到達 StableOps 前超時 | 事件與狀態已持久化,最終事件的履約 outbox 也可能已寫入;StableOps 仍可能重試 | 將唯一 X-Event-Id 衝突視為已接受,返回 2xx,不再更新狀態或建立任何任務 |
| worker 在不可逆履約期間崩潰 | 僅 payment.finalized 的 outbox 任務可能在租約或嘗試後仍未完成 | 重試或對帳該任務;按訂單 ID 的冪等保護使外部操作安全 |
| 同一事件被重放 | 重放會為原始事件建立一條 StableOps 投遞審計記錄 | 再次驗籤,由事件 ID 唯一約束走 no-op 路徑 |
| 同一訂單的不同事件併發到達 | 每個事件 ID 不同,因此事件去重允許兩者透過 | 序列化或條件更新訂單,並按內部訂單 ID 保證履約冪等 |
StableOps 將任何 2xx 視為投遞成功。非 2xx、網路錯誤,或 10 秒內沒有回應都視為失敗並重試;最終失敗的投遞會進入 DLQ。不要在持久化接受前返回成功,也不要為了執行慢任務而故意讓請求一直掛起。透過控制台或重放 API 重放會建立新的投遞審計記錄,因此事件級保護仍不可少。
將支付事件視為狀態機
只有已驗籤且已去重的 payment.finalized 可以觸發不可逆履約。payment.detected 與 payment.confirmed 適合展示支付進度、傳送通知或執行刻意設計為可逆的動作,但不是最終證明。收到 payment.reverted 時,應撤銷或停止樂觀處理;收到 payment.expired 時,應關閉未付款嘗試並引導客戶重新開始。payment_order.canceled 是在檢測到轉帳前被取消訂單的終態;若 Webhook 流是你的事實來源,應將訂單標記為已取消並釋放預留資源。
瀏覽器回跳 URL、交易雜湊和未驗籤 Webhook 都可用於使用者體驗或排查,但都不是履約的最終憑據。確認和最終性取決於鏈與業務風險;不要用固定確認數替代已驗證的最終事件。可閱讀確認狀態模型與穩定幣支付確認,瞭解這一生命週期背後的判斷。
生產檢查清單
- 在解析 JSON 前,以原始請求 body 驗證
X-Product-Signature;Webhook secret 只儲存在伺服器端。 - 金鑰輪換的 24 小時重疊期內,同時使用目前金鑰與舊金鑰驗籤;視窗結束後移除舊金鑰。
- 要求並持久化
X-Event-Id,在與業務狀態變更相同的事務中施加資料庫唯一約束;僅已驗籤、已去重的payment.finalized可在該事務中寫入不可逆履約 outbox。 - 僅在已持久化接受後返回任意
2xx;耗時或外部操作交給 worker。 - 每個不可逆履約動作都按內部業務訂單 ID 冪等,而非只按事件 ID。
- 按狀態機持久化各類支付事件;當 Webhook 狀態為權威來源時,將
payment_order.canceled標為取消並釋放預留資源。 - 監控失敗投遞和 DLQ;端點或下游依賴修復後再使用重放。
- 保留事件 ID、投遞 ID、付款訂單引用、內部訂單 ID 與交易雜湊,以便對帳。
- 上線前演練重複投遞、回應超時、事務崩潰、worker 崩潰、重放和同一訂單併發事件。
FAQ
204 回應算 Webhook 成功嗎?
算。StableOps 將任何 2xx 回應視為成功。當處理器已持久化接受事件且無需回應 body 時,204 很合適。
可以直接在 Webhook 請求中履約嗎?
可以,但這會擴大外部副作用周圍的超時與崩潰視窗。通常更安全的是在處理已驗籤、已去重的 payment.finalized 時,在事務中記錄不可逆履約 outbox,再由 worker 執行;其他事件不進入該 outbox。持久化接受後即確認,隨後透過按訂單 ID 的冪等保護獨立重試履約。
重放會建立新的投遞記錄,還會重複履約嗎?
不應如此。新的投遞只是審計記錄,原始事件 ID 才是去重鍵。即使兩個有效事件都指向同一訂單,按內部訂單 ID 的履約冪等性也會阻止外部副作用被執行兩次。
在真正需要前先建好恢復路徑
從 Webhook 指南開始,接入 SDK 驗籤器,並在 Sandbox 測試一次重複投遞和一次 worker 重啟。當處理器能夠安全地接受、恢復並對帳這些場景時,穩定幣支付流程就能在生產規模下可靠履約。
相關文章
穩定幣轉帳並不等於支付完成。本文從付款訂單狀態機、鏈與資產精確匹配、確認和重組處理、介面冪等、Webhook 事件去重及可恢復履約出發,說明如何把鏈上轉帳轉化為可安全進入生產環境、能夠審計並支援異常恢復的支付事件。
穩定幣退款必須作為新的鏈上轉帳單獨處理。本文講解 StableOps 如何校驗最終訂單和剩餘額度,由商戶從原收款地址簽署退款交易,再核對目標地址、資產、金額與最終深度,並透過 Webhook、失敗重試和對帳避免重複退款與帳務遺漏。
本文講解交易平台如何用唯一地址、鏈上最終確定、Webhook 冪等處理與每日對帳建置加密貨幣充值監控,在支援客戶選擇多條網路的同時安全增加內部餘額,妥善處理過期、遲到與異常轉帳,並降低重複記帳、錯誤歸屬和鏈重組造成的真實資金損失。
本文介紹完整的 AI Agent 穩定幣支付架構:買方側用策略、預算、審批和客戶自持簽名限制自動付款,賣方側用付款訂單、鏈上最終性、Webhook 與對帳確認收款,並說明如何處理結算未知、資源交付失敗和審計追蹤。