Skip to content

Diagnostic Stripe

draft invoices sit for months and never finalize

Somebody asks why the March figure is lower than the March that was forecast, and nobody can answer it from the Dashboard, because the money is not missing and it is not late. It is sitting in invoices that were built, itemised, priced correctly, and then never sent: no number, no PDF, no hosted page, no email. Stripe cannot collect on an invoice that has not been finalized, and these have been drafts since March.

Read-only key Python and Node.js Tests included
Blue and white visa card on silver laptop computer
Photo by CardMapr.nl on Unsplash
The short answer

List GET /v1/invoices?status=draft&created[lt]=<unix now-30d> and read three fields on every row. auto_advance says whether Stripe will ever finalize the invoice on its own. automatically_finalizes_at says when that is scheduled to happen. amount_due says what it is worth.

An old draft with auto_advance: false is not late, it is stranded: nothing inside Stripe will ever move it, and no amount of waiting changes that. One with auto_advance: true and automatically_finalizes_at: null has lost its schedule. One whose automatically_finalizes_at is already in the past is being blocked at finalization, and last_finalization_error will say by what.

The problem in plain words

A draft invoice is a document, not a bill. It has no invoice number, no PDF, no hosted_invoice_url to send anyone, and Stripe explicitly will not attempt payment on it. From the customer's side nothing happened at all: they were never asked for the money, so they are not late paying it and no dunning, reminder or subscription state change will ever be triggered on their account.

This makes it the quietest revenue leak in a billing integration. Every other failure in this section leaves a trace somebody eventually trips over — a failed charge, a past_due subscription, an angry customer. A stranded draft produces no event, no email, no error and no support ticket. It shows up as a number in a report being smaller than it should be, which is exactly the class of problem that gets attributed to churn or seasonality for two quarters before anyone opens the invoice list and sorts by status.

Invoice createdauto_advance falseNo scheduleautomatically_finalizes_atnullNever finalizedno number, no PDFNever sentcustomer neveraskedRevenue shortfound in a report
A draft has no number, no PDF and no hosted page, so the customer was never asked for the money and nothing anywhere is late.

Why it happens

Automatic finalization is not a timer, it is a consequence. Stripe finalizes a draft roughly an hour after your endpoint acknowledges invoice.created, and defers for up to 72 hours while deliveries are failing. That is a deliberate courtesy — it gives you a window to add line items before the invoice becomes immutable — but it couples finalization to webhook health. A broken endpoint does not just cost you events; for three days it also holds up the billing.

auto_advance: false arrives without anyone choosing it. It is the default under payment_behavior=default_incomplete, it is what pause_collection[behavior]=keep_as_draft produces, and it is what subscriptions that went unpaid leave behind. Each of those is a reasonable state at the moment it is created. The failure is that none of them expire: a subscription paused in January is still generating drafts nobody will ever finalize in June.

The list endpoint does not sort by what matters. Drafts come back newest first, and the newest drafts are the healthy ones an hour away from finalizing normally. The stranded ones are at the far end of the pagination, which is why a manual look at the Dashboard usually concludes everything is fine.

Deleting is as valid a repair as finalizing, and nobody wants to decide. Half of these drafts should be billed and half should never have existed. Because the two look identical in a list, the whole pile gets left alone. amount_due and the age are what separate them, and both are in the API.

The fix, as a flow

The script pages drafts older than the cutoff and reads three fields on each one, because whether an invoice is waiting, stranded or blocked decides which of three different repairs it needs.

GET /v1/invoicesstatus=draft, created[lt] cutoffFinalization scheduledleave it aloneamount_due is zeroclutter, delete itauto_advance falsestranded, finalize itScheduled time passedread last_finalization_error
The repair differs by state: finalize, hand it back to Stripe, fix the customer, or delete a draft that should never have existed.

How to fix it

List drafts older than 30 days, not all drafts

Use created[lt] with a unix timestamp 30 days back. Everything younger is either finalizing normally or inside the 72-hour deferral window, and including it buries the real findings under noise. The cutoff is an argument on the script for a reason: a monthly biller may want 45 days, a daily one 7.

Read auto_advance before anything else

false means the invoice has opted out of Stripe's collection workflow entirely. No schedule exists for it and none will be created. This is the state to count first, because it is the only one where waiting is definitively not a strategy.

Treat a past automatically_finalizes_at as an error report

If the scheduled time has passed and the invoice is still a draft, finalization was attempted and failed. last_finalization_error carries the reason, and the most common one on an account with Stripe Tax enabled is customer_tax_location_invalid: a customer with no resolvable address. That is a different repair from finalizing, and doing it in the wrong order just fails again.

