Skip to content

Diagnostic Stripe

pending invoice items that never reach an invoice

Somebody added a setup fee in March. It is still there, sitting in the account with invoice: null, waiting for the next invoice to sweep it up. That customer cancelled in April, so there will never be a next invoice, and the fee has been quietly not-billed for five months while showing up in nobody's report of anything.

Read-only key Python and Node.js Tests included
Black android smartphone on brown wooden table
Photo by CardMapr.nl on Unsplash
The short answer

GET /v1/invoiceitems?pending=true&limit=100 lists every invoice item that has not been attached to an invoice yet. Bucket by customer, take the age of the oldest item from date, and sum amount for the exposure.

Then ask the deciding question per customer: GET /v1/subscriptions?customer={cus}&status=active&limit=1. An item on a customer with a live subscription is waiting for the next cycle. An item on a customer with no live subscription is waiting for an invoice that will never be created, and no amount of time fixes it.

The problem in plain words

A pending invoice item is not an error state, which is exactly why it accumulates. It is the normal, documented way to attach a one-off charge to somebody's next invoice: create it, do not name an invoice, and Stripe sweeps it up next time it bills that customer. The mechanism only works if there is a next time.

The failure is therefore invisible from both ends. Stripe is behaving correctly and holding the item exactly as instructed. Your application already treated the charge as done, because the create call returned 200 five months ago. Nothing reconciles the two, so the money is neither collected nor written off nor reported: it is simply parked, and the only way anyone finds out is by looking.

One off chargecreated withinvoice nullWaits for nextcyclethe documentedbehaviourSubscriptioncancelledno future invoicesItem stillpendingno status changeNever billedfound only bylooking
Stripe holds the item exactly as instructed. The instruction was to wait for an invoice that will now never be raised.

Why it happens

Cancellation stops invoice generation, and the items do not go with it. Cancelling a subscription ends future invoices for that customer. Anything pending at that moment stays pending forever. This is the single largest source of orphans, and it is worst on exactly the accounts where somebody added a manual adjustment during the cancellation conversation.

proration_behavior=none is a decision to bill later that nobody records. Upgrades and downgrades made with create_prorations leave the proration as a pending item, correctly, to be picked up next cycle. Used on a subscription that is about to end, or combined with a plan change that resets the cycle, the proration outlives the thing that was going to bill it.

Nothing ages a pending item. There is no expiry, no status transition, no event. An item created two years ago and an item created this morning are the same object in the same state, and the API sorts by creation date rather than by anything resembling urgency.

Annual cycles make the benign case look identical. On a yearly subscription, an item sitting pending for eleven months is completely healthy. On a monthly one, the same age means two cycles have already passed without picking it up, which means it is not going to be picked up at all. Age alone cannot tell them apart, which is why the subscription check matters.

The fix, as a flow

The script buckets pending items by customer and then asks the only question that decides anything: is any invoice still scheduled for that customer at all.

GET invoiceitemspending=true, bucketed by customerLive sub, under a cyclewaiting, leave itLive sub, past a cycleconfirm the intervalLive sub, past twostalled, look at itNo active subscriptionorphaned at any age
Age alone cannot separate an annual cycle from an item nothing will ever collect.

How to fix it

List pending items and bucket them by customer

GET /v1/invoiceitems?pending=true&limit=100, paginated. Per customer, keep the count, the sum of amount, and the date of the oldest item. The per-customer view matters because the repair is per customer: one invoice sweeps up everything pending for that customer at once.

Ask whether an invoice is still coming

GET /v1/subscriptions?customer={cus}&status=active&limit=1. A non-empty result means something will bill this customer again and the item has a route to an invoice. An empty result is the finding: no scheduled invoice exists, so the item is orphaned regardless of how new it is.

Use age only to sort the customers who still have a subscription

Past a monthly cycle plus slack, a pending item on a live subscription has already survived a billing run, which usually means the cycle is annual or the subscription was created after the item. Both are worth a look; neither is the same emergency as an orphan.

