Diagnostic Stripe
most Checkout Sessions expire unpaid and nobody is told
Sessions are being created at a healthy rate and the revenue line is flat. There is no error, no failed payment, no declined card — the sessions simply stop existing. Stripe does emit an event when one lapses, exactly 24 hours after it was created, and almost nobody is subscribed to it.
Page GET /v1/checkout/sessions?created[gte]=<now-30d> and tally status. The number you want is count(status == "expired") / count(all). Past 0.5 more than half of everything you create is being discarded; past 0.25 it is worth a look. Separately, count sessions where status == "open" and expires_at is already in the past — those have lapsed and have not been marked yet.
The repair is two things. Set expires_at at creation (minimum 30 minutes, maximum 24 hours) so a lapse surfaces in hours instead of a day, and subscribe an event destination to checkout.session.expired so the lapse is recorded somewhere you look.
The problem in plain words
The shape of this is what makes it survive. Every individual session that expires looks like a customer who changed their mind, which is a normal thing that happens. There is no threshold at which Stripe tells you the aggregate has moved, because Stripe has no idea what your normal is. So the abandonment rate can drift from a third to two thirds over a quarter and the only visible symptom is that revenue is not growing the way session volume says it should.
Then there is the timing. A Checkout Session with no explicit expires_at lives for 24 hours. That means the event that marks it abandoned fires a full day after the customer left, long after they have gone somewhere else. Even teams that do handle checkout.session.expired are reacting to yesterday's decision, and teams that never subscribed to it — which is most of them, because the event is not on the list anybody starts with — have no record of the lapse at all.
Why it happens
Nothing errors, so nothing pages. An expired session is a successful outcome as far as the API is concerned. The object is created, it is served, it reaches its expires_at, it moves to expired. Every step returned 200. There is no failed state anywhere in the sequence for an alert to hang off.
The default window is a day, and a day is much longer than a decision. A customer who abandons a cart abandons it in the first few minutes. The remaining 23 hours and 55 minutes are pure lag between the event and your knowledge of it, and during that time the session sits in open where a naive count reads it as still in progress.
The event nobody subscribes to. Integrations start from checkout.session.completed because that is the one that makes money appear. checkout.session.expired has no immediate consequence, so it never makes it onto the endpoint, and the abandonment metric that would have existed for free is simply never collected.
The dashboard number is not the same number. Stripe's conversion reporting is built around sessions that were shown to a customer. A session created by a bot, a health check, or a page that renders a Checkout button and never redirects still counts in your API tally. That gap is fine as long as you know it exists, and it is the reason to measure the trend rather than agonise over the absolute value.
The fix, as a flow
The script tallies status across a fixed window and counts the sessions that have already lapsed but are still marked open, because those are the ones a naive count reads as still in progress.
How to fix it
Tally status across a fixed window
Paginate GET /v1/checkout/sessions?created[gte]=<unix>&limit=100 and count open, complete and expired. Use the same window every time. The share only means something as a series, and changing the window changes the number more than any fix will.
Count the sessions that lapsed but are still marked open
A session with status == "open" and expires_at in the past is already gone; the status just has not caught up. Counting these separately stops you from reporting an abandonment rate that is quietly optimistic by a day's worth of traffic.
Shorten expires_at at creation
expires_at accepts anything from 30 minutes to 24 hours after creation. Two hours is a reasonable default for a cart: long enough for someone to fetch their wallet, short enough that the expiry event arrives while a recovery email still makes sense.
Subscribe to checkout.session.expired
Add it to the event destination alongside checkout.session.completed. Write the lapse to your own store with the session id and customer_details.email. That single row is what turns this from a quarterly spreadsheet exercise into a number on a dashboard.
Re-measure after the window changes, not before
Shortening expires_at moves sessions out of open and into expired faster, so the measured share goes up the week you deploy it. That is the metric getting more honest, not the funnel getting worse. Compare against the window after it settles.
How to check it worked
Re-run the script over the same window. The share should be reported with no lapsed-but-open sessions left behind it.
python3 stripe_checkout_abandonment.py --days 30
# normal 118 of 640 session(s) expired unpaid (18.4%).
The full code
One paginated GET against /v1/checkout/sessions and nothing else — a restricted key with read access to Checkout Sessions is enough, and is what you should give it. The classification is a pure function of three integers, because the interesting part of this check is where the thresholds sit and that is exactly the part a network call would make untestable.
"""Report the share of Stripe Checkout Sessions that expire unpaid.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Checkout Sessions. The repair is printed, never performed, because this
script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_checkout_abandonment")
API = "https://api.stripe.com/v1"
HIGH_SHARE = 0.5 # more than half of everything created is thrown away
WATCH_SHARE = 0.25 # worth looking at before it becomes the first number
def verdict(total, expired, lapsed_open=0):
"""Classify one window of Checkout Sessions. Pure, so the thresholds are testable.
`total` is every session created in the window, `expired` the ones Stripe has
already marked expired, and `lapsed_open` the ones still marked open whose
expires_at is in the past. Returns (state, detail).
"""
if not total:
return ("no-data", "no Checkout Sessions were created in the window")
share = expired / float(total)
pct = 100.0 * share
if share >= HIGH_SHARE:
return ("abandoned",
"%d of %d session(s) expired unpaid (%.1f%%). More than half of "
"everything created is being discarded." % (expired, total, pct))
if lapsed_open:
return ("lapsed",
"%d open session(s) are already past expires_at and have not been "
"marked yet; %.1f%% expired so far." % (lapsed_open, pct))
if share >= WATCH_SHARE:
return ("elevated",
"%d of %d session(s) expired unpaid (%.1f%%). Shorten the window "
"so the lapse is visible in hours." % (expired, total, pct))
return ("normal",
"%d of %d session(s) expired unpaid (%.1f%%)." % (expired, total, pct))
def get(session, path, params=None):
r = session.get(API + path, params=params or {}, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def scan(session, since, cap):
"""Tally status across every session created since `since`.
Stripe returns newest first; the order does not matter for a tally, but the
pagination does, because a single page of 100 on a busy account is a sample
rather than a rate.
"""
counts = {"open": 0, "complete": 0, "expired": 0}
total = 0
lapsed = 0
now = int(time.time())
params = {"created[gte]": since, "limit": 100}
while True:
page = get(session, "/checkout/sessions", params)
data = page.get("data", [])
for cs in data:
total += 1
state = cs.get("status") or "unknown"
counts[state] = counts.get(state, 0) + 1
expires = cs.get("expires_at")
if state == "open" and expires is not None and expires < now:
lapsed += 1
if not data or not page.get("has_more") or total >= cap:
break
params["starting_after"] = data[-1]["id"]
return total, counts, lapsed
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="window to measure, in days (keep it fixed between runs)")
ap.add_argument("--max-sessions", type=int, default=5000,
help="stop paginating after this many sessions")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
since = int(time.time()) - args.days * 86400
total, counts, lapsed = scan(s, since, args.max_sessions)
state, detail = verdict(total, counts.get("expired", 0), lapsed)
log.info("%-11s %s", state, detail)
log.info(" open %d complete %d expired %d (last %d days)",
counts.get("open", 0), counts.get("complete", 0),
counts.get("expired", 0), args.days)
if state in ("normal", "no-data"):
return 0
log.warning(" repair: create sessions with a shorter window so a lapse shows "
"up in hours rather than a day:")
log.warning(" POST %s/checkout/sessions -d expires_at=<now+7200> "
"(min 30 minutes, max 24 hours)", API)
log.warning(" and subscribe an event destination to checkout.session.expired "
"so each lapse is recorded")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report the share of Stripe Checkout Sessions that expire unpaid.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Checkout Sessions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
export const HIGH_SHARE = 0.5; // more than half of everything created is discarded
export const WATCH_SHARE = 0.25; // worth looking at before it becomes the first number
/**
* Classify one window of Checkout Sessions. Pure, so the thresholds are testable.
* `lapsedOpen` counts sessions still marked open whose expires_at has passed.
*/
export function verdict(total, expired, lapsedOpen = 0) {
if (!total) return ['no-data', 'no Checkout Sessions were created in the window'];
const share = expired / total;
const pct = (100 * share).toFixed(1);
if (share >= HIGH_SHARE) {
return ['abandoned',
`${expired} of ${total} session(s) expired unpaid (${pct}%). More than half ` +
'of everything created is being discarded.'];
}
if (lapsedOpen) {
return ['lapsed',
`${lapsedOpen} open session(s) are already past expires_at and have not been ` +
`marked yet; ${pct}% expired so far.`];
}
if (share >= WATCH_SHARE) {
return ['elevated',
`${expired} of ${total} session(s) expired unpaid (${pct}%). Shorten the ` +
'window so the lapse is visible in hours.'];
}
return ['normal', `${expired} of ${total} session(s) expired unpaid (${pct}%).`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function scan(key, since, cap = 5000) {
const counts = { open: 0, complete: 0, expired: 0 };
let total = 0;
let lapsed = 0;
const now = Math.floor(Date.now() / 1000);
const params = { 'created[gte]': since, limit: 100 };
for (;;) {
const page = await get(key, '/checkout/sessions', params);
const data = page.data ?? [];
for (const cs of data) {
total += 1;
const state = cs.status ?? 'unknown';
counts[state] = (counts[state] ?? 0) + 1;
if (state === 'open' && cs.expires_at != null && cs.expires_at < now) lapsed += 1;
}
if (data.length === 0 || !page.has_more || total >= cap) break;
params.starting_after = data[data.length - 1].id;
}
return { total, counts, lapsed };
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const days = Number(process.argv[2] ?? 30);
const since = Math.floor(Date.now() / 1000) - days * 86400;
const { total, counts, lapsed } = await scan(key, since);
const [state, detail] = verdict(total, counts.expired ?? 0, lapsed);
console.log(`${state.padEnd(11)} ${detail}`);
console.log(` open ${counts.open ?? 0} complete ${counts.complete ?? 0} ` +
`expired ${counts.expired ?? 0} (last ${days} days)`);
if (state === 'normal' || state === 'no-data') return;
console.warn(' repair: create sessions with a shorter window so a lapse shows ' +
'up in hours rather than a day:');
console.warn(` POST ${API}/checkout/sessions -d expires_at=<now+7200> ` +
'(min 30 minutes, max 24 hours)');
console.warn(' and subscribe an event destination to checkout.session.expired ' +
'so each lapse is recorded');
process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two of these tests exist because of an accounting mistake that is easy to make and impossible to see afterwards. An account with no sessions at all is not a perfect conversion rate, and sessions that have lapsed but are still marked open have to be counted somewhere, or the reported share is optimistic by a day of traffic every single run.
from stripe_checkout_abandonment import verdict
def test_no_sessions_is_not_a_perfect_score():
# An empty window divided into zero expired sessions is 0% abandonment, which
# would report "normal" on an account that has simply stopped taking payments.
state, _ = verdict(0, 0)
assert state == "no-data"
def test_half_expired_is_the_boundary():
assert verdict(100, 49)[0] == "elevated"
assert verdict(100, 50)[0] == "abandoned"
def test_a_quarter_expired_is_only_elevated():
assert verdict(100, 24)[0] == "normal"
assert verdict(100, 25)[0] == "elevated"
def test_lapsed_open_sessions_are_reported_even_when_the_share_is_low():
state, detail = verdict(100, 4, 3)
assert state == "lapsed"
assert "3 open session(s)" in detail
def test_a_healthy_account_still_reports_the_percentage():
state, detail = verdict(640, 118)
assert state == "normal"
assert "18.4%" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-checkout-abandonment.mjs';
test('no sessions is not a perfect score', () => {
assert.equal(verdict(0, 0)[0], 'no-data');
});
test('half expired is the boundary', () => {
assert.equal(verdict(100, 49)[0], 'elevated');
assert.equal(verdict(100, 50)[0], 'abandoned');
});
test('a quarter expired is only elevated', () => {
assert.equal(verdict(100, 24)[0], 'normal');
assert.equal(verdict(100, 25)[0], 'elevated');
});
test('lapsed open sessions are reported even when the share is low', () => {
const [state, detail] = verdict(100, 4, 3);
assert.equal(state, 'lapsed');
assert.match(detail, /3 open session\(s\)/);
});
test('a healthy account still reports the percentage', () => {
const [state, detail] = verdict(640, 118);
assert.equal(state, 'normal');
assert.match(detail, /18\.4%/);
});
FAQ
What is the default lifetime of a Checkout Session?
24 hours from creation. You can set expires_at explicitly to anything between 30 minutes and 24 hours after creation, but you cannot extend it past a day. Once it passes, status moves to expired and checkout.session.expired fires.
Is a high expired share always a problem?
No, and that is why the script reports a number rather than a pass or fail. Sessions created by crawlers, health checks, or a page that renders a Checkout button before the customer commits all inflate the denominator. Watch the trend against a fixed window; a share that moves is worth investigating, a share that sits still is your baseline.
Why do sessions sit in open long after the customer left?
Because status only changes at expires_at. A customer who closed the tab two minutes in leaves a session that reads open for the rest of the day. Counting open sessions whose expires_at has already passed is the only way to see them before Stripe relabels them.
Does shortening expires_at lose me sales?
Rarely, and the ones it loses are recoverable. Anyone who returns to a dead link gets Stripe's expired-session page, so give them a route back: enable recovery at creation and email the recovery URL. Two hours covers essentially everyone who was going to complete, and the shorter window is what makes the recovery email arrive while the cart is still in mind.
Can I re-open an expired Checkout Session?
No. Expiry is terminal; the session cannot be revived and its URL stops working. You create a new session, which is exactly what Checkout's built-in recovery does under the covers, linking the new one back to the old through recovered_from.
Related field notes
- A complete session whose payment_status is still unpaid
- Checkout Sessions carry no ID that maps back to your order
- A deactivated Payment Link is still linked from your site
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- The Checkout Session object — Stripe API reference
- List all Checkout Sessions — Stripe API reference
- Recover abandoned carts — Stripe Docs
- Create a Checkout Session — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.