A Stripe Checkout Session that expires before payment occurs when the session’s lifecycle window closes before the customer completes the transaction. By default, Stripe Checkout sessions remain valid for 24 hours unless your application specifies a custom expiration timestamp (expires_at) or a backend process cancels the session. When customers encounter an “Expired” page instead of the payment form, it indicates that the session creation logic passed an aggressive expiration time, the checkout URL was cached and reused, or a server-side routine prematurely triggered an expiration call.
Why does a Stripe Checkout Session expire before payment is made?
A Stripe Checkout Session expires before payment when the session reaches its configured
expires_attimestamp (which can be set between 30 minutes and 24 hours), when a backend worker calls the session expire API endpoint, or when an application reuses an old checkout URL from cache rather than generating a fresh session on demand.
Diagnostic Snapshot: Stripe Checkout Session Expiration
| Parameter | Diagnostic Value |
|---|---|
| Error Type | API Client / Session State (status: "expired") |
| Trigger Event | Webhook checkout.session.expired or client redirect to expired URL |
| Scope | E-commerce checkout, invoice payments, or subscription signups |
| Common Origin | Timestamp mismatch (seconds vs. milliseconds), aggressive cart timeouts, or URL caching |
| Access Required | Stripe Dashboard (Developer Logs) and Backend Codebase |
Diagnostic Path: Isolate the Expiration Source
Customer sees "Session Expired"
│
├──► Check Stripe Dashboard Logs (Developers > Logs > /v1/checkout/sessions)
│ ├── Session created recently (< 30 min)?
│ │ └── Check creation payload for 'expires_at'
│ │ ├── Passed in milliseconds? ──► Timestamps in ms evaluate to the past
│ │ └── Passed short TTL? ──────► Window too short for customer completion
│ │
│ └── Session created > 24 hours ago?
│ └── Application is caching or recycling static checkout URLs
│
└──► Check API Calls for /v1/checkout/sessions/:id/expire
└── Backend cron or inventory lock job is actively terminating the session
What Happens When a Checkout Session Expires
When your backend calls POST /v1/checkout/sessions, Stripe generates a hosted checkout instance referenced by a session identifier (cs_test_... or cs_live_...). Stripe assigns this session a status of open.
A session transitions to expired through one of three pathways:
- Natural Lifetime Expiration: The session reaches its default 24-hour limit without a successful payment authorization.
- Custom Expiration Threshold: The creation payload included an
expires_atparameter. Stripe enforces a minimum duration of 30 minutes and a maximum of 24 hours from session creation. - Explicit API Cancellation: Your application server sends an explicit request to
POST /v1/checkout/sessions/{SESSION_ID}/expire.
Once a session enters the expired state, it is immutable. It cannot be reopened, recharged, or updated. Any customer visiting the hosted URL will see a localized message informing them that the checkout link has expired. Stripe immediately fires the checkout.session.expired webhook event. If unhandled, this can leave abandoned carts or unreserved inventory hanging in your database.
Primary Causes of Premature Session Expiration
1. Timestamp Format Error (Milliseconds vs. Seconds)
The Stripe API strictly accepts Unix timestamps formatted in seconds. A frequent developer mistake in JavaScript and TypeScript environments is passing the output of Date.now(), which evaluates in milliseconds.
When you pass a 13-digit millisecond value instead of a 10-digit second value, Stripe rejects the request with an API error. However, if your codebase performs internal calculations like Math.floor(Date.now() / 1000) + expirationOffset, but the offset variable was defined in milliseconds (for instance, 15 * 60 * 1000 instead of 15 * 60), the resulting value projects hundreds of days into the future. Stripe rejects values exceeding 24 hours. Conversely, passing a miscalculated offset can evaluate to a timestamp in the past or right at the 30-minute minimum limit, causing the session to expire almost immediately after landing on the page.
2. URL Caching and Static Link Re-use
If your frontend framework or content management system caches server responses, it may cache the session.url returned by Stripe.
When User A clicks “Proceed to Checkout,” a session is created. If that generated URL is cached at the edge (such as via Cloudflare, Varnish, or Redis) and subsequently served to User B hours later, User B inherits User A’s aging session. If User B takes another hour to finish their transaction, the total elapsed time easily surpasses the session validity window.
Checkout URLs should always be generated ephemerally per checkout intent and never saved to persistent static storage for public reuse.
3. Aggressive Inventory Lock Timers
Many platforms implement stock reservation logic (e.g., holding a concert ticket or a limited-edition physical item for 15 minutes). Because Stripe Checkout enforces a minimum session duration of 30 minutes, teams often create a session and run an independent background worker that issues an expire request:
POST /v1/checkout/sessions/cs_test_.../expire
If the background job fires while the customer is still typing their card details or completing Multi-Factor Authentication (3D Secure), the session collapses mid-transaction. If this occurs frequently, you may also see issues like a Fix: Stripe “Post-payment” redirect loop to Success Page when payments collide with session timeouts.
Step-by-Step Resolution
Step 1: Inspect the Session Payload in the Stripe Dashboard
- Open the Stripe Dashboard and navigate to Developers > Logs.
- Filter the requests by path:
/v1/checkout/sessions. - Locate the
POSTrequest corresponding to the failed transaction. - Review the Request Body:
- Verify if
expires_atwas passed. - If present, calculate the difference between
createdandexpires_at:Duration in Minutes = (expires_at - created) / 60
- Verify if
- If
Duration in Minutesis 30 (the absolute API minimum), determine whether real-world buyers need more time to finish authentication and address verification. Increase the duration to at least 60 to 120 minutes.
Step 2: Correct Unix Epoch Calculations
Ensure your backend creates valid Unix timestamps in seconds.
Node.js / JavaScript Example:
// Correct: 2 hours from current time in seconds
const expiresInSeconds = Math.floor(Date.now() / 1000) + (2 * 60 * 60);
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: items,
mode: 'payment',
success_url: '<https://example.com/success>',
cancel_url: '<https://example.com/cancel>',
expires_at: expiresInSeconds,
});
Python Example:
import time
# Correct: 1 hour from current time in seconds
expires_in_seconds = int(time.time()) + (3600)
session = stripe.checkout.Session.create(
payment_method_types=['card'],
line_items=items,
mode='payment',
success_url='<https://example.com/success>',
cancel_url='<https://example.com/cancel>',
expires_at=expires_in_seconds,
)
Step 3: Implement Idempotent Session Generation on User Action
Do not generate the Stripe Checkout Session on cart page load. Generate it strictly when the user clicks the explicit “Pay” or “Checkout” button.
To prevent duplicate sessions if the customer clicks rapidly, use idempotency keys. If you encounter duplicate entries or billing collisions, refer to Fix: Stripe “Idempotency Key” reuse causing Duplicate Charges.
Step 4: Handle the checkout.session.expired Webhook
Ensure your application responds gracefully to expiration events.
- In your webhook endpoint, listen for
checkout.session.expired. - Inspect
event.data.objectto retrieve theidandmetadata. - Release any temporary database locks on inventory.
- Mark the internal cart or order status as
abandoned. - If the webhook listener fails or times out, you can troubleshoot server responses with Troubleshooting: Stripe Webhook “500 Internal Server Error”.
switch (event.type) {
case 'checkout.session.expired':
const session = event.data.object;
await releaseReservedInventory(session.metadata.order_id);
await updateOrderStatus(session.metadata.order_id, 'expired');
break;
}
When the Issue Persists: 3DS Authentication Delays
If a customer takes a long time during card issuer verification, their session might expire while waiting for SMS codes or biometric approval. When strong customer authentication is required, review Fix: Stripe “Payment Intent” stuck in “Requires Action” status to ensure your flow handles prolonged confirmation steps properly.