Diagnostic Stripe
incomplete_expired volume means confirmation is broken
Sign-ups are steady, marketing spend is steady, and paid subscriptions are down. There are no failed payments to look at, because no payment was ever attempted. Filter the subscription list to incomplete_expired and there they are: a few hundred people who thought they had subscribed, in a state that Stripe considers finished.
Count both populations over the same window and divide. GET /v1/subscriptions?status=incomplete_expired&limit=100&created[gte]=<30 days ago> against GET /v1/subscriptions?status=active&limit=100&created[gte]=<the same timestamp>, paginating both.
A ratio under a few percent is ordinary abandonment. At roughly 10% of activations the confirmation step is failing for a slice of your traffic. If activations are near zero while the expired count is not, nothing is confirming at all and the checkout has been broken since whatever shipped last.
The problem in plain words
incomplete_expired is what a first invoice becomes when nobody pays it within Stripe's window. It is terminal. There is no call that reopens it, no retry, and the invoice attached to it has been voided. Whatever the customer believed when they closed the tab, the only route back is a fresh subscription with a payment method you do not have.
What makes this hard to see is that a single expired record is unremarkable. People do abandon card forms. The failure only becomes legible as a proportion: dozens of them against a handful of activations is not a hundred people changing their mind, it is a step in your checkout that a hundred people could not get past. Nobody computes that proportion, because no screen puts the two counts next to each other.
Meanwhile every other signal says the funnel is fine. The POST /v1/subscriptions call returned 200 for every one of them. Product analytics recorded the signup event on the server. Only the money is missing, and it goes missing quietly enough that the first suspicion is usually a marketing channel rather than a bug.
Why it happens
The ratio is the measurement, not the count. Two hundred expired subscriptions against four thousand activations is background noise. Two hundred against three hundred is an outage that has been running for a month. The same absolute number means opposite things, which is why any check built on a threshold count reports nothing useful.
The client never finishes what the server started. The intended flow creates the subscription with payment_behavior=default_incomplete, hands the first invoice's confirmation secret to the browser, and confirms it there. An integration that creates the subscription server-side and then redirects to a thank-you page has skipped the only step that takes money. Every signup through that path expires.
Partial breakage looks like a conversion dip. When the confirmation fails only in some conditions — one browser, one payment method, a redirect that drops a query parameter on mobile — the ratio moves from 2% to 15% rather than to 100%. That is exactly the size of change a growth team will attribute to seasonality for a quarter.
The evidence expires on its own schedule. Records that are still incomplete can be looked at while they are live, and those are the actionable ones. By the time they read incomplete_expired, the invoice is void and the state is frozen. The pile is only useful as a measurement, which is what this check treats it as.
Nothing fires when it happens. There is a customer.subscription.updated event carrying the transition, but no failure event and no dunning, because there was never a payment to fail. An integration listening for payment problems will never hear about this one.
The fix, as a flow
The script counts two statuses over one identical window and divides, because the same number of expired signups is background noise on one account and a month long outage on another.
How to fix it
Count both statuses over one identical window
The same created[gte] timestamp on both queries, or the ratio is meaningless. Thirty days is a reasonable default: long enough that a slow week does not swing it, short enough that a fix shows up in the next run.
Paginate to the end of both
Stripe caps a page at 100. A check that reads one page of each and divides reports 1.00 on any account with real volume, which is both alarming and wrong. Page both lists fully before dividing anything.
Judge the ratio, not the count
Under a few percent is ordinary abandonment on a card form. Around 10% of activations means a slice of traffic cannot complete the step. Half or more means it is broken for most people and the exceptions are the interesting ones.
Handle zero activations explicitly
Expired subscriptions with no activations at all is the worst case, and it is also the one that divides by zero. It deserves its own verdict rather than an error or a silently skipped run, because it is the state that most needs a page.
Fix the creation call, and watch the live ones as well
The repair is on the create path: payment_behavior=default_incomplete, expand the latest invoice, pass its confirmation secret to the client, confirm in the same session, and handle invoice.payment_action_required so an unfinished one gets an email instead of a countdown. For the records still inside the window, ageing the live incomplete ones is the check that can still rescue individuals.
How to check it worked
Re-run a full window after the confirmation fix has been live for thirty days. The ratio should fall back to abandonment levels, and the absolute count should stop tracking your signup volume.
python3 stripe_incomplete_expired_rate.py --days 30
# 30 day(s): 12 expired, 940 active — background, 1.3% of activations
The full code
Two paginated GETs and no writes — a restricted key with read access to Subscriptions is enough. The classifier here takes two counts rather than one object, because the finding is a rate: the same number of expired subscriptions is noise on one account and an outage on another, and a function that only sees one row can never say which.
"""Measure Stripe incomplete_expired subscriptions against activations.
Read only. Two paginated GETs, no writes: give this a RESTRICTED key with read
access to Subscriptions. 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_incomplete_expired_rate")
API = "https://api.stripe.com/v1"
# Share of activations above which the confirmation step is failing for part of
# the traffic rather than being abandoned by part of the customers.
LEAKING = 0.10
# Above this, it is not a slice of traffic any more.
BROKEN = 0.50
def verdict(expired, active, days=30, leaking=LEAKING, broken=BROKEN):
"""Judge one window of signups by the share that never confirmed. Pure.
Takes two counts rather than one subscription, because the finding is a
ratio: 200 expired subscriptions is background noise against 4,000
activations and an outage against 300, and no single row can tell you which.
Returns (state, detail).
"""
if expired < 0 or active < 0:
return ("unknown", "negative counts, so the ratio means nothing")
if expired == 0 and active == 0:
return ("no-signups",
"no subscriptions created in the last %d day(s), so there is "
"nothing to measure" % days)
if expired == 0:
return ("clean",
"%d activation(s) in %d day(s) and nothing expired unconfirmed"
% (active, days))
if active == 0:
return ("broken",
"%d subscription(s) expired unconfirmed and not one activated in "
"%d day(s): nothing is confirming at all" % (expired, days))
ratio = expired / active
pct = 100.0 * ratio
if ratio >= broken:
return ("broken",
"%d expired against %d activation(s), %.1f%%: the confirmation "
"step is failing for most of the traffic" % (expired, active, pct))
if ratio >= leaking:
return ("leaking",
"%d expired against %d activation(s), %.1f%%: a slice of the "
"traffic cannot complete the confirmation" % (expired, active, pct))
return ("background",
"%d expired against %d activation(s), %.1f%%: ordinary abandonment"
% (expired, active, pct))
def get(session, path, **params):
r = session.get(API + path, params=params, 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 count(session, status, since, limit):
"""Page a status to the end. One page of each and a division is a wrong answer."""
total = 0
params = {"status": status, "limit": 100, "created[gte]": since}
while True:
page = get(session, "/subscriptions", **params)
rows = page.get("data", [])
total += len(rows)
if not page.get("has_more") or not rows or total >= limit:
break
params["starting_after"] = rows[-1]["id"]
return total
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="window to measure, in days (default 30)")
ap.add_argument("--max-rows", type=int, default=5000,
help="stop paginating each status after this many rows")
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
expired = count(s, "incomplete_expired", since, args.max_rows)
active = count(s, "active", since, args.max_rows)
state, detail = verdict(expired, active, args.days)
if state in ("clean", "background", "no-signups"):
log.info("%s: %s", state, detail)
return 0
log.warning("%s: %s", state, detail)
log.warning("repair: create with payment_behavior=default_incomplete, expand "
"latest_invoice.confirmation_secret, and confirm it client side "
"in the same session")
log.warning("repair: handle invoice.payment_action_required so an unfinished "
"signup gets an email rather than a countdown")
log.warning("note: expired subscriptions are terminal. The invoice is void "
"and no API call revives them; these customers need a new signup.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Measure Stripe incomplete_expired subscriptions against activations.
*
* Read only. Two paginated GETs, no writes: give this a RESTRICTED key with read
* access to Subscriptions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Share of activations above which the confirmation step is failing for part of
// the traffic rather than being abandoned by part of the customers.
export const LEAKING = 0.10;
export const BROKEN = 0.50;
/**
* Judge one window of signups by the share that never confirmed. Pure.
*
* Takes two counts rather than one subscription, because the finding is a ratio:
* 200 expired is noise against 4,000 activations and an outage against 300.
*/
export function verdict(expired, active, days = 30, leaking = LEAKING, broken = BROKEN) {
if (expired < 0 || active < 0) {
return ['unknown', 'negative counts, so the ratio means nothing'];
}
if (expired === 0 && active === 0) {
return ['no-signups',
`no subscriptions created in the last ${days} day(s), so there is nothing to measure`];
}
if (expired === 0) {
return ['clean',
`${active} activation(s) in ${days} day(s) and nothing expired unconfirmed`];
}
if (active === 0) {
return ['broken',
`${expired} subscription(s) expired unconfirmed and not one activated in ` +
`${days} day(s): nothing is confirming at all`];
}
const ratio = expired / active;
const pct = (100 * ratio).toFixed(1);
if (ratio >= broken) {
return ['broken',
`${expired} expired against ${active} activation(s), ${pct}%: the ` +
'confirmation step is failing for most of the traffic'];
}
if (ratio >= leaking) {
return ['leaking',
`${expired} expired against ${active} activation(s), ${pct}%: a slice of ` +
'the traffic cannot complete the confirmation'];
}
return ['background',
`${expired} expired against ${active} activation(s), ${pct}%: ordinary abandonment`];
}
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();
}
// Page a status to the end. One page of each and a division is a wrong answer.
async function count(key, status, since, limit) {
let total = 0;
const params = { status, limit: 100, 'created[gte]': since };
for (;;) {
const page = await get(key, '/subscriptions', params);
const rows = page.data ?? [];
total += rows.length;
if (!page.has_more || rows.length === 0 || total >= limit) break;
params.starting_after = rows[rows.length - 1].id;
}
return total;
}
async function main() {
const days = Number(process.argv[2] ?? 30);
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 since = Math.floor(Date.now() / 1000) - days * 86400;
const expired = await count(key, 'incomplete_expired', since, 5000);
const active = await count(key, 'active', since, 5000);
const [state, detail] = verdict(expired, active, days);
if (state === 'clean' || state === 'background' || state === 'no-signups') {
console.log(`${state}: ${detail}`);
return;
}
console.warn(`${state}: ${detail}`);
console.warn('repair: create with payment_behavior=default_incomplete, expand ' +
'latest_invoice.confirmation_secret, and confirm it client side in the same session');
console.warn('repair: handle invoice.payment_action_required so an unfinished ' +
'signup gets an email rather than a countdown');
console.warn('note: expired subscriptions are terminal. The invoice is void and ' +
'no API call revives them; these customers need a new signup.');
process.exitCode = 1;
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The tests pin the two ends that a count-based check gets wrong. The same twenty expired subscriptions must read as background against a healthy activation number and as a leak against a small one, and expired signups with no activations at all has to produce a verdict rather than a division by zero.
from stripe_incomplete_expired_rate import verdict
def test_the_same_count_is_noise_against_enough_activations():
state, detail = verdict(20, 4000)
assert state == "background"
assert "abandonment" in detail
def test_the_same_count_is_a_leak_against_a_small_one():
# 20 of 150 is 13% of activations: a slice of traffic cannot confirm.
state, detail = verdict(20, 150)
assert state == "leaking"
assert "13.3%" in detail
def test_expired_with_no_activations_does_not_divide_by_zero():
state, detail = verdict(31, 0, days=14)
assert state == "broken"
assert "not one activated" in detail
def test_a_quiet_window_is_not_reported_as_healthy_signups():
assert verdict(0, 0)[0] == "no-signups"
def test_nothing_expired_is_clean():
assert verdict(0, 900)[0] == "clean"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-incomplete-expired-rate.mjs';
test('the same count is noise against enough activations', () => {
const [state, detail] = verdict(20, 4000);
assert.equal(state, 'background');
assert.match(detail, /abandonment/);
});
test('the same count is a leak against a small one', () => {
const [state, detail] = verdict(20, 150);
assert.equal(state, 'leaking');
assert.match(detail, /13\.3%/);
});
test('expired with no activations does not divide by zero', () => {
const [state, detail] = verdict(31, 0, 14);
assert.equal(state, 'broken');
assert.match(detail, /not one activated/);
});
test('a quiet window is not reported as healthy signups', () => {
assert.equal(verdict(0, 0)[0], 'no-signups');
});
test('nothing expired is clean', () => {
assert.equal(verdict(0, 900)[0], 'clean');
});
FAQ
What is incomplete_expired?
The terminal state of a subscription whose very first invoice was never paid inside Stripe's window. The invoice is voided and the subscription cannot be transitioned back. It is not a cancellation and not a failed payment; it is a signup that never became one.
How many is too many?
It depends entirely on your activation volume, which is why the check divides rather than counts. Under a few percent of activations is normal abandonment on a card form. Around ten percent means part of your traffic cannot complete the confirmation. Expired signups with no activations at all means it is broken for everyone.
Can I recover the expired subscriptions?
No. There is no call that revives one, and the attached invoice is already void. The only path is a new subscription, which needs a payment method you do not have, which means an email to somebody who believes they already subscribed. Fix the creation flow first so the pile stops growing.
How is this different from watching the incomplete ones?
Different window and different purpose. Ageing live incomplete subscriptions catches individual records while there is still time for a human to rescue them. This check measures the ones already lost, as a rate, to tell you whether the checkout has a bug at all. Run both: one is a work queue, the other is a diagnosis.
Why does nothing alert on this today?
Because nothing failed. No payment was attempted, so there is no failed-payment event, no decline code, and no dunning. The transition is carried on customer.subscription.updated among a great deal of other traffic, and an integration listening for payment problems will never hear it.
Related field notes
- Incomplete subscriptions die silently after 23 hours
- Subscriptions frozen on requires_action 3DS authentication
- Active subscriptions with no payment method to charge
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 Subscription object — Stripe API reference
- Create a subscription — Stripe API reference
- How subscriptions work — Stripe Docs
- API keys — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.