Skip to content

Diagnostic Stripe

incomplete subscriptions die silently after 23 hours

Someone filled in the card form, saw a spinner, and closed the tab believing they had subscribed. Stripe has a subscription for them in incomplete. In under a day that record becomes incomplete_expired, the open invoice is voided, and there is nothing left to recover — no charge, no error, no ticket.

Read-only key Python and Node.js Tests included
Two envelopes on a table
Photo by Markus Spiske on Unsplash
The short answer

Read GET /v1/subscriptions?status=incomplete&limit=100 and age every row against its created timestamp. Anything older than 82800 seconds — 23 hours — is already past the point Stripe gives up on.

A handful of rows minutes old is normal: those are customers mid-confirmation. Rows hours old are not, and they are the signal. They mean the first invoice's PaymentIntent is never being confirmed, which is a bug in the handoff between your server and your client, not a card problem.

The problem in plain words

A subscription created with collection_method=charge_automatically does not become active until its first invoice is paid. Until then it sits in incomplete, which is a real subscription object that shows up in the Dashboard, has an id, and bills nobody.

The window is 23 hours and it is fixed. After it, Stripe moves the subscription to incomplete_expired, voids the open invoice, and that state is terminal. You cannot reopen it, re-confirm it, or transition it back. The only path forward is a brand new subscription, which means going back to a customer who already believes they are a paying customer and asking them to sign up again.

What makes this expensive is that it fails on the happy path. The card was fine. The customer did everything right.

Subscriptioncreatedstatus incompleteClient secretreturnedhanded to thebrowserNever confirmedredirect loses it23 hours pass82800 secondsincomplete_expiredinvoice voided
The creation call succeeds and the confirmation never happens. Stripe waits 23 hours, then voids the invoice and the record is terminal.

Why it happens

The client never confirms the PaymentIntent. The documented flow is to create the subscription with payment_behavior=default_incomplete, hand the first invoice's client secret to the browser, and confirm it there. An integration that creates the subscription server-side and then redirects to a success page has skipped the confirmation entirely. Every signup lands in incomplete and stays there.

The client secret gets lost between the two halves. A redirect that drops a query parameter, a single-page app that remounts and refetches, an error boundary that swallows the confirm call — the subscription exists and the secret needed to finish it does not.

Nothing in your logs says so. The POST /v1/subscriptions call returned 200. From the server's point of view the signup worked. The failure is the absence of a second call that nobody is counting.

The 23 hours run out overnight. A problem that starts at 5pm has expired every affected record before anyone opens a dashboard the next morning, so the evidence you find is a pile of incomplete_expired rather than something you can still act on.

The fix, as a flow

The script asks Stripe one question and then does arithmetic on the answer: which subscriptions are incomplete, and how far each one is through the 23 hour window before it expires for good.

GET /v1/subscriptionsstatus=incompleteunder an hour oldnormal, leave ithours old, unconfirmedfix the confirm callpast 23 hoursgone, sign them up again
Age is the whole signal. Minutes old is a customer mid-flow; hours old is a confirmation step that never runs.

How to fix it

List the incomplete subscriptions and age them

One GET call. Sort by created and look at the oldest. If the oldest is twenty minutes old you are watching normal traffic mid-flow; if it is nine hours old, the confirmation step is not happening at all.

Separate the two populations

Rows under an hour old are noise. Rows over an hour old are the finding, because no real customer spends an hour on a card form. The script draws that line explicitly rather than reporting one undifferentiated count.

Check what fraction of signups this is

Compare the count against subscriptions that reached active over the same period. A couple of stragglers a week is abandonment. A third of your signups is a broken integration, and the number tells you which conversation to have.

Fix the creation call, not the stuck records

Create with payment_behavior=default_incomplete, expand the latest invoice, pass its confirmation secret to the client, and confirm it in the same session. Until that is true, every fix you apply to individual subscriptions is refilling a bucket with a hole in it.

Accept that the expired ones are gone

Past 23 hours there is no API call that revives a subscription. The repair the script prints is a fresh POST /v1/subscriptions with the customer and price, which needs a payment method you do not have yet. That is a customer email, not a script.

How to check it worked

Re-run the script after a normal day of signups. Everything it reports should be minutes old, not hours.

python3 stripe_incomplete_subs.py
# 3 incomplete subscription(s), 0 past the 23 hour window, 0 stalled

The full code

