Diagnostic Stripe
open invoices are weeks past due_date and nobody chases
Invoiced customers pay when they are asked twice. The integration asks once: Stripe emails the invoice on finalization and then waits, because that is what collection_method=send_invoice means. Nobody enabled the reminder emails, so an invoice that went out in April is still sitting at open in July, and the customer's subscription never noticed.
List GET /v1/invoices?status=open&collection_method=send_invoice and filter client side on due_date, because the list endpoint has no server-side filter for it. Anything with due_date in the past and amount_remaining above zero is receivable that nothing is chasing.
Rank by days overdue and by amount_remaining, and use status_transitions.finalized_at for the true age. Past 60 days you are outside the window Stripe's own reminder emails cover, so no automated follow-up will ever be sent for that invoice no matter what you switch on today.
The problem in plain words
send_invoice is the correct collection method for customers who pay from an accounts-payable process rather than a saved card, and it behaves exactly as documented: Stripe finalizes the invoice, emails it, and stops. There is no charge attempt, no retry schedule and no dunning, because there is nothing on file to charge.
The gap is that most teams read "Stripe handles billing" as covering collection too. It does for charge_automatically. For send_invoice the follow-up is a Dashboard setting nobody turned on, and the result is an accounts receivable ledger that exists only inside Stripe, that nobody exports, and that ages quietly while the customer keeps their service. The first honest measurement usually happens during a funding round or an audit, at which point some of it is a year old and effectively uncollectible.
Why it happens
Nothing changes state when an invoice goes past due. An overdue invoice stays open. Stripe has no overdue status, so no event fires, no filter in the Dashboard highlights it, and any dashboard you built on invoice status shows it in exactly the same bucket as one issued this morning.
The list endpoint cannot filter on due_date. You can filter by status, by collection method, by customer and by created, but not by the field that defines overdue. That single omission is why this check is nearly always written as "we will look in the Dashboard sometimes" instead of as a cron job: the obvious one-line API call does not exist, and the two-line version with a client-side filter never gets written.
The subscription does not react either. The 30, 60 and 90 day past-due actions are opt-in settings. Left at their defaults, an unpaid send_invoice subscription keeps renewing and keeps generating new invoices on top of the unpaid one, so the customer accumulates debt while continuing to receive the service.
Reminders have a window, and it closes. Stripe's built-in reminders run from shortly before the due date to about 60 days after it. An invoice already 120 days overdue is past all of them: enabling reminders today does nothing for it, and it needs either a manual resend or an honest write-off.
The fix, as a flow
The script pages open invoices for the send_invoice collection method and compares due_date itself, because Stripe offers no server side filter for the one field that defines overdue.
How to fix it
Page open invoices for the send_invoice collection method only
Automatically charged invoices sitting open are a different problem with a different repair — that is dunning, and it is the next note. Filtering here keeps the two apart, so the report you hand someone is one kind of work.
Filter due_date client side and compute the age in days
There is no server-side option, so fetch and compare. Also count the invoices where due_date is null: those can never be overdue by definition, which means no reminder will ever fire for them either. That is usually a days_until_due that was never set on the subscription.
Sort by amount_remaining, not by age
Use amount_remaining rather than total, because partial payments are common on invoiced accounts and the balance is what you are chasing. The oldest invoice is rarely the one worth the phone call.
Turn on the reminders before working the backlog
Settings, then Billing, then Invoices in the Dashboard: enable the reminder emails and set the past-due subscription action. Do this first, so the backlog you are about to clear is the last one that accumulates for this reason.
Re-send what is still collectible, write off what is not
POST /v1/invoices/{id}/send re-sends the invoice email. For the ones nobody is going to pay, marking them uncollectible is not an admission of failure; it is what keeps the receivable figure meaningful. Leaving them open forever is the option that costs you an accurate number.
How to check it worked
Re-run the script. Anything still listed should be inside its terms rather than past them.
python3 stripe_overdue_invoices.py
# clear 0 open invoice(s) past due_date
The full code
One paginated GET against /v1/invoices with the status and collection method fixed, then a client-side filter because Stripe does not offer a server-side one for due_date. The classifier is pure and takes two scalars, so the boundaries that decide whether an invoice is chaseable, stale or past every reminder Stripe would ever send are visible rules rather than a comparison buried in a loop.
"""Report open Stripe invoices that are past their due_date with nothing chasing them.
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 re-sending an
invoice emails a customer and marking one uncollectible writes off revenue.
"""
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_overdue_invoices")
API = "https://api.stripe.com/v1"
ACTION_DAYS = 30 # earliest past-due subscription action Stripe offers
REMINDER_END_DAYS = 60 # past this, no built-in reminder will ever be sent
def verdict(days_overdue, amount_remaining):
"""Classify one open invoice. Pure, so the boundaries can be tested without a network.
`days_overdue` is negative while the invoice is still within terms and None
when it has no due_date at all. Returns (state, detail).
"""
if not amount_remaining or amount_remaining <= 0:
return ("nothing_due", "open with amount_remaining 0: no money outstanding")
if days_overdue is None:
return ("undated",
"open with no due_date: it can never be overdue, so no reminder "
"will ever fire for it")
if days_overdue < 0:
return ("current", "due in %.1f day(s)" % -days_overdue)
if days_overdue < ACTION_DAYS:
return ("overdue",
"%.0f day(s) past due; still inside the reminder window" % days_overdue)
if days_overdue < REMINDER_END_DAYS:
return ("stale",
"%.0f day(s) past due; past the point where a subscription action "
"would have fired had one been configured" % days_overdue)
return ("abandoned",
"%.0f day(s) past due; beyond every built-in reminder, so nothing "
"automated will chase this one again" % days_overdue)
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 open_invoices(session, limit):
"""Page open, manually collected invoices.
There is no server-side filter for due_date on this endpoint, so the whole set
comes back and the comparison happens here.
"""
out = []
params = {"status": "open", "collection_method": "send_invoice", "limit": 100}
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("--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 open invoices")
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 open_invoices(s, args.max_invoices):
due = inv.get("due_date")
remaining = inv.get("amount_remaining") or 0
rows.append((
inv.get("id", "<no id>"),
verdict(None if due is None else (now - due) / 86400.0, remaining),
remaining,
(inv.get("currency") or "").upper(),
))
late = [r for r in rows if r[1][0] in ("overdue", "stale", "abandoned", "undated")]
if not late:
log.info("%-12s 0 open invoice(s) past due_date", "clear")
return 0
# Biggest balance first: the oldest invoice is rarely the one worth a call.
late.sort(key=lambda r: r[2], reverse=True)
outstanding = sum(r[2] for r in late)
log.warning("%-12s %d unchased invoice(s) worth %d in minor units",
"receivable", len(late), outstanding)
for inv_id, (state, detail), amount, currency in late[:args.top]:
log.warning(" %-12s %s %d %s %s", state, inv_id, amount, currency, detail)
if len(late) > args.top:
log.warning(" ... and %d more", len(late) - args.top)
log.warning(" turn the follow-up on first: Dashboard, Settings, Billing, "
"Invoices, then enable reminder emails and the past-due "
"subscription action")
log.warning(" then per invoice: POST %s/invoices/<id>/send to re-send, or "
"mark_uncollectible on the ones nobody will pay", API)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report open Stripe invoices past their due_date with nothing chasing them.
*
* 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';
const ACTION_DAYS = 30; // earliest past-due subscription action
export const REMINDER_END_DAYS = 60; // past this, no built-in reminder is sent
/**
* Classify one open invoice. Pure, so the boundaries can be tested without a network.
* `daysOverdue` is negative within terms and null when there is no due_date.
*/
export function verdict(daysOverdue, amountRemaining) {
if (!amountRemaining || amountRemaining <= 0) {
return ['nothing_due', 'open with amount_remaining 0: no money outstanding'];
}
if (daysOverdue === null || daysOverdue === undefined) {
return ['undated',
'open with no due_date: it can never be overdue, so no reminder will ever fire for it'];
}
if (daysOverdue < 0) {
return ['current', `due in ${(-daysOverdue).toFixed(1)} day(s)`];
}
if (daysOverdue < ACTION_DAYS) {
return ['overdue',
`${daysOverdue.toFixed(0)} day(s) past due; still inside the reminder window`];
}
if (daysOverdue < REMINDER_END_DAYS) {
return ['stale',
`${daysOverdue.toFixed(0)} day(s) past due; past the point where a ` +
'subscription action would have fired had one been configured'];
}
return ['abandoned',
`${daysOverdue.toFixed(0)} day(s) past due; beyond every built-in reminder, ` +
'so nothing automated will chase this one again'];
}
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 openInvoices(key, limit = 2000) {
const out = [];
const params = { status: 'open', collection_method: 'send_invoice', limit: 100 };
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 now = Date.now() / 1000;
const rows = (await openInvoices(key)).map((inv) => {
const remaining = inv.amount_remaining ?? 0;
const due = inv.due_date;
return {
id: inv.id ?? '<no id>',
amount: remaining,
currency: (inv.currency ?? '').toUpperCase(),
state: verdict(due === null || due === undefined ? null : (now - due) / 86400, remaining),
};
});
const late = rows.filter((r) => ['overdue', 'stale', 'abandoned', 'undated'].includes(r.state[0]));
if (late.length === 0) {
console.log(`${'clear'.padEnd(12)} 0 open invoice(s) past due_date`);
return;
}
// Biggest balance first: the oldest invoice is rarely the one worth a call.
late.sort((a, b) => b.amount - a.amount);
const outstanding = late.reduce((a, r) => a + r.amount, 0);
console.warn(`${'receivable'.padEnd(12)} ${late.length} unchased invoice(s) worth ${outstanding} in minor units`);
for (const r of late.slice(0, 20)) {
const [state, detail] = r.state;
console.warn(` ${state.padEnd(12)} ${r.id} ${r.amount} ${r.currency} ${detail}`);
}
if (late.length > 20) console.warn(` ... and ${late.length - 20} more`);
console.warn(' turn the follow-up on first: Dashboard, Settings, Billing, ' +
'Invoices, then enable reminder emails and the past-due subscription action');
console.warn(` then per invoice: POST ${API}/invoices/<id>/send to re-send, or ` +
'mark_uncollectible on the ones nobody will pay');
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 are about the day the state changes and two are about invoices that look fine and are not. An invoice with no due_date is the interesting case: it is not overdue, it can never become overdue, and treating it as healthy is how a receivable disappears from the report entirely.
from stripe_overdue_invoices import verdict
def test_within_terms_is_current():
state, detail = verdict(-4.0, 25000)
assert state == "current"
assert "4.0" in detail
def test_the_due_date_itself_is_already_overdue():
assert verdict(-0.1, 25000)[0] == "current"
assert verdict(0.0, 25000)[0] == "overdue"
def test_thirty_and_sixty_days_are_the_two_boundaries():
assert verdict(29.9, 25000)[0] == "overdue"
assert verdict(30.0, 25000)[0] == "stale"
assert verdict(59.9, 25000)[0] == "stale"
state, detail = verdict(60.0, 25000)
assert state == "abandoned"
assert "nothing automated will chase" in detail
def test_no_due_date_is_reported_rather_than_ignored():
# It can never be overdue, which means no reminder will ever fire. Silence
# here is how an invoice leaves the receivable report for good.
state, _ = verdict(None, 25000)
assert state == "undated"
def test_a_zero_balance_is_not_receivable():
assert verdict(120.0, 0)[0] == "nothing_due"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-overdue-invoices.mjs';
test('within terms is current', () => {
const [state, detail] = verdict(-4.0, 25000);
assert.equal(state, 'current');
assert.match(detail, /4.0/);
});
test('the due date itself is already overdue', () => {
assert.equal(verdict(-0.1, 25000)[0], 'current');
assert.equal(verdict(0.0, 25000)[0], 'overdue');
});
test('thirty and sixty days are the two boundaries', () => {
assert.equal(verdict(29.9, 25000)[0], 'overdue');
assert.equal(verdict(30.0, 25000)[0], 'stale');
assert.equal(verdict(59.9, 25000)[0], 'stale');
const [state, detail] = verdict(60.0, 25000);
assert.equal(state, 'abandoned');
assert.match(detail, /nothing automated will chase/);
});
test('no due date is reported rather than ignored', () => {
assert.equal(verdict(null, 25000)[0], 'undated');
});
test('a zero balance is not receivable', () => {
assert.equal(verdict(120.0, 0)[0], 'nothing_due');
});
FAQ
Why does Stripe not charge a send_invoice invoice automatically?
Because there is nothing to charge. collection_method=send_invoice means the customer pays on their own terms, usually by bank transfer or card through the hosted invoice page, and no payment method is assumed to be on file. Stripe emails the invoice at finalization and then waits, which is the documented behaviour rather than a failure.
Is there an overdue status I can filter on?
No. An overdue invoice keeps the status open, and the list endpoint has no due_date filter either. That combination is the whole reason this needs a script: you fetch open invoices for the collection method and compare due_date yourself.
What does marking an invoice uncollectible actually do?
It closes the invoice as bad debt without pretending it was paid, so your revenue and receivable figures stay honest. It does not refund anything and it does not delete the record. Leaving unrecoverable invoices open indefinitely is the option that quietly inflates the receivable number.
Will enabling reminders help the invoices that are already 200 days late?
No. Stripe's reminder schedule runs from shortly before the due date to roughly 60 days after it, so an invoice past that window is outside every automated follow-up. Enable the reminders for everything that comes after, and work the old backlog by hand or write it off.
What about open invoices on charge_automatically?
Different problem entirely. Those have a payment method and a retry schedule, so the question is whether dunning is still running or has already given up. That is the next note in this section, and mixing the two in one report gets both of them ignored.
Related field notes
- Dunning ran out of retries and no attempt is scheduled
- Draft invoices sit for months and never finalize
- past_due subscriptions keep their access forever
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.
- Collection method — Stripe Docs
- List all invoices — Stripe API reference
- The invoice object — Stripe API reference
- Send an invoice for manual payment — 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.