Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Checkout Session の期限を省くと、離脱した予約が24時間分の席を占有する

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Stripe Checkout Session は expires_at を指定しない限り既定で24時間有効になり、決済せず離脱した予約もその24時間のあいだ定員を占有し続ける。

結論

Stripe の Checkout Session 作成時に expires_at を指定しないと、Stripe の既定値である24時間が適用される。 決済せずにページを離脱しただけの予約も、Session が有効なあいだは payment_status: 'pending' のまま残り、定員の空き枠計算からは決済済みの予約と同じように差し引かれ続ける。ワークショップの予約サイトでこれが原因の「実際には空いているのに満席表示のまま」という事故が起き、Stripe が許す最短の30分に短縮することで解決した。

症状

このワークショップ予約サイトでは、申込者が参加人数と開催回を選ぶと、まず bookings テーブルに status: 'pending' の行が作られ、そのあとで Stripe の Checkout Session を発行してカード情報の入力画面へ遷移する。

// app/api/check-availability/route.ts
const { data: sessionBookings } = await supabaseAdmin
  .from('bookings')
  .select('participants')
  .eq('session_id', sessionId)
  .neq('status', 'cancelled')
  .in('payment_status', ['pending', 'paid'])

定員の空き枠は statuscancelled でなく、かつ payment_statuspendingpaid の予約の参加人数を合計して算出する。つまり、カード情報の入力画面まで進みながら決済せずにブラウザを閉じただけの予約も、cancelled になっていない限り定員からそのまま差し引かれ続ける。

人気の開催回でこの種の離脱が数件重なると、実際には空いているはずの席が「満席」表示のまま丸一日動かない、という報告につながった。

原因

bookings.statuscancelled に戻す仕組み自体は最初から実装されていた。Checkout Session が失効すると Stripe は checkout.session.expired イベントを送り、それを受け取って処理するというWebhook(外部サービスからのイベント通知を受けるエンドポイント)の既存ハンドラが、このイベントを受けて予約をキャンセルする。

// app/api/stripe-webhook/route.ts
case 'checkout.session.expired': {
  const session = event.data.object as Stripe.Checkout.Session
  const bookingId = session.metadata?.booking_id
  if (bookingId && supabaseAdmin) {
    await supabaseAdmin
      .from('bookings')
      .update({ status: 'cancelled', payment_status: 'failed' })
      .eq('id', bookingId)
  }
  break
}

問題は「Checkout Session がいつ失効するか」だった。Session の作成コードには expires_at が渡されておらず、Stripe はこれを省略すると作成時点から24時間後に失効させる。解放の仕組みは正しく動いていたのに、それが起動するまでの待ち時間が24時間というこの予約サイトの運用には長すぎる値だった。

// app/api/create-checkout-session/route.ts(修正前)
const session = await stripe.checkout.sessions.create({
  payment_method_types: ['card'],
  line_items: [/* ... */],
  mode: 'payment',
  customer_email: customer_email,
  // expires_at を指定していない → Stripe の既定値(24時間)が適用される
  success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/workshops/${workshop_id}`,
  metadata: { booking_id, workshop_id, coupon_id, discount_amount, early_bird_discount },
})

直し方

lib/stripe.ts に、Session の有効期限(=席を保留しておく時間)を返す関数を追加した。

// lib/stripe.ts
// 未指定だと Stripe デフォルトの24時間になり、決済せず離脱した予約が
// 翌日まで定員を埋め続けるため、Stripe が許す最短の30分に短縮している。
export const CHECKOUT_HOLD_MINUTES = 30

// Stripe は「リクエスト受信時点から30分以上先」を要求するため、
// 通信ラグでちょうど30分を切って弾かれないよう60秒だけ余裕を持たせる。
const CHECKOUT_EXPIRY_BUFFER_SECONDS = 60

export function checkoutExpiresAt(): number {
  return (
    Math.floor(Date.now() / 1000) +
    CHECKOUT_HOLD_MINUTES * 60 +
    CHECKOUT_EXPIRY_BUFFER_SECONDS
  )
}

Session 作成側では、この値を expires_at に渡すだけでよい。

// app/api/create-checkout-session/route.ts(修正後)
expires_at: checkoutExpiresAt(),

Stripe API は expires_at にリクエスト受信時点から30分未満の時刻を渡すとエラーを返すため、30分がこの仕組みで指定できる最短値になる。解放の経路(checkout.session.expired → Webhook → status: cancelled)は変更していない。待ち時間を24時間から30分に縮めただけで、決済放棄が定員を塞ぐ時間は1/48になった。

なぜ気づけなかったか

expires_at は Checkout Session の作成コードを見ない限り気づけない値で、かつ「未指定」は Stripe 側の既定挙動として決まるため、このリポジトリのどこにも「24時間」という数値は書かれていなかった。予約のキャンセル処理自体は実装済みで動作していたので、「離脱した予約はいずれキャンセルされる」という前提は間違っていなかった。抜けていたのは「いずれ」が具体的に何時間を指すかの検討だけだった。

よくある質問

Q1expires_at を指定しないと Checkout Session はどうなりますか?

Stripe の既定値である作成から24時間後に失効します。ユーザーが決済せずにページを離脱しても Session 自体はその間ずっと有効なままなので、紐づく予約の payment_status も pending のまま残り続けます。

Q2なぜ24時間という長さが問題になったのですか?

定員の空き枠を計算する処理が、決済済み(paid)だけでなく決済待ち(pending)の予約もキャンセル扱いでない限り定員から差し引いていたためです。カード情報を入力せず離脱しただけの予約が積み重なると、実際には空いている席が丸一日「満席」表示のまま動かなくなります。

Q3expires_at はどこまで短くできますか?

Stripe はリクエスト受信時点から30分以上先の時刻しか受け付けません。実用上の最短値は30分で、通信ラグで30分をわずかに切って弾かれないよう60秒のバッファを足しています。

Q430分に短縮したあと、席の解放はどう行われますか?

Checkout Session が失効すると Stripe は checkout.session.expired イベントを送ります。既存の Webhook ハンドラがこのイベントを受けて予約の status を cancelled に更新する処理はすでに実装済みだったため、expires_at を短くするだけで解放までの時間が縮まりました。

確認した環境

  • Next.js ^15.4.10 / stripe ^18.4.0(apiVersion 2025-07-30.basil)
  • 2026-07-23 の修正コミットで対応

この記事の根拠

  • TypeScriptファイル 58〜84行目コミット 468612b
  • TypeScriptファイル 1〜23行目コミット 468612b
  • TypeScriptファイル 66〜72行目コミット 37b9772
  • TypeScriptファイル 332〜349行目

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。