Rebounder Tech Blog

Written by the people who actually run these systems in production.

Stripe Checkout Session expires_at: 24h Default Holds Seats

Published About 4 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

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.

Frequently asked questions

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.

Environment verified

  • Next.js ^15.4.10 / stripe ^18.4.0 (apiVersion 2025-07-30.basil)
  • Addressed in the fix commit on 2026-07-23

What this article is based on

  • TypeScript file lines 58-84commit 468612b
  • TypeScript file lines 1-23commit 468612b
  • TypeScript file lines 66-72commit 37b9772
  • TypeScript file lines 332-349

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.