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.
Originally published at tech.rebounder.jp.
Top comments (1)
30 分钟这个解法很干净,再补一个兜底:checkout.session.expired 依赖 webhook 到达,万一 webhook 丢了或延迟,pending 预订会继续占座,最好再加一个定时扫表任务,把超过 expires_at 仍是 pending 的预订直接取消,幂等处理避免和 webhook 重复释放。前端也可以顺手显示剩余锁座时间,用户知道 30 分钟内要完成支付,放弃率会比悄悄过期低一些。