One GET request and no writes: a restricted key with read access to Subscriptions is enough, and is what you should give it. The ageing rules are a pure function, so the 23-hour boundary is something you can read and test rather than something buried in a loop.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 21 Stripe fixes, free and open source.
stripe_incomplete_subs.py
"""Report Stripe subscriptions stuck in incomplete before the 23-hour deadline.

Read only. One GET request, 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_subs")

API = "https://api.stripe.com/v1"

# Stripe holds an unpaid first invoice open for exactly 23 hours, then moves the
# subscription to the terminal incomplete_expired and voids the invoice.
WINDOW = 82800
# The last stretch before that, where a human can still rescue an individual one.
LAST_CHANCE = 7200


def verdict(sub, now, grace=3600):
    """Classify one incomplete subscription by how long it has sat unconfirmed.

    Pure, so the 23-hour boundary can be tested without a network. `grace` is how
    long a real customer might plausibly spend on the confirmation step; anything
    older than that was never confirmed at all.
    """
    created = sub.get("created")
    if not isinstance(created, (int, float)):
        return ("unknown", "no created timestamp, so this row cannot be aged")
    age = now - created
    if age >= WINDOW:
        return ("expired",
                "%.1f h old: past the 23 hour window, so the invoice is voided and "
                "this record cannot be revived" % (age / 3600.0))
    if age >= WINDOW - LAST_CHANCE:
        return ("expiring",
                "%.1f h old: under %.1f h left before Stripe expires it"
                % (age / 3600.0, (WINDOW - age) / 3600.0))
    if age >= grace:
        return ("stalled",
                "%.1f h old and still unconfirmed: the first PaymentIntent was "
                "never confirmed by the client" % (age / 3600.0))
    return ("pending",
            "%.0f min old: a customer may still be on the confirmation step"
            % (age / 60.0))


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 page_all(session, path, limit, **params):
    """Walk a list endpoint. Read only; every call here is a GET."""
    out = []
    params = dict(params, limit=100)
    while True:
        page = get(session, path, **params)
        out.extend(page.get("data", []))
        if not page.get("has_more") or len(out) >= limit:
            break
        params["starting_after"] = page["data"][-1]["id"]
    return out[:limit]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--grace", type=int, default=3600,
                    help="seconds a confirmation may plausibly take (default 3600)")
    ap.add_argument("--max", type=int, default=1000,
                    help="stop after this many subscriptions")
    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})

    subs = page_all(s, "/subscriptions", args.max, status="incomplete")
    if not subs:
        log.info("no incomplete subscriptions for this key's mode")
        return 0

    now = time.time()
    counts = {}
    for sub in subs:
        state, detail = verdict(sub, now, args.grace)
        counts[state] = counts.get(state, 0) + 1
        line = "%-8s %s  %s" % (state, sub.get("id", "?"), detail)
        if state == "pending":
            log.info(line)
            continue
        log.warning(line)
        if state == "expired":
            log.warning("  repair: unrecoverable. Create a new subscription: "
                        "POST %s/subscriptions -d customer=%s -d items[0][price]=... "
                        "-d default_payment_method=...",
                        API, sub.get("customer", "cus_..."))
        else:
            log.warning("  repair: confirm the first invoice's PaymentIntent client "
                        "side before %s/subscriptions/%s expires", API, sub.get("id"))

    bad = len(subs) - counts.get("pending", 0)
    log.info("%d incomplete subscription(s), %d past the 23 hour window, %d stalled",
             len(subs), counts.get("expired", 0), counts.get("stalled", 0))
    if bad:
        log.warning("structural fix: create with payment_behavior=default_incomplete "
                    "and confirm the invoice's client secret in the same session")
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-incomplete-subs.mjs
/**
 * Report Stripe subscriptions stuck in incomplete before the 23-hour deadline.
 *
 * Read only. One GET request, 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';

// Stripe holds an unpaid first invoice open for exactly 23 hours, then moves the
// subscription to the terminal incomplete_expired and voids the invoice.
export const WINDOW = 82800;
// The last stretch before that, where a human can still rescue an individual one.
const LAST_CHANCE = 7200;

/**
 * Classify one incomplete subscription by how long it has sat unconfirmed.
 * Pure, so the 23-hour boundary can be tested without a network.
 */
export function verdict(sub, now, grace = 3600) {
  const created = sub.created;
  if (typeof created !== 'number') {
    return ['unknown', 'no created timestamp, so this row cannot be aged'];
  }
  const age = now - created;
  if (age >= WINDOW) {
    return ['expired',
      `${(age / 3600).toFixed(1)} h old: past the 23 hour window, so the invoice ` +
      'is voided and this record cannot be revived'];
  }
  if (age >= WINDOW - LAST_CHANCE) {
    return ['expiring',
      `${(age / 3600).toFixed(1)} h old: under ${((WINDOW - age) / 3600).toFixed(1)} h ` +
      'left before Stripe expires it'];
  }
  if (age >= grace) {
    return ['stalled',
      `${(age / 3600).toFixed(1)} h old and still unconfirmed: the first ` +
      'PaymentIntent was never confirmed by the client'];
  }
  return ['pending',
    `${(age / 60).toFixed(0)} min old: a customer may still be on the confirmation step`];
}

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();
}

async function pageAll(key, path, limit, params = {}) {
  const out = [];
  const q = { ...params, limit: 100 };
  for (;;) {
    const page = await get(key, path, q);
    out.push(...(page.data ?? []));
    if (!page.has_more || out.length >= limit) break;
    q.starting_after = page.data[page.data.length - 1].id;
  }
  return out.slice(0, limit);
}

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 subs = await pageAll(key, '/subscriptions', 1000, { status: 'incomplete' });
  if (subs.length === 0) {
    console.log("no incomplete subscriptions for this key's mode");
    return;
  }

  const now = Date.now() / 1000;
  const counts = new Map();
  for (const sub of subs) {
    const [state, detail] = verdict(sub, now);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    const line = `${state.padEnd(8)} ${sub.id ?? '?'}  ${detail}`;
    if (state === 'pending') { console.log(line); continue; }
    console.warn(line);
    if (state === 'expired') {
      console.warn(`  repair: unrecoverable. Create a new subscription: ` +
        `POST ${API}/subscriptions -d customer=${sub.customer ?? 'cus_...'} ` +
        `-d items[0][price]=... -d default_payment_method=...`);
    } else {
      console.warn(`  repair: confirm the first invoice's PaymentIntent client side ` +
        `before ${API}/subscriptions/${sub.id} expires`);
    }
  }

  const bad = subs.length - (counts.get('pending') ?? 0);
  console.log(`${subs.length} incomplete subscription(s), ` +
    `${counts.get('expired') ?? 0} past the 23 hour window, ` +
    `${counts.get('stalled') ?? 0} stalled`);
  if (bad) {
    console.warn('structural fix: create with payment_behavior=default_incomplete ' +
      "and confirm the invoice's client secret in the same session");
  }
  process.exitCode = bad ? 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

The boundary worth pinning is 82800 seconds, because it is the one number in this note that is not a judgement call. The other case to hold still is the young row: it has to stay quiet, or the check cries wolf on every healthy signup and gets muted within a week.

test_stripe_incomplete_subs.py
from stripe_incomplete_subs import WINDOW, verdict

NOW = 1_800_000_000


def test_a_minutes_old_subscription_is_not_an_alert():
    state, detail = verdict({"created": NOW - 1800}, NOW)
    assert state == "pending"
    assert "confirmation step" in detail


def test_hours_old_and_unconfirmed_is_the_finding():
    state, detail = verdict({"created": NOW - 5 * 3600}, NOW)
    assert state == "stalled"
    assert "never confirmed" in detail


def test_the_last_two_hours_are_called_out_separately():
    # Still rescuable by a human, which is why it is not folded into "stalled".
    state, detail = verdict({"created": NOW - (WINDOW - 3600)}, NOW)
    assert state == "expiring"
    assert "left before" in detail


def test_exactly_23_hours_is_already_expired():
    # 82800 is the boundary Stripe documents, not a rounded-off guess.
    state, detail = verdict({"created": NOW - WINDOW}, NOW)
    assert state == "expired"
    assert "cannot be revived" in detail


def test_a_row_with_no_timestamp_is_not_silently_healthy():
    state, _ = verdict({}, NOW)
    assert state == "unknown"
stripe-incomplete-subs.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, WINDOW } from './stripe-incomplete-subs.mjs';

const NOW = 1_800_000_000;

test('a minutes old subscription is not an alert', () => {
  const [state, detail] = verdict({ created: NOW - 1800 }, NOW);
  assert.equal(state, 'pending');
  assert.match(detail, /confirmation step/);
});

test('hours old and unconfirmed is the finding', () => {
  const [state, detail] = verdict({ created: NOW - 5 * 3600 }, NOW);
  assert.equal(state, 'stalled');
  assert.match(detail, /never confirmed/);
});

test('the last two hours are called out separately', () => {
  const [state, detail] = verdict({ created: NOW - (WINDOW - 3600) }, NOW);
  assert.equal(state, 'expiring');
  assert.match(detail, /left before/);
});

test('exactly 23 hours is already expired', () => {
  const [state, detail] = verdict({ created: NOW - WINDOW }, NOW);
  assert.equal(state, 'expired');
  assert.match(detail, /cannot be revived/);
});

test('a row with no timestamp is not silently healthy', () => {
  assert.equal(verdict({}, NOW)[0], 'unknown');
});

FAQ

Can I recover a subscription that already went incomplete_expired?

No. It is a terminal status: the open invoice has been voided and there is no transition out of it. The only way forward is a new subscription with POST /v1/subscriptions, which needs a payment method you do not have, so in practice it is an email to the customer rather than a script.

How long exactly does a subscription stay incomplete?

23 hours, or 82800 seconds, measured from creation. It applies to the first invoice on a charge_automatically subscription that has not been paid. The window is not configurable.

Is a small number of incomplete subscriptions normal?

Yes, if they are minutes old. Those are customers who are mid-confirmation right now. The number that matters is how many are older than an hour, because nobody spends an hour on a card form.

Why do these appear even though the card was valid?

Because no charge was ever attempted. The subscription creation call succeeded and the client-side confirmation of the first invoice's PaymentIntent never happened, so the card was never used. It is a handoff bug, not a decline.

Does this happen with collection_method=send_invoice too?

No. The 23-hour incomplete window applies to charge_automatically. Subscriptions billed by emailed invoice go active and leave the invoice open for its due date instead, which is a different problem with a different deadline.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.