これはDEVのSummer Bug Smash: Smash StoriesSentry提供)の投稿です。

支払いwebhookはシンプルに思えますが、支払いが成功しても顧客が支払ったサービスが実際に利用できないことがあります。

それはThe Listening Ear(予約およびオンライン相談プラットフォーム)を構築している際に遭遇した興味深いバグのひとつでした。

要件は明確でした。

顧客がセッションの支払いを行う → アプリケーションが支払いを確認する → 顧客の予約が確定する → 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がこれらすべての操作の真ん中に位置していたことです。

バグ修正とパフォーマンス改善

このバグは、Zoom予約ワークフローを解除する支払いwebhookを実装している際に現れました。

当初の実装は支払いイベントをリッスンし、イベントが次の条件を満たすかをチェックしていました。

if (event === 'charge.success') {

この条件が満たされると、webhookは即座に予約ワークフローに進みました。

そのワークフローには以下が含まれていました。

  1. 支払いイベントから予約メタデータを読み取る。
  2. 特別な緊急予約を処理する。
  3. Zoomミーティングのペイロードを構築する。
  4. ZoomミーティングAPIを呼び出す。
  5. データベースに予約レコードを作成する。
  6. 決済プロバイダに成功レスポンスを返す。

問題は、これらの操作がすべてwebhookリクエストに実質的に結合されていたことです。

支払いwebhookは非同期の外部イベントです。再試行されたり、複数回配信されたり、システムの他の部分がまだ処理中のタイミングで到着する可能性があります。

当初の実装は、これを通常の同期APIリクエストのように扱いすぎていました。

それによりボトルネックが生じました。

支払い確認 → webhook → Zoom API → 予約API → レスポンス

下流の操作のいずれかが遅いか失敗した場合、webhook自体が失敗する可能性がありました。

つまり、決済プロバイダがwebhookを再試行する可能性があります。

その結果、同じ成功した支払いを再度処理してしまう可能性がありました。

それが解決すべき本当のバグでした。

デバッグの旅路

最初の症状は誤解しやすいものでした。

ユーザーは支払いを正常に完了したのに、期待していた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は単に支払いを確認するだけでなく、2つの追加サービスのオーケストレーションレイヤーとしても機能していました。

リトライの問題

ここで問題が特に興味深いものになりました。

webhookエンドポイントは、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の信頼性がイベントの正常な処理だけではないことを思い出させる重要なポイントでした。

2つの別々の質問があります。

  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ミーティングのペイロードの一部となりました。

重要な変更は、単にif文を1つ変えることではありませんでした。

支払い → 予約 → Zoomの関係を明示的かつ予測可能にすることでした。

webhook処理の安全性を高める

最大の教訓は、webhookハンドラは最初からリトライを考慮して設計すべきだということでした。

次のような質問を考える必要がありました。

  • 同じ成功した支払いイベントが2回到着したらどうなるか?
  • 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ハンドラがなぜ欺瞞的に複雑になり得るかのもうひとつの例でした。

単一の支払いイベントが次のようなメタデータを運んでいました。

  • 予約のタイミング
  • 緊急セッションの動作
  • Zoomミーティングの設定
  • 招待者
  • 認証例外
  • トランザクション参照
  • データベースの予約情報

webhookは事実上、アプリケーションのいくつかの独立した部分の橋渡し役となっていました。

それにより、ワークフローを予測可能に保つことがさらに重要になりました。

結果

最終的な目標は明確でした。

Successful payment
        ↓
Reliable payment confirmation
        ↓
Correct appointment state
        ↓
Zoom meeting creation
        ↓
Booked session

Enter fullscreen mode Exit fullscreen mode

支払いwebhookを単なる通知エンドポイントとして扱うのではなく、信頼性の境界として扱うことを学びました。

この視点の変化が最大の改善でした。

学んだこと

このバグは、私がその後のバックエンド作業で心に留めていることを教えてくれました。

外部イベントは、通常の同期関数呼び出しであるかのように扱うべきではない。

webhookはアプリケーションとは異なる世界に存在します。

送信者と受信者は同じ実行コンテキストを共有しません。ネットワークリクエストは失敗する可能性があります。レスポンスはタイムアウトする可能性があります。イベントは再試行される可能性があります。下流のAPIは成功する一方でレスポンスは失敗する可能性があります。そして、同じイベントが複数回配信される可能性があります。

つまり、堅牢なwebhook処理には次のような点を考える必要があります。

  • 認証と署名検証
  • 明示的な支払い状態
  • 非同期配信
  • リトライ
  • 冪等性
  • 重複イベント
  • 部分的な失敗
  • 下流の副作用
  • トランザクション参照

最も重要な教訓は、支払いwebhookを機能させる方法ではありませんでした。

物事が完璧にいかないときにwebhookを取り巻くシステムを予測可能に振る舞わせる方法を学ぶことでした。

そして、それが私が最も喜んで修正したバグでした。