Total amount_due before writing the ticket

"Forty stranded drafts" gets triaged into next quarter. "Forty stranded drafts worth 61,400 in minor units" gets done this week. The sum is one addition over data you already fetched, and it is the only line of the report anyone outside the team will read.

Split the pile into collect and delete

POST /v1/invoices/{id}/finalize for the ones you intend to bill, or set auto_advance=true to hand the invoice back to Stripe's workflow. For the ones that should never have existed, drafts are the only invoices Stripe lets you delete. Do the deletions in a separate pass from the finalizations, because a finalized invoice cannot be taken back.

Check webhook health at the same time

If drafts are piling up account-wide rather than for one paused subscription, look at the endpoints before the invoices. Three days of failing deliveries produce three days of drafts that look stranded and are merely deferred.

How to check it worked

Re-run the script after the pass. What is left should be recent drafts with a finalization scheduled, and nothing older than the cutoff.

python3 stripe_draft_invoices.py --older-than 30
# clear       0 draft invoice(s) older than 30 days

The full code

One paginated GET against /v1/invoices and nothing else — a restricted key with read access to Invoices is enough, and is what you should give it. The classifier is pure and takes four scalars, because the difference between stranded, unscheduled and blocked decides whether the repair is a finalize call, a field update, or a fix to the customer record, and getting that wrong wastes a day.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_draft_invoices.py
"""Report Stripe draft invoices that will never finalize on their own.

Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Invoices. The repair is printed, never performed, because finalizing an
invoice sends a bill to a customer and deleting one destroys it.
"""
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_draft_invoices")

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

# Stripe finalizes about an hour after invoice.created is acknowledged, and
# defers up to 72 hours while endpoints are failing. Anything still a draft well
# past that is not waiting for the workflow, it is outside it.
STALE_DAYS = 30


def verdict(age_days, auto_advance, finalizes_in_days, amount_due):
    """Classify one draft invoice. Pure, so the rules can be tested without a network.

    `age_days` is how long the invoice has been a draft. `finalizes_in_days` is
    the time until `automatically_finalizes_at`, negative if that moment has
    already passed, and None when the field is null. Returns (state, detail).
    """
    if age_days < STALE_DAYS:
        return ("fresh",
                "draft for %.1f day(s); still inside the window where Stripe "
                "finalizes on its own" % age_days)
    if not amount_due:
        return ("empty",
                "draft for %.0f day(s) with amount_due 0: clutter rather than "
                "money, and safe to delete" % age_days)
    if not auto_advance:
        return ("stranded",
                "auto_advance is false after %.0f day(s): no finalization is "
                "scheduled and none will be" % age_days)
    if finalizes_in_days is None:
        return ("unscheduled",
                "auto_advance is true after %.0f day(s) but "
                "automatically_finalizes_at is null: nothing is queued" % age_days)
    if finalizes_in_days < 0:
        return ("blocked",
                "the scheduled finalization passed %.1f day(s) ago and this is "
                "still a draft: read last_finalization_error"
                % -finalizes_in_days)
    return ("scheduled",
            "finalizes in %.1f day(s); leave it alone" % finalizes_in_days)


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 drafts(session, older_than_days, limit):
    """Page draft invoices created before the cutoff. Newest first, as Stripe sends them."""
    cutoff = int(time.time() - older_than_days * 86400)
    out = []
    params = {"status": "draft", "limit": 100, "created[lt]": cutoff}
    while True:
        page = get(session, "/invoices", **params)
        data = page.get("data", [])
        out.extend(data)
        if not data or not page.get("has_more") or len(out) >= limit:
            break
        params["starting_after"] = data[-1]["id"]
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--older-than", type=float, default=STALE_DAYS,
                    help="only look at drafts created this many days ago or more")
    ap.add_argument("--top", type=int, default=20,
                    help="how many individual invoices to print")
    ap.add_argument("--max-invoices", type=int, default=2000,
                    help="stop paginating after this many drafts")
    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})

    now = time.time()
    rows = []
    for inv in drafts(s, args.older_than, args.max_invoices):
        created = inv.get("created") or now
        finalizes_at = inv.get("automatically_finalizes_at")
        rows.append((
            inv.get("id", "<no id>"),
            verdict((now - created) / 86400.0,
                    bool(inv.get("auto_advance")),
                    None if finalizes_at is None else (finalizes_at - now) / 86400.0,
                    inv.get("amount_due") or 0),
            inv.get("amount_due") or 0,
            (inv.get("currency") or "").upper(),
        ))

    stuck = [r for r in rows
             if r[1][0] in ("stranded", "unscheduled", "blocked")]
    if not stuck:
        log.info("%-11s 0 draft invoice(s) older than %g days",
                 "clear", args.older_than)
        return 0

    at_stake = sum(r[2] for r in stuck)
    log.warning("%-11s %d stuck draft(s) worth %d in minor units",
                "stuck", len(stuck), at_stake)
    for inv_id, (state, detail), amount, currency in stuck[:args.top]:
        log.warning("  %-11s %s  %d %s  %s", state, inv_id, amount, currency, detail)
        if state == "blocked":
            log.warning("      GET %s/invoices/%s  and read last_finalization_error",
                        API, inv_id)
        else:
            log.warning("      POST %s/invoices/%s/finalize   to bill it", API, inv_id)
            log.warning("      POST %s/invoices/%s  auto_advance=true   to hand it "
                        "back to Stripe", API, inv_id)
    if len(stuck) > args.top:
        log.warning("  ... and %d more", len(stuck) - args.top)
    log.warning("  drafts you never intended to bill are the one kind of invoice "
                "Stripe lets you remove; do that in a separate pass")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-draft-invoices.mjs
