MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第7回】Webhookで予約を確定する(署名の検証・二重処理の防止・仮予約の期限切れ)

    オンライン予約サービスで「支払いが済んだのに予約が確定していない」「同じ支払いで二重に処理された」といったトラブルは、決済サービスからの通知(Webhook)の受け方で決まります。この回では、Stripe(ストライプ。クレジットカード決済などを提供する海外の決済サービス)から届く「支払いが終わりました」という通知を安全に受け取り、仮予約を確定させるところまでを作ります。

    「支払いが終わったはずなのに、予約が確定しない。逆に、同じ通知が2回届いて二重に処理されたりしないか心配…」

    先に結論です。予約を確定してよいのは、Stripe から届いた署名つきの通知を受けたときだけです。そのうえで、「署名を確かめる」「同じ通知は一度しか処理しない」「金額が合うか確かめる」「期限切れとの競合を決めておく」の4点を押さえれば、小さな予約サービスでも支払いと予約のずれを防げます。

    前回の第6回(Stripeで支払いを受ける)では、予約を「支払い待ち」の仮予約として保存し、Stripe の決済ページ(Checkout)へ利用者を移すところまで作りました。今回はその続きで、支払い結果を受け取って予約を確定します。

    目次

    この回で作るもの:支払い完了の通知を受けて予約を確定する流れ

    Webhook(ウェブフック)とは、Stripe のサーバーから自社のサーバーへ、出来事を知らせるために送られる通知のことです。利用者のブラウザを経由しないので、画面を閉じられても、URL を書き換えられても影響を受けません。流れは次のとおりです。

    順番起きること担当
    1利用者が Stripe のページで支払うStripe
    2Stripe が自社の通知用 URL(/api/stripe/webhook)へ通知を送るStripe → 自社サーバー
    3通知の署名を確かめる。合わなければ拒否する自社サーバー
    4同じ通知を処理済みでないか確かめる自社サーバー+DB
    5金額などを予約と照らし合わせ、仮予約を確定にする自社サーバー+DB
    6成功の応答(2xx)を返す。失敗したら 500 を返し、Stripe に再送してもらう自社サーバー

    使ったバージョンは Next.js 16・Stripe の Node.js ライブラリ 23 です。実際のコードは blogs/it_hacchu/series/next-yoyaku/code/ にあります。

    📰 出典:Stripe ドキュメント「Receive Stripe events in your webhook endpoint」

    仕組みの説明:安全に通知を受ける4つの考え方

    署名:通知が本当に Stripe から来たかを確かめる

    通知を受ける URL は、誰でも外から呼べる状態になります。もし署名を確かめずに受け入れると、第三者が「支払い済み」という偽の通知を送って、お金を払わずに予約を確定できてしまいます。

    Stripe は通知ごとに、本文と時刻から計算した署名を stripe-signature というヘッダーに付けます。自社側は、Webhook用のシークレット(whsec_ で始まる合言葉)で同じ計算をし、一致するかを確かめます。公式のライブラリでは、署名の時刻が5分以上ずれている通知も既定で拒否されます(使い回しの防止)。

    署名の計算には、届いた本文の文字列そのままが必要です。JSON に直したあとの値では計算が合わなくなるため、後ほどのコードでも本文は文字列のまま読みます。

    二重処理の防止:同じ通知は何度でも届きうる

    Stripe は、自社のサーバーが 2xx 以外を返したり、応答が遅れたりすると、通知を再送します(本番環境では最大3日間、間隔を広げながら再送。テスト環境では数時間に3回)。また、まれに同じ通知が重ねて届くこともあります。通知の順番も保証されません。

    そこで、処理した通知の ID(evt_ で始まる Stripe のイベントID)を表に記録し、すでにあるものは何もしないようにします。

    📰 出典:Stripe ドキュメント「Handle duplicate events」(Webhooks のベストプラクティス)

    照合:通知の内容が予約と合うか確かめる

    署名が正しくても、通知の内容をそのまま信じるのは避けます。今回は次の3つを予約と照らし合わせ、1つでも合わなければ確定せずログに残します。

    • 通知に含まれる決済ページの ID が、予約に結びつけた ID と同じか(第6回で保存した値)
    • 支払われた金額が、予約時にサーバーが計算した金額と同じか
    • 支払い状態が「支払い済み(paid)」か(コンビニ払いのように、決済ページの完了後に入金が遅れる方法では、この時点で未払いのことがある)

    期限切れとの競合:支払いが期限より遅れて届いたら

    第6回で、仮予約の期限(35分)が切れると枠が解放されるようにしました。ところが、支払いの通知が期限より後に届くことがあります。この場合の扱いを決めておかないと、お金だけ受け取って予約がない、という最悪の状態になります。今回は次のルールにしました。

    状況扱い
    期限内に通知が届いた確定にする
    期限切れのあとに届いたが、枠はまだ空いている確定に戻す
    期限切れのあとに届き、枠を別の人が取っていた確定にせず、「支払い済み」の印だけ残して返金対象にする(返金は次回)

    最後の判断を人の目に頼らず、第4回で作った排他制約(同じ時間の予約を DB が拒否する仕組み)にそのまま任せるのがポイントです。確定に戻す更新が制約違反になれば、枠が埋まっているとわかります。

    実装

    1. 通知の記録用の表と、支払い日時の列を足す

    lib/schema.ts に、処理した通知の表と、予約の支払い日時の列を追加しました。マイグレーションは drizzle/0006_webhook.sql です。

    // lib/schema.ts(抜粋)
    export const stripeEvents = pgTable("stripe_events", {
      id: text("id").primaryKey(), // Stripe のイベント ID(evt_...)
      type: text("type").notNull(),
      processedAt: timestamp("processed_at", { withTimezone: true }).notNull().defaultNow(),
    });

    イベントIDを主キーにすることで、同じIDを2回入れようとすると DB が拒否します。「処理済みかを調べてから記録する」と2段階にするより、1回の挿入で決まるので、ほぼ同時に同じ通知が届いても二重に処理されません。

    2. 署名を確かめる

    署名の確認は lib/webhook.ts の小さな関数にまとめました。Stripe の窓口は引数で受け取るので、テストでは通信なしで確かめられます。

    // lib/webhook.ts(抜粋)
    export function verifyStripeEvent(stripe: Pick<Stripe, "webhooks">, rawBody: string, signature: string | null, secret: string | undefined): Stripe.Event {
      if (!secret) throw new WebhookNotConfiguredError();
      if (!signature) throw new Error("stripe-signature ヘッダーがありません");
      return stripe.webhooks.constructEvent(rawBody, signature, secret);
    }

    3. 通知を受ける入口(Route Handler)

    Next.js では、app/api/stripe/webhook/route.ts のように route.ts を置くと、画面ではなく API の入口を作れます(Route Handler)。

    // app/api/stripe/webhook/route.ts(抜粋)
    export async function POST(request: Request) {
      // 署名の計算には「届いた本文そのまま」が必要。request.json() で読むと文字列が変わり、署名が合わなくなる
      const rawBody = await request.text();
    
      let event;
      try {
        event = verifyStripeEvent(getStripe(), rawBody, request.headers.get("stripe-signature"), process.env.STRIPE_WEBHOOK_SECRET);
      } catch (error) {
        console.error("Stripe の通知の署名を確認できませんでした", error instanceof Error ? error.message : error);
        return Response.json({ error: "invalid signature" }, { status: 400 });
      }
    
      try {
        const outcome = await handleStripeEvent(event);
        if (outcome === "slot_lost" || outcome === "mismatch") {
          console.error(`要確認: ${event.id}(${event.type})の処理結果は ${outcome} です`);
        }
        return Response.json({ received: true, outcome });
      } catch (error) {
        // 2xx 以外を返すと Stripe が時間をおいて再送する。DB の一時的な不調ならここで拾い直せる
        console.error("Stripe の通知の処理に失敗しました", error);
        return Response.json({ error: "processing failed" }, { status: 500 });
      }
    }

    応答の返し分けも大切です。署名が不正なら 400(再送されても同じ結果なので拒否でよい)、こちらの処理が失敗したら 500(Stripe に再送してもらい、あとで成功させる)、成功なら 200 です。

    4. 通知を処理する:記録と確定を同じトランザクションで

    handleStripeEvent は、通知の記録と予約の更新を 同じトランザクション(途中で失敗したら全部なかったことにする単位)に入れます。更新の途中で失敗すれば、記録も取り消されるので、再送された通知をもう一度正しく処理できます。

    // lib/webhook.ts(抜粋)
    export async function handleStripeEvent(event: Stripe.Event, now: Date = new Date()): Promise<WebhookOutcome> {
      return getDb().transaction(async (tx) => {
        // 先にイベント ID を記録する。すでにあれば(二重に届いた)何も返らず、ここで終わる
        const inserted = await tx.insert(stripeEvents).values({ id: event.id, type: event.type }).onConflictDoNothing().returning({ id: stripeEvents.id });
        if (inserted.length === 0) return "duplicate";
    
        switch (event.type) {
          case "checkout.session.completed":
          case "checkout.session.async_payment_succeeded":
            return confirmPaidSession(tx, event.data.object, now);
          case "checkout.session.expired":
            return expireSession(tx, event.data.object);
          default:
            return "ignored";
        }
      });
    }

    扱う通知は3種類だけです。checkout.session.completed(決済ページでの手続き完了)、checkout.session.async_payment_succeeded(入金が遅れる支払い方法での入金確認)、checkout.session.expired(決済ページの期限切れ)。必要な種類だけを扱い、それ以外は記録して無視します。

    📰 出典:Stripe ドキュメント「Fulfill orders」(Checkout の注文処理)

    確定の本体は次のとおりです。

    // lib/webhook.ts(抜粋)
    async function confirmPaidSession(tx: Tx, session: Stripe.Checkout.Session, now: Date): Promise<WebhookOutcome> {
      // 画面に戻ってきた時点ではなく、Stripe が「支払い済み」と伝えたときだけ確定する
      if (session.payment_status !== "paid") return "unpaid";
    
      const id = Number(session.client_reference_id);
      if (!Number.isInteger(id)) return "not_found";
      const [r] = await tx.select().from(reservations).where(eq(reservations.id, id)).limit(1);
      if (!r) return "not_found";
    
      // 予約に結びつけた決済ページと一致し、請求した金額が支払われていることを確かめる
      if (r.stripeSessionId !== session.id || session.amount_total !== r.totalYen) return "mismatch";
    
      if (r.status === "confirmed") return "already_confirmed";
    
      if (r.status === "pending") {
        await tx.update(reservations).set({ status: "confirmed", paidAt: now }).where(and(eq(reservations.id, id), eq(reservations.status, "pending")));
        return "confirmed";
      }
      // …(期限切れ後の扱いは次の項)
    }

    client_reference_id は、第6回で決済ページを作るときに「どの予約の支払いか」を持たせておいた値です。更新の条件にも status = 'pending' を付けているので、別の処理が先に状態を変えていても上書きしません。

    5. 期限切れのあとに支払いが届いたとき

    期限切れの予約に支払いが届いた場合は、確定に戻せるかを DB に試させます。

    // lib/webhook.ts(抜粋)
      if (r.status === "expired") {
        // お金は入ったが仮予約の期限が先に切れていた。枠が空いていれば確定に戻し、他の人が取っていれば排他制約が止める。
        // 失敗しても外側のトランザクション全体を壊さないよう、SAVEPOINT(入れ子のトランザクション)の中で試す
        try {
          await tx.transaction(async (inner) => {
            await inner.update(reservations).set({ status: "confirmed", paidAt: now }).where(eq(reservations.id, id));
          });
          return "revived";
        } catch (error) {
          if (!isExclusionViolation(error)) throw error;
          await tx.update(reservations).set({ paidAt: now }).where(eq(reservations.id, id)); // 支払いの事実だけ残す(返金対象の目印)
          return "slot_lost";
        }
      }

    PostgreSQL は、エラーが起きたトランザクションをそのまま続けられません。そのため、失敗するかもしれない更新だけを入れ子のトランザクション(SAVEPOINT)の中で試し、制約違反なら支払い日時だけを残します。「支払い済みなのに確定していない予約」は、countPaidButNotConfirmed() で数えられます。運用では、この件数が0でないときに通知が飛ぶようにしておくと、返金漏れを防げます。

    6. 仮予約の期限切れを定期的に片付ける

    第6回では、期限切れの仮予約を「予約を保存する直前」にだけ片付けていました。今回は、全スペース分をまとめて片付ける入口 app/api/cron/expire-holds/route.ts を足しました。外部のスケジューラー(Vercel Cron など)から定期的に呼ぶ想定です。

    // app/api/cron/expire-holds/route.ts(抜粋)
    export async function GET(request: Request) {
      const secret = process.env.CRON_SECRET;
      if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
        return Response.json({ error: "unauthorized" }, { status: 401 });
      }
      const expired = await expireStaleHolds();
      return Response.json({ expired });
    }

    誰でも呼べる URL なので、合言葉(CRON_SECRET)を持たない呼び出しは 401 で断ります。実際に定期実行へ登録する作業は、公開を扱う第11回で行います。なお、空き状況の表示は期限を見て除外しているので、片付けが遅れても二重予約にはなりません(第6回で確認済み)。

    動作確認の方法

    自動テスト

    tests/webhook.test.ts に16件のテストを足しました(DB に接続する部分は、DATABASE_URL があるときだけ実行されます)。

    • 署名: 正しい署名は通る。本文が1文字違う・別のシークレット・1時間前の署名・ヘッダーなし・シークレット未設定は拒否される
    • 確定: 支払い済みの通知で確定し、支払い日時が残る
    • 二重処理: 同じイベントIDが2回届いても、2回目は何もしない(支払い日時も上書きされない)
    • 照合: 金額が違う通知・別の決済ページの通知・未払いの通知では確定しない
    • 期限切れ: 決済ページの期限切れの通知で枠が空く。期限切れのあとに支払いが届いたとき、枠が空いていれば確定に戻り、埋まっていれば確定せず印だけ残る

    検証コマンドの結果は、npm run lint・npx tsc --noEmit・npm run build が成功、npm test が75件すべて成功でした(PostgreSQL 16 に接続して実行)。

    開発環境での HTTP の確認

    本番ビルドを起動し、署名つきの通知を実際に HTTP で送って確かめました(開発環境での確認です)。

    • 不正な署名 → 400
    • 正しい署名 → 200(処理結果は confirmed。DB の状態が confirmed になり、支払い日時が入る)
    • 同じ通知をもう一度 → 200(処理結果は duplicate)
    • 合言葉なしで定期実行の入口を呼ぶ → 401、合言葉つき → 200

    Stripe 本体との結合は未確認

    この環境には有効な Stripe のテスト用の鍵がなく、実際の Stripe からの通知の受信は未確認です。ここまでの確認は、署名を含む通知を自前で作って送ったものです。お手元で試すときは、Stripe CLI の stripe listen --forward-to localhost:3000/api/stripe/webhook を動かし、表示される whsec_ で始まる値を .env の STRIPE_WEBHOOK_SECRET に入れてください。テスト用のカード番号(4242 で始まるもの)で支払うと、通知が届いて予約が確定するはずです。

    📰 出典:Stripe ドキュメント「テスト」

    つまずきやすい点・セキュリティ上の注意

    • 本文は request.text() で読む:request.json() で読んで文字列に戻すと、空白や並び順が変わり、署名が合わなくなります。
    • シークレットを間違えやすい:Stripe CLI が表示する値と、ダッシュボードで登録した通知先の値は別物です。環境ごとに別のシークレットになります。
    • 通知の処理を重くしない:Stripe は応答が遅いと再送します。今回は予約の更新だけの軽い処理です。確認メールの送信など時間のかかる処理は、次回以降は別の仕組みに任せる前提で作ります。
    • 確定したことを画面の表示だけで判断しない:完了画面は、通知が届く前に表示されることがあります。そのため今回は「数秒かかることがあります。読み込み直してください」と案内しました(自動更新は省いています)。
    • 本番運用で必要だが省略したこと:通知の失敗を知らせる監視、通知用 URL の IP 制限、通知が長期間届かなかった場合の照合(Stripe の API で支払い状況を引き直す処理)。

    発注者向けメモ:支払いと予約のずれを防ぐために、開発会社に確認したいこと

    支払いの確認は、画面に見えない部分だからこそ、作り方で品質の差が出ます。次の点を、見積もりや設計の段階で確認すると安心です。

    • ☐ 予約の確定を、決済サービスからの署名つき通知で行っているか(完了画面に戻っただけで確定していないか)
    • ☐ 同じ通知が2回届いても、二重に処理されない作りか
    • ☐ 通知の処理が失敗したとき、再送で復旧できるか。復旧できなかった分に気づく仕組み(アラート)があるか
    • ☐ 支払い済みなのに予約が確定していない状態(期限切れとの競合など)が起きたとき、誰が・どう返金するか決まっているか
    • ☐ 支払いの金額が、予約の金額と一致しているかを確かめているか
    • ☐ 決済サービスのテスト環境で、成功・失敗・期限切れ・再送のパターンを一緒に確認できるか

    開発会社への質問例です。

    • 「支払いの通知が届かなかった場合、予約はどうなりますか? 気づく仕組みはありますか?」
    • 「支払い後に予約の枠が埋まっていた場合の対応(返金の流れ)は、設計に入っていますか?」
    • 「決済サービスから届く通知の履歴は、あとから調べられるように残りますか?」

    工数やリスクが増える条件は、コンビニ払いや銀行振込など入金が遅れる支払い方法の追加、複数の決済サービスの併用、返金・一部返金の自動化、売上の会計システムへの連携などです。これらは別途見積もりが必要な範囲として、最初に「カードの1回払いのみ」で始めるのが堅実です。

    まとめと次回予告

    この回では、Stripe からの通知(Webhook)で仮予約を確定する仕組みを作りました。

    • 通知は署名で本物かを確かめ、本文は文字列のまま読む
    • イベントIDを DB に記録して、同じ通知は一度しか処理しない
    • 通知の金額・決済ページIDを予約と照らし合わせ、合わなければ確定しない
    • 期限切れのあとに届いた支払いは、排他制約に判断を任せて、確定に戻すか返金対象にする
    • 期限切れの仮予約を片付ける入口を、合言葉つきで用意する

    次回の【第8回】は「予約確認メールとキャンセル・返金」です。確定した予約の確認メールを送り、キャンセル規定にもとづいて返金を行う流れと、今回の「返金対象」の扱いを作ります。前の回は第6回 Stripeで支払いを受けるです。

    この連載の記事一覧

    この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      目次