這是 DEV 的 Summer Bug Smash:Smash Stories(由 Sentry 贊助)的投稿。
付款 webhook 聽起來很簡單,但當成功付款卻無法真正提供客戶所付費的服務時,問題就來了。
這是我在開發「The Listening Ear」——一個預約與線上諮詢平台時遇到的其中一個有趣 bug。
需求很簡單:
客戶支付課程費用 → 應用程式確認付款 → 客戶的預約已成立 → 建立 Zoom 會議。
實際情況卻複雜得多。
專案概覽
The Listening Ear 將線上付款與預約排程及 Zoom 線上諮詢結合在一起。
這個應用程式使用了 Next.js 14、TypeScript、Supabase、Prisma、PostgreSQL、Zoom 以及付款供應商 API 等技術。
付款流程特別重要,因為付款確認實質上是後續預約體驗的守門人。
預期的流程如下:
Customer
│
▼
Payment Provider
│
│ webhook
▼
Next.js Webhook
│
├── Verify / interpret payment
│
├── Create Zoom meeting
│
└── Create appointment record
│
▼
Customer receives access to their scheduled session
Enter fullscreen mode Exit fullscreen mode
問題在於 webhook 直接位於所有這些操作的中間。
Bug 修復或效能改善
這個 bug 出現在我實作會觸發 Zoom 排程流程的付款 webhook 時。
我最初的實作會監聽付款事件,並檢查事件是否為:
if (event === 'charge.success') {
一旦滿足該條件,webhook 就會立即進入預約流程。
該流程包含:
- 從付款事件讀取預約中繼資料。
- 處理特殊緊急預約。
- 建立 Zoom 會議 payload。
- 呼叫 Zoom 會議 API。
- 在資料庫建立預約紀錄。
- 回傳成功回應給付款供應商。
問題在於所有這些操作實際上都與 webhook 請求綁在一起。
付款 webhook 是一個非同步的外部事件。它可能會被重試、多次送達,或在系統其他部分仍在處理時到達。
我的實作最初將它視為普通的同步 API 請求來處理。
這造成了瓶頸:
付款確認 → webhook → Zoom API → 預約 API → 回應
如果其中任何一個下游操作變慢或失敗,webhook 本身就可能失敗。
這意味著付款供應商可能會重試 webhook。
然後我可能會再次處理同一筆成功的付款。
這就是我真正需要解決的 bug。
除錯歷程
第一個症狀很容易被誤解。
使用者已成功完成付款,但預期的 Zoom 排程體驗不一定能正確完成。
我最初的直覺自然是去查看 Zoom 整合。
但順著請求回溯,暴露出一個更有趣的依賴關係:
Zoom scheduling
▲
│
Appointment creation
▲
│
Payment webhook
▲
│
Payment provider
Enter fullscreen mode Exit fullscreen mode
Zoom API 不一定是原始問題。
付款 webhook 負責的工作太多了。
我開始從收到的付款事件一路追蹤 webhook 經過的每一個下游操作。
原始實作的相關部分大致如下:
if (event === 'charge.success') {
// ...
const res = await axios.post(
base_url + '/api/create-zoom-meeting',
zoomPayload
);
if (res) {
const bookedSlotRes = await axios.post(
base_url + '/api/appointments',
{
date: bookingInfo?.appointmentDay,
time: `${bookingInfo.start}-${bookingInfo.end}`,
transaction_ref:
jsonData?.data?.tx_ref ||
jsonData?.data?.reference,
duration: bookingInfo?.sessionType
}
);
return NextResponse.json({
message: "Zoom meeting created with booked time slot",
bookedSlot: bookedSlotRes.data,
meeting: res.data?.meeting,
success: true
});
}
}
Enter fullscreen mode Exit fullscreen mode
這是我調查的轉捩點。
webhook 不只是確認付款。
它同時也在為另外兩個服務充當編排層。
重試問題
問題變得特別有趣的地方就在這裡。
webhook endpoint 只有在 Zoom 請求與預約請求都完成後,才會回傳成功的回應。
因此生命週期實際上是:
Payment Provider
│
│ charge.success
▼
Webhook
│
├──────► Zoom API
│ │
│ ▼
│ Success?
│
├──────► Appointments API
│ │
│ ▼
│ Success?
│
▼
HTTP 200
Enter fullscreen mode Exit fullscreen mode
如果在最終回應之前發生錯誤,處理程序就會進入 catch 區塊:
catch (error: any) {
console.log("Webhook Error:", error);
return NextResponse.json({
message: error.message || error || "Error occured handling webhook",
success: false
}, { status: 500 });
}
Enter fullscreen mode Exit fullscreen mode
從應用程式的角度來看,這是合理的錯誤回應。
但從交付 webhook 的付款供應商角度來看,500 可能代表:
「接收系統未能成功處理此事件。請重試。」
而這正是重試變得危險的地方。
重試可能導致應用程式再次嘗試建立 Zoom 會議。
這就引入了從單一付款事件產生重複下游副作用的可能性。
還有另一個問題
在除錯 webhook 的同時,我也重新檢視了實作中的簽章驗證部分。
原始程式碼包含以下內容:
const stringifiedBody = request.body?.toString() as string;
const hash = crypto
.createHmac('sha512', secret_key)
.update(stringifiedBody, 'utf-8')
.digest('hex');
Enter fullscreen mode Exit fullscreen mode
然後將產生的雜湊值與 Paystack 簽章進行比較:
request.headers.get('x-paystack-signature')
然而,請求主體已在這裡被消耗:
const jsonData = await request.json();
這使得原始請求主體的處理出現問題。
更重要的是,實際的簽章檢查已被註解掉:
// if (hash === request.headers.get('x-paystack-signature')) {
// }
Enter fullscreen mode Exit fullscreen mode
這提醒我們,webhook 的可靠性不只關乎成功處理事件。
有兩個獨立的問題:
- 這個事件是否真實?
- 我的應用程式能否安全且可靠地處理它?
兩者都很重要。
修復方案
我重新設計了 webhook 流程,建立了更清晰的職責分離。
第一個原則是:
付款 webhook 應該在下游服務對其採取行動之前,先可靠地建立付款狀態。
我沒有將 charge.success 視為盲目執行整個預約流程的許可,而是將此事件視為需要仔細解讀與處理的狀態轉換。
預約中繼資料繼續提供課程所需資訊:
const bookingInfo: any = jsonData?.data?.metadata;
實作也必須考量特殊預約類型。
例如,緊急預約需要應用程式計算新的時間範圍:
if (bookingInfo.appointmentOption === "emergency") {
const { start, end } =
computeTimeRange(bookingInfo.sessionType, 5);
startTime = start;
bookingInfo.start = start;
bookingInfo.end = end;
}
Enter fullscreen mode Exit fullscreen mode
這些資訊接著成為 Zoom 會議 payload 的一部分。
重要的改變不只是修改一個 if 敘述。
而是讓付款 → 預約 → Zoom 的關係變得明確且可預測。
讓 webhook 處理更安全
最大的教訓是 webhook handler 從一開始就應該考量重試情境。
我必須思考以下問題:
- 如果同一筆成功的付款事件送達兩次會發生什麼事?
- 如果 Zoom 成功但預約建立失敗會發生什麼事?
- 如果預約建立成功但 webhook 回應失敗會發生什麼事?
- 如果供應商在逾時後重試事件會發生什麼事?
- 我如何防止重複建立會議?
- 我如何防止重複建立預約紀錄?
- 什麼時候告訴付款供應商處理成功才是安全的?
這些對 webhook 驅動的系統而言並非罕見的邊緣案例。
它們是正常運作環境的一部分。
因此,最終的做法更謹慎地處理付款狀態、下游操作以及重試行為,而不是將 webhook 視為單次執行的函式。
Zoom 整合只是故事的一半
其中一個最有用的除錯教訓是將可見的症狀與問題的真正來源分開。
使用者體驗看起來像是 Zoom 的問題:
「我付錢了,但我拿不到會議。」
但實際的鏈條是:
Payment
↓
Webhook
↓
Payment state
↓
Booking
↓
Zoom meeting
Enter fullscreen mode Exit fullscreen mode
這意味著我無法只盯著 Zoom 整合就可靠地修復問題。
付款 webhook 是外部付款供應商狀態轉變為內部應用程式狀態的系統邊界。
一旦正確處理了該狀態,後續流程就更容易推理。
也讓我陷入的小細節
webhook 也處理 Zoom 會議的可選受邀者:
if (bookingInfo.invites && bookingInfo.invites !== "") {
const parsedInvites = bookingInfo.invites.split(",");
parsedInvites.map((invitee: string) => {
zoomPayload.settings.meeting_invitees.push({
email: invitee
});
zoomPayload.settings.authentication_exception.push({
name: invitee.split('@')?.[0],
email: invitee
});
});
}
Enter fullscreen mode Exit fullscreen mode
這又是 webhook handler 會變得看似複雜的例子。
單一付款事件攜帶的中繼資料會影響:
- 預約時間;
- 緊急課程行為;
- Zoom 會議設定;
- 受邀者;
- 驗證例外;
- 交易參考;
- 以及資料庫預約資訊。
webhook 實際上成了應用程式幾個獨立部分之間的橋樑。
這使得讓流程保持可預測變得更加重要。
結果
最終目標很簡單:
Successful payment
↓
Reliable payment confirmation
↓
Correct appointment state
↓
Zoom meeting creation
↓
Booked session
Enter fullscreen mode Exit fullscreen mode
我學會了不把付款 webhook 視為簡單的通知 endpoint,而是將它視為可靠性邊界。
這種觀點的轉變是最大的改善。
我學到的教訓
這個 bug 給我帶來了一個後續後端工作中一直記得的教訓:
外部事件永遠不應被視為普通的同步函式呼叫。
webhook 存在於與應用程式不同的世界。
傳送端與接收端不共享相同的執行環境。網路請求可能失敗。回應可能逾時。事件可能被重試。下游 API 可能成功,但你的回應卻失敗。同一個事件也可能被多次送達。
這意味著穩健的 webhook 處理需要思考:
- 驗證與簽章檢查;
- 明確的付款狀態;
- 非同步傳遞;
- 重試;
- 冪等性;
- 重複事件;
- 部分失敗;
- 下游副作用;
- 以及交易參考。
最重要的教訓不是如何讓付款 webhook 運作。
而是學習如何讓 webhook 所在的系統在事情不完美時也能表現得可預測。
而這正是我最樂於解決的 bug。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.