オンライン予約サービスで「支払いが済んだのに予約が確定していない」「同じ支払いで二重に処理された」といったトラブルは、決済サービスからの通知(Webhook)の受け方で決まります。この回では、Stripe(ストライプ。クレジットカード決済などを提供する海外の決済サービス)から届く「支払いが終わりました」という通知を安全に受け取り、仮予約を確定させるところまでを作ります。
「支払いが終わったはずなのに、予約が確定しない。逆に、同じ通知が2回届いて二重に処理されたりしないか心配…」
先に結論です。予約を確定してよいのは、Stripe から届いた署名つきの通知を受けたときだけです。そのうえで、「署名を確かめる」「同じ通知は一度しか処理しない」「金額が合うか確かめる」「期限切れとの競合を決めておく」の4点を押さえれば、小さな予約サービスでも支払いと予約のずれを防げます。
前回の第6回(Stripeで支払いを受ける)では、予約を「支払い待ち」の仮予約として保存し、Stripe の決済ページ(Checkout)へ利用者を移すところまで作りました。今回はその続きで、支払い結果を受け取って予約を確定します。
この回で作るもの:支払い完了の通知を受けて予約を確定する流れ
Webhook(ウェブフック)とは、Stripe のサーバーから自社のサーバーへ、出来事を知らせるために送られる通知のことです。利用者のブラウザを経由しないので、画面を閉じられても、URL を書き換えられても影響を受けません。流れは次のとおりです。
| 順番 | 起きること | 担当 |
|---|---|---|
| 1 | 利用者が Stripe のページで支払う | Stripe |
| 2 | Stripe が自社の通知用 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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Next.jsで作るオンライン予約サービス 第0回】完成像とNext.js 16の雛形を動かす(App Router・PostgreSQL・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)
- 【Next.jsで作るオンライン予約サービス 第2回】空き状況カレンダーを作る(時間枠・日本時間・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第3回】予約フォームをServer Actionsで作る(Zodによる入力チェックとエラー表示)
- 【Next.jsで作るオンライン予約サービス 第4回】ダブルブッキングを防ぐ(PostgreSQLの排他制約と同時アクセスのテスト)
- 【Next.jsで作るオンライン予約サービス 第5回】会員ログインを付ける(メールのリンクで入る方式と予約履歴)
- 【Next.jsで作るオンライン予約サービス 第6回】Stripeで支払いを受ける(Checkoutとサーバー側で決める金額)
- 【Next.jsで作るオンライン予約サービス 第7回】Webhookで予約を確定する(署名の検証・二重処理の防止・仮予約の期限切れ)(この記事)