Sum the exposure in minor units and report it

The number that gets this scheduled is currency and cents: "EUR 41,900 across 23 customers, oldest item 412 days". Group by currency rather than adding across currencies, which is the mistake that gets the whole report distrusted.

Sweep, or delete, per customer

For items still owed: create an invoice for that customer with collection_method=charge_automatically and auto_advance=true, then finalize it. Everything pending for that customer is pulled onto it. For items no longer owed, delete them: DELETE /v1/invoiceitems/{id} works while the item is still unattached, which is the only period during which it can be cleanly removed.

Stop the next one

Set pending_invoice_item_interval on subscriptions that regularly accrue one-off charges, so Stripe raises an invoice on its own schedule instead of waiting for the next renewal. Where a change should bill immediately, use proration_behavior=always_invoice rather than create_prorations.

How to check it worked

Re-run the script after the sweep. Every remaining pending item should belong to a customer with a live subscription and be younger than a cycle.

python3 stripe_pending_invoice_items.py
# waiting     cus_123  2 item(s), 4,500 minor unit(s), oldest 6d, next invoice due

The full code

Two GETs and no writes — a restricted key with read access to Invoices and Subscriptions is enough, and is what you should give it. The classifier is pure and takes the age of the oldest item, whether the customer still has an active subscription, and how many items are stacked up, because age on its own cannot tell an annual cycle apart from an item that has been abandoned.

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_pending_invoice_items.py
"""Report Stripe invoice items left pending with no invoice coming to collect them.

Read only. Two GETs, no writes: give this a RESTRICTED key with read access to
Invoices and 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_pending_invoice_items")

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

FRESH_DAYS = 1     # created today; a manual invoice may be seconds behind it
SWEEP_DAYS = 35    # a monthly cycle plus slack: one invoice should have run
STALE_DAYS = 60    # two cycles missed, or an annual plan worth confirming


def verdict(age_days, has_active_subscription, item_count):
    """Classify one customer's pending invoice items. Pure, so it can be tested.

    `age_days` is the age of that customer's oldest pending item, and
    `has_active_subscription` whether any invoice is still scheduled for them at
    all. Returns (state, detail).
    """
    stack = "%d pending item(s), oldest %.0fd" % (item_count, age_days)
    if not has_active_subscription:
        if age_days < FRESH_DAYS:
            return ("fresh",
                    "%s, no active subscription. Probably an invoice being built "
                    "right now; check again tomorrow." % stack)
        return ("orphaned",
                "%s, and no active subscription to raise an invoice. Nothing will "
                "ever sweep these up." % stack)
    if age_days < SWEEP_DAYS:
        return ("waiting", "%s, next invoice still due" % stack)
    if age_days < STALE_DAYS:
        return ("aging",
                "%s, past a monthly cycle. Fine on an annual plan, a miss on a "
                "monthly one." % stack)
    return ("stalled",
            "%s, past two monthly cycles with a live subscription. Confirm the "
            "billing interval before assuming this is benign." % stack)


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")
    if r.status_code == 403:
        raise SystemExit("403 from Stripe: the restricted key lacks read access to "
                         + path)
    r.raise_for_status()
    return r.json()


def paginate(session, path, **params):
    params = dict(params, limit=params.get("limit", 100))
    while True:
        page = get(session, path, **params)
        data = page.get("data", [])
        for row in data:
            yield row
        if not data or not page.get("has_more"):
            return
        params["starting_after"] = data[-1]["id"]


def bucket_by_customer(items):
    """Group pending items per customer, keeping the oldest date and the totals.

    Amounts are kept per currency. Summing across currencies produces a number
    that is wrong in a way nobody can see, which is how a report gets distrusted.
    """
    buckets = {}
    for it in items:
        cus = it.get("customer")
        if not cus:
            continue
        b = buckets.setdefault(cus, {"count": 0, "oldest": None, "amounts": {}})
        b["count"] += 1
        date = it.get("date") or it.get("created")
        if date is not None and (b["oldest"] is None or date < b["oldest"]):
            b["oldest"] = date
        cur = (it.get("currency") or "???").upper()
        b["amounts"][cur] = b["amounts"].get(cur, 0) + (it.get("amount") or 0)
    return buckets


def has_active_subscription(session, customer):
    page = get(session, "/subscriptions", customer=customer, status="active", limit=1)
    return bool(page.get("data"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-items", type=int, default=5000,
                    help="stop paginating after this many pending invoice items")
    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})

    items = []
    for it in paginate(s, "/invoiceitems", pending="true"):
        items.append(it)
        if len(items) >= args.max_items:
            break

    buckets = bucket_by_customer(items)
    now = time.time()
    findings = 0
    exposure = {}

    for cus, b in sorted(buckets.items(), key=lambda kv: kv[1]["oldest"] or 0):
        age = 0.0 if b["oldest"] is None else (now - b["oldest"]) / 86400.0
        live = has_active_subscription(s, cus)
        state, detail = verdict(age, live, b["count"])
        money = ", ".join("%s %d" % (c, v) for c, v in sorted(b["amounts"].items()))

        line = "%-11s %s  %s  [%s minor unit(s)]" % (state, cus, detail, money)
        if state in ("waiting", "fresh"):
            log.info(line)
            continue

        findings += 1
        for c, v in b["amounts"].items():
            exposure[c] = exposure.get(c, 0) + v
        log.warning(line)
        if state == "orphaned":
            log.warning("  raise one invoice for this customer to sweep every "
                        "pending item onto it, then finalize it; or delete the "
                        "items that are no longer owed while they are unattached")
        else:
            log.warning("  confirm the billing interval: GET %s/subscriptions"
                        "?customer=%s&status=active", API, cus)

    log.info("%d customer(s) with pending items, %d needing a decision", len(buckets),
             findings)
    for c, v in sorted(exposure.items()):
        log.info("  unbilled exposure: %s %d minor unit(s)", c, v)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-pending-invoice-items.mjs
/**
 * Report Stripe invoice items left pending with no invoice coming to collect them.
 *
 * Read only. Two GETs, no writes: give this a RESTRICTED key with read access to
 * Invoices and Subscriptions. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

export const FRESH_DAYS = 1;  // created today; a manual invoice may be seconds behind
export const SWEEP_DAYS = 35; // a monthly cycle plus slack: one invoice should have run
export const STALE_DAYS = 60; // two cycles missed, or an annual plan worth confirming

/**
 * Classify one customer's pending invoice items. Pure, so it can be tested.
 * Returns [state, detail].
 */
export function verdict(ageDays, hasActiveSubscription, itemCount) {
  const stack = `${itemCount} pending item(s), oldest ${Math.round(ageDays)}d`;
  if (!hasActiveSubscription) {
    if (ageDays < FRESH_DAYS) {
      return ['fresh',
        `${stack}, no active subscription. Probably an invoice being built right ` +
        'now; check again tomorrow.'];
    }
    return ['orphaned',
      `${stack}, and no active subscription to raise an invoice. Nothing will ever ` +
      'sweep these up.'];
  }
  if (ageDays < SWEEP_DAYS) return ['waiting', `${stack}, next invoice still due`];
  if (ageDays < STALE_DAYS) {
    return ['aging',
      `${stack}, past a monthly cycle. Fine on an annual plan, a miss on a monthly one.`];
  }
  return ['stalled',
    `${stack}, past two monthly cycles with a live subscription. Confirm the ` +
    'billing interval before assuming this is benign.'];
}

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.status === 403) {
    throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

async function* paginate(key, path, params = {}) {
  const p = { limit: 100, ...params };
  for (;;) {
    const page = await get(key, path, p);
    const data = page.data ?? [];
    for (const row of data) yield row;
    if (data.length === 0 || !page.has_more) return;
    p.starting_after = data[data.length - 1].id;
  }
}

/**
 * Group pending items per customer, keeping the oldest date and the totals.
 * Amounts stay per currency: summing across currencies is wrong invisibly.
 */
export function bucketByCustomer(items) {
  const buckets = new Map();
  for (const it of items) {
    if (!it.customer) continue;
    if (!buckets.has(it.customer)) {
      buckets.set(it.customer, { count: 0, oldest: null, amounts: {} });
    }
    const b = buckets.get(it.customer);
    b.count += 1;
    const date = it.date ?? it.created;
    if (date !== undefined && date !== null && (b.oldest === null || date < b.oldest)) {
      b.oldest = date;
    }
    const cur = (it.currency ?? '???').toUpperCase();
    b.amounts[cur] = (b.amounts[cur] ?? 0) + (it.amount ?? 0);
  }
  return buckets;
}

async function hasActiveSubscription(key, customer) {
  const page = await get(key, '/subscriptions',
    { customer, status: 'active', limit: 1 });
  return (page.data ?? []).length > 0;
}

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 maxItems = 5000;
  const items = [];
  for await (const it of paginate(key, '/invoiceitems', { pending: 'true' })) {
    items.push(it);
    if (items.length >= maxItems) break;
  }

  const buckets = bucketByCustomer(items);
  const now = Date.now() / 1000;
  let findings = 0;
  const exposure = {};

  const ordered = [...buckets.entries()].sort((a, b) => (a[1].oldest ?? 0) - (b[1].oldest ?? 0));
  for (const [cus, b] of ordered) {
    const age = b.oldest === null ? 0 : (now - b.oldest) / 86400;
    const live = await hasActiveSubscription(key, cus);
    const [state, detail] = verdict(age, live, b.count);
    const money = Object.entries(b.amounts).sort()
      .map(([c, v]) => `${c} ${v}`).join(', ');

    const line = `${state.padEnd(11)} ${cus}  ${detail}  [${money} minor unit(s)]`;
    if (state === 'waiting' || state === 'fresh') { console.log(line); continue; }

    findings += 1;
    for (const [c, v] of Object.entries(b.amounts)) exposure[c] = (exposure[c] ?? 0) + v;
    console.warn(line);
    if (state === 'orphaned') {
      console.warn('  raise one invoice for this customer to sweep every pending ' +
        'item onto it, then finalize it; or delete the items that are no longer ' +
        'owed while they are unattached');
    } else {
      console.warn(`  confirm the billing interval: GET ${API}/subscriptions` +
        `?customer=${cus}&status=active`);
    }
  }

  console.log(`${buckets.size} customer(s) with pending items, ${findings} needing a decision`);
  for (const [c, v] of Object.entries(exposure).sort()) {
    console.log(`  unbilled exposure: ${c} ${v} minor unit(s)`);
  }
  if (findings) 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 hold the one rule that matters: no active subscription means orphaned at any age, because there is no future invoice for the item to wait for. The rest pin the two day boundaries and the currency bucketing, since an exposure figure that silently adds euros to yen is worse than no figure at all.

test_stripe_pending_invoice_items.py
from stripe_pending_invoice_items import bucket_by_customer, verdict


def test_a_live_subscription_means_the_item_is_merely_waiting():
    state, detail = verdict(6.0, True, 2)
    assert state == "waiting"
    assert "2 pending item(s)" in detail


def test_no_subscription_is_orphaned_at_any_age():
    # This is the whole point: age is irrelevant once nothing will ever bill.
    assert verdict(3.0, False, 1)[0] == "orphaned"
    assert verdict(400.0, False, 1)[0] == "orphaned"


def test_an_item_created_today_gets_the_benefit_of_the_doubt():
    assert verdict(0.5, False, 1)[0] == "fresh"
    assert verdict(1.0, False, 1)[0] == "orphaned"


def test_the_cycle_boundaries_separate_aging_from_stalled():
    assert verdict(34.9, True, 1)[0] == "waiting"
    assert verdict(35.0, True, 1)[0] == "aging"
    assert verdict(59.9, True, 1)[0] == "aging"
    assert verdict(60.0, True, 1)[0] == "stalled"


def test_bucketing_keeps_currencies_apart_and_the_oldest_date():
    items = [
        {"customer": "cus_1", "date": 500, "amount": 1000, "currency": "eur"},
        {"customer": "cus_1", "date": 100, "amount": 250, "currency": "eur"},
        {"customer": "cus_1", "date": 900, "amount": 700, "currency": "usd"},
        {"customer": None, "date": 100, "amount": 999, "currency": "eur"},
    ]
    b = bucket_by_customer(items)["cus_1"]
    assert b["count"] == 3
    assert b["oldest"] == 100
    assert b["amounts"] == {"EUR": 1250, "USD": 700}
stripe-pending-invoice-items.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, bucketByCustomer } from './stripe-pending-invoice-items.mjs';

test('a live subscription means the item is merely waiting', () => {
  const [state, detail] = verdict(6, true, 2);
  assert.equal(state, 'waiting');
  assert.ok(detail.includes('2 pending item(s)'));
});

test('no subscription is orphaned at any age', () => {
  assert.equal(verdict(3, false, 1)[0], 'orphaned');
  assert.equal(verdict(400, false, 1)[0], 'orphaned');
});

test('an item created today gets the benefit of the doubt', () => {
  assert.equal(verdict(0.5, false, 1)[0], 'fresh');
  assert.equal(verdict(1, false, 1)[0], 'orphaned');
});

test('the cycle boundaries separate aging from stalled', () => {
  assert.equal(verdict(34.9, true, 1)[0], 'waiting');
  assert.equal(verdict(35, true, 1)[0], 'aging');
  assert.equal(verdict(59.9, true, 1)[0], 'aging');
  assert.equal(verdict(60, true, 1)[0], 'stalled');
});

test('bucketing keeps currencies apart and the oldest date', () => {
  const items = [
    { customer: 'cus_1', date: 500, amount: 1000, currency: 'eur' },
    { customer: 'cus_1', date: 100, amount: 250, currency: 'eur' },
    { customer: 'cus_1', date: 900, amount: 700, currency: 'usd' },
    { customer: null, date: 100, amount: 999, currency: 'eur' },
  ];
  const b = bucketByCustomer(items).get('cus_1');
  assert.equal(b.count, 3);
  assert.equal(b.oldest, 100);
  assert.deepEqual(b.amounts, { EUR: 1250, USD: 700 });
});

FAQ

What exactly is a pending invoice item?

An invoice item whose invoice field is null. It has been created and it belongs to a customer, but no invoice has claimed it yet. Stripe sweeps every pending item for a customer onto the next invoice it raises for them, which is a useful mechanism right up until no next invoice exists.

Why does a cancelled subscription orphan the items rather than billing them?

Cancelling ends future invoice generation for that customer. Stripe does not raise a final invoice to clear pending items, and the items carry no expiry, so they stay in the same state indefinitely. Nothing is broken from Stripe's side, which is why nothing reports it.

Is an old pending item always a problem?

No, and that is why the script checks for an active subscription rather than trusting age. On an annual plan an item can sit pending for eleven months and be collected exactly as intended. Age sorts the list; the presence of a live subscription decides the verdict.

Can I still delete a pending invoice item?

Yes, while it is unattached. DELETE /v1/invoiceitems/{id} works on items with invoice: null. Once an item is on a finalized invoice it is part of a financial document and has to be handled with a credit note instead, so unattached is the cheap window.

How do I stop this happening again?

Set pending_invoice_item_interval on subscriptions that regularly accrue one-off charges, so Stripe raises its own invoice on a schedule rather than waiting for a renewal. For changes that should bill straight away use proration_behavior=always_invoice instead of create_prorations.

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.