/**
 * Report Stripe draft invoices that will never finalize on their own.
 *
 * Read only. One paginated GET and no writes: give this a RESTRICTED key with
 * read access to Invoices. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// Stripe finalizes about an hour after invoice.created is acknowledged, and
// defers up to 72 hours while endpoints are failing. Anything still a draft well
// past that is not waiting for the workflow, it is outside it.
export const STALE_DAYS = 30;

/**
 * Classify one draft invoice. Pure, so the rules can be tested without a network.
 * `finalizesInDays` is negative when that moment has passed, null when the field is.
 */
export function verdict(ageDays, autoAdvance, finalizesInDays, amountDue) {
  if (ageDays < STALE_DAYS) {
    return ['fresh',
      `draft for ${ageDays.toFixed(1)} day(s); still inside the window where ` +
      'Stripe finalizes on its own'];
  }
  if (!amountDue) {
    return ['empty',
      `draft for ${ageDays.toFixed(0)} day(s) with amount_due 0: clutter rather ` +
      'than money, and safe to delete'];
  }
  if (!autoAdvance) {
    return ['stranded',
      `auto_advance is false after ${ageDays.toFixed(0)} day(s): no finalization ` +
      'is scheduled and none will be'];
  }
  if (finalizesInDays === null || finalizesInDays === undefined) {
    return ['unscheduled',
      `auto_advance is true after ${ageDays.toFixed(0)} day(s) but ` +
      'automatically_finalizes_at is null: nothing is queued'];
  }
  if (finalizesInDays < 0) {
    return ['blocked',
      `the scheduled finalization passed ${(-finalizesInDays).toFixed(1)} day(s) ` +
      'ago and this is still a draft: read last_finalization_error'];
  }
  return ['scheduled',
    `finalizes in ${finalizesInDays.toFixed(1)} day(s); leave it alone`];
}

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 drafts(key, olderThanDays = STALE_DAYS, limit = 2000) {
  const cutoff = Math.floor(Date.now() / 1000 - olderThanDays * 86400);
  const out = [];
  const params = { status: 'draft', limit: 100, 'created[lt]': cutoff };
  for (;;) {
    const page = await get(key, '/invoices', params);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return out;
}

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 olderThan = Number(process.argv[2] ?? STALE_DAYS);
  const now = Date.now() / 1000;
  const rows = (await drafts(key, olderThan)).map((inv) => {
    const created = inv.created ?? now;
    const at = inv.automatically_finalizes_at;
    return {
      id: inv.id ?? '<no id>',
      amount: inv.amount_due ?? 0,
      currency: (inv.currency ?? '').toUpperCase(),
      state: verdict((now - created) / 86400,
        Boolean(inv.auto_advance),
        at === null || at === undefined ? null : (at - now) / 86400,
        inv.amount_due ?? 0),
    };
  });

  const stuck = rows.filter((r) => ['stranded', 'unscheduled', 'blocked'].includes(r.state[0]));
  if (stuck.length === 0) {
    console.log(`${'clear'.padEnd(11)} 0 draft invoice(s) older than ${olderThan} days`);
    return;
  }

  const atStake = stuck.reduce((a, r) => a + r.amount, 0);
  console.warn(`${'stuck'.padEnd(11)} ${stuck.length} stuck draft(s) worth ${atStake} in minor units`);
  for (const r of stuck.slice(0, 20)) {
    const [state, detail] = r.state;
    console.warn(`  ${state.padEnd(11)} ${r.id}  ${r.amount} ${r.currency}  ${detail}`);
    if (state === 'blocked') {
      console.warn(`      GET ${API}/invoices/${r.id}  and read last_finalization_error`);
    } else {
      console.warn(`      POST ${API}/invoices/${r.id}/finalize   to bill it`);
      console.warn(`      POST ${API}/invoices/${r.id}  auto_advance=true   to hand it back to Stripe`);
    }
  }
  if (stuck.length > 20) console.warn(`  ... and ${stuck.length - 20} more`);
  console.warn('  drafts you never intended to bill are the one kind of invoice ' +
               'Stripe lets you remove; do that in a separate pass');
  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

The tests fix the order of the rules, which is the part that actually decides what a human does next. A zero-value draft is checked before auto_advance, because there is no point routing an empty invoice to someone to finalize; a past automatically_finalizes_at is checked last, because it means something tried and failed rather than never started.

test_stripe_draft_invoices.py
from stripe_draft_invoices import verdict


def test_recent_drafts_are_not_a_finding():
    # Everything under the cutoff is either finalizing normally or inside the
    # 72-hour deferral window. Reporting those buries the real ones.
    assert verdict(29.9, False, None, 5000)[0] == "fresh"
    assert verdict(30.0, False, None, 5000)[0] == "stranded"


def test_auto_advance_false_is_stranded_not_late():
    state, detail = verdict(90.0, False, None, 12000)
    assert state == "stranded"
    assert "none will be" in detail


def test_zero_amount_is_clutter_before_it_is_stranded():
    # Checked ahead of auto_advance on purpose: nobody should be asked to
    # finalize an invoice worth nothing.
    assert verdict(90.0, False, None, 0)[0] == "empty"


def test_auto_advance_true_with_no_schedule_is_unscheduled():
    state, _ = verdict(45.0, True, None, 8000)
    assert state == "unscheduled"


def test_a_past_finalization_time_means_it_failed():
    state, detail = verdict(45.0, True, -3.0, 8000)
    assert state == "blocked"
    assert "last_finalization_error" in detail


def test_a_future_finalization_time_is_left_alone():
    assert verdict(45.0, True, 0.5, 8000)[0] == "scheduled"
stripe-draft-invoices.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-draft-invoices.mjs';

test('recent drafts are not a finding', () => {
  assert.equal(verdict(29.9, false, null, 5000)[0], 'fresh');
  assert.equal(verdict(30.0, false, null, 5000)[0], 'stranded');
});

test('auto_advance false is stranded, not late', () => {
  const [state, detail] = verdict(90.0, false, null, 12000);
  assert.equal(state, 'stranded');
  assert.match(detail, /none will be/);
});

test('zero amount is clutter before it is stranded', () => {
  assert.equal(verdict(90.0, false, null, 0)[0], 'empty');
});

test('auto_advance true with no schedule is unscheduled', () => {
  assert.equal(verdict(45.0, true, null, 8000)[0], 'unscheduled');
});

test('a past finalization time means it failed', () => {
  const [state, detail] = verdict(45.0, true, -3.0, 8000);
  assert.equal(state, 'blocked');
  assert.match(detail, /last_finalization_error/);
});

test('a future finalization time is left alone', () => {
  assert.equal(verdict(45.0, true, 0.5, 8000)[0], 'scheduled');
});

FAQ

Can a draft invoice be paid?

No. Stripe will not attempt payment on an invoice that has not been finalized, and there is no hosted page or PDF for a customer to pay from because those are created at finalization along with the invoice number. A draft is a document, not a bill.

Why is auto_advance false when I never set it?

Several ordinary paths set it for you. payment_behavior=default_incomplete leaves the first invoice with auto_advance false, pause_collection[behavior]=keep_as_draft produces drafts by design, and subscriptions that reached unpaid stop advancing their invoices. None of those states expires on its own, so the drafts keep accumulating after the reason for them has gone.

Does a broken webhook endpoint really delay billing?

For up to 72 hours, yes. Stripe waits for successful delivery of invoice.created before finalizing, so that you can add line items first, and gives up waiting after three days. If drafts are piling up across the whole account rather than on one subscription, check endpoint health before you touch the invoices.

Should I finalize these or delete them?

Both, in separate passes. Drafts are the only invoices Stripe permits deleting, which makes deletion the honest way to clear ones that should never have existed. Finalizing is irreversible and sends a real bill, so do that pass with a list you have actually read, not with a loop over the script's output.

Why filter on created[lt] rather than reading every draft?

Because a healthy account always has recent drafts in flight, and they outnumber the stuck ones. Stripe returns newest first, so without the cutoff the findings you want are at the end of the pagination, underneath every invoice that is about to finalize normally.

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.