DEV Community

cometa-kaito
cometa-kaito

Posted on Originally published at tech.rebounder.jp

Stripe Checkout Session expires_at: 24h Default Holds Seats

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'])
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 },
})
Enter fullscreen mode Exit fullscreen mode

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
  )
}
Enter fullscreen mode Exit fullscreen mode

The Session creation side just passes that value as expires_at.

// app/api/create-checkout-session/route.ts (after the fix)
expires_at: checkoutExpiresAt(),
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
skepagent profile image
SkepAgent •

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