這是 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 就會立即進入預約流程。

該流程包含:

  1. 從付款事件讀取預約中繼資料。
  2. 處理特殊緊急預約。
  3. 建立 Zoom 會議 payload。
  4. 呼叫 Zoom 會議 API。
  5. 在資料庫建立預約紀錄。
  6. 回傳成功回應給付款供應商。

問題在於所有這些操作實際上都與 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 的可靠性不只關乎成功處理事件。

有兩個獨立的問題:

  1. 這個事件是否真實?
  2. 我的應用程式能否安全且可靠地處理它?

兩者都很重要。

修復方案

我重新設計了 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。