Omitting expires_at Holds a Seat for 24 Hours
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
A Stripe Checkout Session is valid for 24 hours unless expires_at is set, so a booking abandoned without payment keeps occupying capacity for that entire day.
The short version
Create a Stripe Checkout Session without expires_at and Stripe’s default of 24 hours applies. A booking where the user merely left the page without paying stays at payment_status: 'pending' for as long as the Session is valid, and the free-capacity calculation keeps subtracting it exactly like a paid booking. On a workshop booking site this produced an incident — “shown as sold out while seats are actually free” — resolved by shortening it to 30 minutes, the shortest Stripe allows.
What it looks like
On this workshop booking site, when an applicant picks a party size and a session, a row is first created in the bookings table with status: 'pending', and only then is a Stripe Checkout Session issued and the user sent to the card-entry screen.
// 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'])
Free capacity is the sum of participants across bookings whose status is not cancelled and whose payment_status is pending or paid. Which means a booking that got as far as the card-entry screen and then had the browser closed keeps being subtracted from capacity as long as it is not cancelled.
When a few such abandonments piled up on a popular session, it produced reports of seats that should have been free sitting at “sold out” for a full day.
Why
The mechanism that returns bookings.status to cancelled was implemented from the start. When a Checkout Session expires, Stripe sends a checkout.session.expired event, and an existing handler on the webhook (the endpoint that receives event notifications from an external service) cancels the booking on receiving it.
// 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
}
The problem was when the Checkout Session expires. The Session creation code passed no expires_at, and Stripe, given the omission, expires it 24 hours after creation. The release mechanism worked correctly; the wait before it fired was simply too long a value for how this booking site operates.
// app/api/create-checkout-session/route.ts (before the fix)
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [/* ... */],
mode: 'payment',
customer_email: customer_email,
// expires_at not specified → Stripe's default (24 hours) applies
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 },
})
Fixing it
We added a function to lib/stripe.ts returning the Session’s expiry — that is, how long a seat is held.
// lib/stripe.ts
// Unspecified means Stripe's default of 24 hours, which lets a booking abandoned
// without payment fill capacity until the next day, so we shorten it to 30 minutes,
// the minimum Stripe allows.
export const CHECKOUT_HOLD_MINUTES = 30
// Stripe requires "at least 30 minutes from when the request is received", so we
// add a 60-second margin to avoid being rejected for slipping just under 30 on lag.
const CHECKOUT_EXPIRY_BUFFER_SECONDS = 60
export function checkoutExpiresAt(): number {
return (
Math.floor(Date.now() / 1000) +
CHECKOUT_HOLD_MINUTES * 60 +
CHECKOUT_EXPIRY_BUFFER_SECONDS
)
}
The Session creation side just passes that value as expires_at.
// app/api/create-checkout-session/route.ts (after the fix)
expires_at: checkoutExpiresAt(),
The Stripe API returns an error if expires_at is less than 30 minutes after the request is received, so 30 minutes is the shortest this mechanism can specify. The release path (checkout.session.expired → webhook → status: cancelled) is unchanged. Shortening the wait from 24 hours to 30 minutes alone cut the time an abandoned payment blocks capacity to one forty-eighth.
Why it wasn’t noticed
expires_at is a value you cannot notice without reading the Checkout Session creation code, and because “unspecified” is decided by Stripe’s default behaviour, the number 24 hours appears nowhere in this repository. Booking cancellation itself was implemented and working, so the assumption “an abandoned booking eventually gets cancelled” was not wrong. What was missing was any consideration of how many hours “eventually” meant.
よくある質問
Q1What happens to a Checkout Session with no expires_at?
It expires at Stripe's default, 24 hours after creation. Even if the user leaves the page without paying, the Session stays valid for that whole period, so the linked booking's payment_status also stays pending.
Q2Why was 24 hours a problem?
Because the routine computing free capacity subtracted not only paid bookings but pending ones too, unless they were cancelled. Stack up a few bookings abandoned before entering card details and seats that are genuinely free stay shown as "sold out" for a full day.
Q3How short can expires_at be?
Stripe only accepts a time at least 30 minutes after the request is received. The practical minimum is 30 minutes, and we add a 60-second buffer so network lag cannot push it just under 30 and get it rejected.
Q4After shortening to 30 minutes, how is the seat released?
When the Checkout Session expires, Stripe sends a checkout.session.expired event. The existing webhook handler that receives it and sets the booking's status to cancelled was already implemented, so shortening expires_at alone shortened the time to release.
確認した環境
- Next.js ^15.4.10 / stripe ^18.4.0 (apiVersion 2025-07-30.basil)
- Addressed in the fix commit on 2026-07-23
この記事の根拠
- TypeScriptファイル 58〜84行目コミット 468612b
- TypeScriptファイル 1〜23行目コミット 468612b
- TypeScriptファイル 66〜72行目コミット 37b9772
- TypeScriptファイル 332〜349行目
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。