Diagnostic Stripe
expired Checkout Sessions are never recovered by email
Somebody asks why there is no abandoned-cart email. The answer is not that nobody built one. It is that there is nothing to send: Checkout will mint a recovery link for every session that lapses, but only for sessions created with recovery switched on, and switching it on afterwards does nothing at all.
Page GET /v1/checkout/sessions?status=expired&created[gte]=<now-60d> and count the sessions where after_expiration is null or after_expiration.recovery.enabled is false. Those lapsed with no recovery URL and no way to mint one now.
Then page the completed sessions in the same window and count recovered_from. A non-trivial expired population with zero recovered_from means no cart has ever been recovered, whatever the configuration says. Fix it at creation with after_expiration[recovery][enabled]=true and consent_collection[promotions]=auto, then mail after_expiration.recovery.url when you handle checkout.session.expired.
The problem in plain words
Abandoned carts get written off wholesale, and the reason given is usually that recovery emails are a project. They are not: Checkout builds the link, keeps it alive for 30 days, and tells you which recovered session came from which original. The project is one parameter and one event handler.
What makes this worth checking rather than assuming is that the config is per session, set at creation, and invisible everywhere else. A team can decide to do recovery, write the email, wire the handler, deploy it, and discover that every incoming checkout.session.expired payload has after_expiration.recovery.url of null, because the sessions were created by a code path nobody changed.
Why it happens
Recovery is opt-in on the session, not on the account. There is no Dashboard switch that turns it on everywhere. It is a parameter passed at POST /v1/checkout/sessions, which means it is as easy to miss as any other optional field, and it can be present on the sessions one service creates and absent on the ones another creates.
The recovery URL does not exist until the session lapses, and cannot be added after. It appears on the expired session only if recovery was enabled at creation. An expired session without it is finished: there is no endpoint that mints a recovery link retroactively, and no amount of fixing the flag today helps the sessions that lapsed yesterday.
The link has its own expiry, and it is not the session's. after_expiration.recovery.expires_at is 30 days from the lapse. A batch job that mails weekly, or a queue that stalls, can hold perfectly valid recovery links until they are dead, and the send will look like it worked.
Consent is a separate field from recovery, and it is the one that stops you. consent_collection.promotions controls whether the customer is even asked; consent.promotions records what they answered. Recovery enabled without consent collection produces a working URL for an address you have no permission to market to, which is a worse position than having nothing, because it looks like a green light.
The fix, as a flow
The script classifies every lapse in the window, then asks the other half of the question: has any completed session ever carried recovered_from? Configuration says what should happen; that field says what did.
How to fix it
Page the expired sessions over 60 days
GET /v1/checkout/sessions?status=expired&created[gte]=<unix>&limit=100. Sixty days is deliberate: it is twice the life of a recovery URL, so the report shows both the links you could still use and the ones that quietly went past their own expiry.
Classify each lapse rather than counting them
Four outcomes matter and they need different work. No recovery configured is a code change. A lapsed URL is a scheduling problem. A live URL with no consent is a legal one. A live URL with consent is a send you have not made.
Ask whether a single recovery has ever landed
A session created from a recovery URL carries recovered_from pointing at the original. Count them across completed sessions in the window. That count is the only end-to-end proof the loop closes; configuration says what should happen, recovered_from says what did.
Turn recovery and consent on together
Enabling recovery without collecting consent gets you links you cannot use. Set both at creation and the expired payload arrives with a URL and a recorded answer, so the handler has everything it needs to decide whether to send.
Handle checkout.session.expired and read the URL from the payload
The event is the trigger and the payload already carries after_expiration.recovery.url and customer_details.email. There is no second lookup to do. If nothing is subscribed to the event, subscribe it first — the sessions lapse whether or not anybody is listening.
Check expires_at before every send, not at queue time
Thirty days is generous until a mail job runs weekly and retries twice. Comparing after_expiration.recovery.expires_at to the clock at send time costs nothing and stops you mailing dead links, which convert at zero and look like recovery not working.
How to check it worked
Re-run after the change. New lapses should report recoverable, and the recovered_from count should stop being zero.
python3 stripe_checkout_recovery.py --days 30
# 96 expired: 0 no-recovery, 0 lapsed, 0 no-consent, 96 recoverable
# 12 completed session(s) carry recovered_from
The full code
Two paginated GETs and no writes: expired sessions to classify, completed sessions to count recovered_from. The classifier is pure and takes the current time as an argument rather than reading the clock, because the whole point of the lapsed state is a boundary, and a boundary you cannot pin in a test is a boundary you find out about in production.
"""Report expired Stripe Checkout Sessions that can never be recovered by email.
Read only. Two paginated GETs 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_recovery")
API = "https://api.stripe.com/v1"
def verdict(session, now):
"""Classify one expired Checkout Session. Pure, so the rules can be tested
offline.
`now` is unix seconds, passed in rather than read, so the recovery URL's own
expiry boundary can be pinned in a test.
Returns (state, detail).
"""
recovery = (session.get("after_expiration") or {}).get("recovery") or {}
if not recovery.get("enabled"):
return ("no-recovery",
"after_expiration[recovery][enabled] was not set at creation, so "
"this lapse has no recovery url and never will")
url = str(recovery.get("url") or "").strip()
if not url:
return ("unknown",
"recovery is enabled but no url is present on an expired session")
expires_at = recovery.get("expires_at")
if expires_at is not None and expires_at <= now:
return ("lapsed",
"the recovery url expired %.1f day(s) ago; mailing it now sends "
"the customer to a dead link" % ((now - expires_at) / 86400.0))
left = ((expires_at - now) / 86400.0) if expires_at is not None else float("nan")
consent = (session.get("consent") or {}).get("promotions")
if consent != "opt_in":
return ("no-consent",
"the recovery url is live for %.1f more day(s), but "
"consent.promotions is %r: there is no recorded permission to "
"mail this address" % (left, consent))
return ("recoverable",
"the recovery url is live for %.1f more day(s) and the customer "
"opted in" % (left,))
def get(http, path, params=None):
r = http.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 sessions_with_status(http, status, since, limit):
"""Yield Checkout Sessions with `status` created since `since`, newest first."""
seen = 0
params = {"limit": 100, "status": status, "created[gte]": int(since)}
while True:
page = get(http, "/checkout/sessions", params)
data = page.get("data", [])
for s in data:
yield s
seen += 1
if not data or not page.get("has_more") or seen >= limit:
break
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=60,
help="how far back to read sessions")
ap.add_argument("--max-sessions", type=int, default=5000,
help="stop paginating after this many sessions per status")
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
http = requests.Session()
http.headers.update({"Authorization": "Bearer " + key})
now = time.time()
since = now - args.days * 86400
tally = {"no-recovery": 0, "lapsed": 0, "no-consent": 0,
"recoverable": 0, "unknown": 0}
expired = 0
for s in sessions_with_status(http, "expired", since, args.max_sessions):
expired += 1
state, _ = verdict(s, now)
tally[state] = tally.get(state, 0) + 1
recovered = 0
completed = 0
for s in sessions_with_status(http, "complete", since, args.max_sessions):
completed += 1
if s.get("recovered_from"):
recovered += 1
log.info("%d expired: %d no-recovery, %d lapsed, %d no-consent, %d recoverable",
expired, tally["no-recovery"], tally["lapsed"], tally["no-consent"],
tally["recoverable"])
log.info("%d completed session(s), %d carrying recovered_from",
completed, recovered)
if tally["no-recovery"]:
log.warning(" repair: POST %s/checkout/sessions "
"-d 'after_expiration[recovery][enabled]=true' "
"-d 'consent_collection[promotions]=auto'", API)
if tally["no-consent"]:
log.warning(" recovery urls exist but consent.promotions is not opt_in; "
"add -d 'consent_collection[promotions]=auto' at creation")
if tally["lapsed"]:
log.warning(" recovery urls went past after_expiration.recovery.expires_at "
"before anything sent them; check expires_at at send time")
if expired and not recovered:
log.warning(" no completed session carries recovered_from: nothing has "
"ever come back through a recovery url")
log.warning(" subscribe checkout.session.expired and mail "
"after_expiration.recovery.url to customer_details.email")
return 1 if (tally["no-recovery"] or tally["lapsed"] or tally["no-consent"]) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report expired Stripe Checkout Sessions that can never be recovered by email.
*
* Read only. Two paginated GETs 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';
/**
* Classify one expired Checkout Session. Pure, so the rules can be tested
* offline. `now` is unix seconds, passed in rather than read, so the recovery
* URL's own expiry boundary can be pinned in a test.
*/
export function verdict(session, now) {
const recovery = session.after_expiration?.recovery ?? {};
if (!recovery.enabled) {
return ['no-recovery',
'after_expiration[recovery][enabled] was not set at creation, so this ' +
'lapse has no recovery url and never will'];
}
const url = String(recovery.url ?? '').trim();
if (!url) {
return ['unknown',
'recovery is enabled but no url is present on an expired session'];
}
const expiresAt = recovery.expires_at;
if (expiresAt != null && expiresAt <= now) {
const ago = ((now - expiresAt) / 86400).toFixed(1);
return ['lapsed',
`the recovery url expired ${ago} day(s) ago; mailing it now sends the ` +
'customer to a dead link'];
}
const left = expiresAt == null ? NaN : ((expiresAt - now) / 86400);
const consent = session.consent?.promotions;
if (consent !== 'opt_in') {
return ['no-consent',
`the recovery url is live for ${left.toFixed(1)} more day(s), but ` +
`consent.promotions is ${JSON.stringify(consent)}: there is no recorded ` +
'permission to mail this address'];
}
return ['recoverable',
`the recovery url is live for ${left.toFixed(1)} more day(s) and the ` +
'customer opted in'];
}
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* sessionsWithStatus(key, status, since, limit = 5000) {
let seen = 0;
const params = { limit: 100, status, 'created[gte]': Math.floor(since) };
for (;;) {
const page = await get(key, '/checkout/sessions', params);
const data = page.data ?? [];
for (const s of data) { yield s; seen += 1; }
if (data.length === 0 || !page.has_more || seen >= limit) break;
params.starting_after = data[data.length - 1].id;
}
}
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] ?? 60);
const now = Date.now() / 1000;
const since = now - days * 86400;
const tally = { 'no-recovery': 0, lapsed: 0, 'no-consent': 0, recoverable: 0, unknown: 0 };
let expired = 0;
for await (const s of sessionsWithStatus(key, 'expired', since)) {
expired += 1;
const [state] = verdict(s, now);
tally[state] = (tally[state] ?? 0) + 1;
}
let completed = 0;
let recovered = 0;
for await (const s of sessionsWithStatus(key, 'complete', since)) {
completed += 1;
if (s.recovered_from) recovered += 1;
}
console.log(`${expired} expired: ${tally['no-recovery']} no-recovery, ` +
`${tally.lapsed} lapsed, ${tally['no-consent']} no-consent, ` +
`${tally.recoverable} recoverable`);
console.log(`${completed} completed session(s), ${recovered} carrying recovered_from`);
if (tally['no-recovery']) {
console.warn(` repair: POST ${API}/checkout/sessions ` +
`-d 'after_expiration[recovery][enabled]=true' ` +
`-d 'consent_collection[promotions]=auto'`);
}
if (tally['no-consent']) {
console.warn(' recovery urls exist but consent.promotions is not opt_in; ' +
"add -d 'consent_collection[promotions]=auto' at creation");
}
if (tally.lapsed) {
console.warn(' recovery urls went past after_expiration.recovery.expires_at ' +
'before anything sent them; check expires_at at send time');
}
if (expired && !recovered) {
console.warn(' no completed session carries recovered_from: nothing has ever ' +
'come back through a recovery url');
console.warn(' subscribe checkout.session.expired and mail ' +
'after_expiration.recovery.url to customer_details.email');
}
process.exitCode =
(tally['no-recovery'] || tally.lapsed || tally['no-consent']) ? 1 : 0;
}
// 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 cases carry the note. A recovery URL that is live but has no recorded consent looks like success from every angle except the only one that matters, and a URL that went past its own 30-day expiry while sitting in a mail queue is indistinguishable from a working one unless something compares it to the clock. Both are pinned here, with the boundary passed in rather than read.
from stripe_checkout_recovery import verdict
NOW = 1_700_000_000
DAY = 86400
def expired_session(**recovery):
"""An expired Session with recovery enabled unless told otherwise."""
base = {"enabled": True, "url": "https://checkout.stripe.com/c/pay/cs_test_x",
"expires_at": NOW + 10 * DAY}
base.update(recovery)
return {"after_expiration": {"recovery": base},
"consent": {"promotions": "opt_in"}}
def test_recovery_never_enabled_is_the_default_finding():
state, detail = verdict({}, NOW)
assert state == "no-recovery"
assert "never will" in detail
def test_live_url_with_consent_is_recoverable():
state, detail = verdict(expired_session(), NOW)
assert state == "recoverable"
assert "10.0" in detail
def test_a_url_past_its_own_expiry_is_not_recoverable():
# 30 days from the lapse, not from the session: a weekly mail job can miss it.
state, detail = verdict(expired_session(expires_at=NOW - 2 * DAY), NOW)
assert state == "lapsed"
assert "2.0" in detail
def test_a_live_url_without_consent_is_its_own_state():
session = expired_session()
session["consent"] = {"promotions": None}
state, detail = verdict(session, NOW)
assert state == "no-consent"
assert "permission" in detail
def test_enabled_but_urlless_is_not_silently_recoverable():
assert verdict(expired_session(url=None), NOW)[0] == "unknown"
assert verdict(expired_session(url=" "), NOW)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-checkout-recovery.mjs';
const NOW = 1_700_000_000;
const DAY = 86400;
const expiredSession = (recovery = {}) => ({
after_expiration: {
recovery: {
enabled: true,
url: 'https://checkout.stripe.com/c/pay/cs_test_x',
expires_at: NOW + 10 * DAY,
...recovery,
},
},
consent: { promotions: 'opt_in' },
});
test('recovery never enabled is the default finding', () => {
const [state, detail] = verdict({}, NOW);
assert.equal(state, 'no-recovery');
assert.match(detail, /never will/);
});
test('live url with consent is recoverable', () => {
const [state, detail] = verdict(expiredSession(), NOW);
assert.equal(state, 'recoverable');
assert.match(detail, /10\.0/);
});
test('a url past its own expiry is not recoverable', () => {
const [state, detail] = verdict(expiredSession({ expires_at: NOW - 2 * DAY }), NOW);
assert.equal(state, 'lapsed');
assert.match(detail, /2\.0/);
});
test('a live url without consent is its own state', () => {
const session = expiredSession();
session.consent = { promotions: null };
const [state, detail] = verdict(session, NOW);
assert.equal(state, 'no-consent');
assert.match(detail, /permission/);
});
test('enabled but urlless is not silently recoverable', () => {
assert.equal(verdict(expiredSession({ url: null }), NOW)[0], 'unknown');
assert.equal(verdict(expiredSession({ url: ' ' }), NOW)[0], 'unknown');
});
FAQ
Can I turn recovery on for sessions that have already expired?
No. It is a parameter at session creation, and the recovery URL is minted at the lapse only if it was set. There is no endpoint that adds one afterwards, so every session that lapsed without it is permanently unrecoverable. Changing the flag today only helps sessions created from today.
How long is a recovery URL valid?
Thirty days from the lapse, in after_expiration.recovery.expires_at. That is generous until a mail job runs weekly, stalls, and retries: compare expires_at to the clock at send time rather than at queue time, or you will mail links that are already dead.
Do I need consent to send the recovery email?
You need a recorded answer before you rely on one. consent_collection[promotions]=auto shows the customer the box, and consent.promotions records what they chose. Gate the send on opt_in and you have both the permission and the evidence of it; your own legal obligations are a separate question from the API.
How can I tell whether a recovery actually worked?
A Checkout Session created from a recovery URL carries recovered_from, pointing at the session it replaced. Counting those across completed sessions is the only end-to-end proof: the configuration tells you what should happen, recovered_from tells you what did.
Would shortening expires_at improve recovery?
It brings the whole loop forward, which usually helps: a session that lapses in two hours can be recovered while the customer still remembers the cart, rather than a day later. See the note on expired session share for what that window costs you at the default of 24 hours.
Related field notes
- Most Checkout Sessions expire unpaid and nobody is told
- Guest checkouts finish with customer null and can't be linked
- Customers with no email address on file
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.
- Recover abandoned carts — Stripe Docs
- The Checkout Session object — Stripe API reference
- Create a Checkout Session — Stripe API reference
- Types of events — 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.