Skip to content

Diagnostic Stripe

session status is complete but payment_status is still unpaid

The handler listens for checkout.session.completed, marks the order paid and ships it. Most of the time that is correct. For ACH, SEPA and every other delayed payment method it is a guess, and a few days later some of those guesses come back as checkout.session.async_payment_failed with the goods already gone.

Read-only key Python and Node.js Tests included
Group of people standing in front of people
Photo by adrianna geo on Unsplash
The short answer

status and payment_status are two different fields and status: "complete" explicitly allows payment to still be in progress. Page GET /v1/checkout/sessions?status=complete&created[gte]=<now-90d> and flag every session where payment_status == "unpaid".

To see which way each one went, expand the intent: GET /v1/checkout/sessions/{cs_id}?expand[]=payment_intent. payment_intent.status == "processing" is money in flight; requires_payment_method or canceled means it already failed and the fulfilment needs reversing. The fix in the code is one condition: gate fulfilment on payment_status != "unpaid".

The problem in plain words

The bug is invisible on a card-only account, which is where the code was written and tested. Cards authorise synchronously, so by the time checkout.session.completed arrives the money really is there and payment_status really is paid. Fulfilling on the event is correct a hundred times out of a hundred, so the shortcut is never punished and becomes the pattern everyone copies.

Then somebody enables ACH Direct Debit, or SEPA, or Boleto for a new market, in a Dashboard setting that does not touch your code at all. Now some sessions complete with payment_status: "unpaid" and settle days later. Most of them settle fine. The ones that do not arrive as checkout.session.async_payment_failed, an event the handler has no branch for, for orders that shipped last Tuesday.

ACH enableda DashboardsettingSessioncompletespayment_statusunpaidcompleted fireshandler ships theorderDebit failsdays laterNo branch foritgoods already gone
status and payment_status are independent fields, and complete explicitly allows the payment to still be in progress.

Why it happens

The field names encourage the mistake. "Complete" sounds terminal in a way that "the customer finished the form, and payment may still be processing" does not. Stripe's own reference says as much in the object docs, but the reading that survives is the one the word suggests.

The event fires at the same moment either way. checkout.session.completed arrives when the customer finishes checkout, not when the money settles. There is nothing different about the delivery, the timing or the payload shape to distinguish a card payment from an ACH debit — only one field inside it.

Enabling a payment method is a configuration change, not a deploy. Somebody in finance turns on bank debits to cut card fees. No pull request, no review, no test. The first delayed payment is also the first time your fulfilment logic is wrong, and nobody involved connected the two.

The failure lands days after the fulfilment, on a different event. checkout.session.async_payment_failed is a separate event type that has to be subscribed to and handled. If it is not on the endpoint, the failure is a line in the Stripe Dashboard that nobody is reading, and the loss shows up eventually as an unexplained gap between orders and revenue.

The fix, as a flow

The script flags complete sessions whose payment_status is unpaid, then expands the PaymentIntent, because still processing and already failed look identical on the session itself.

Session plus expanded intentstatus, payment_status, intentstatuspaidfulfilment is safeno_payment_requirednothing to collectunpaid, processingwait for the async eventunpaid, intent deadunwind the fulfilment
Unpaid and processing is a normal bank debit. Unpaid with a dead intent is stock that has already left the warehouse.

How to fix it

Find complete sessions that were never paid

Paginate GET /v1/checkout/sessions?status=complete&created[gte]=<unix>&limit=100 and flag payment_status == "unpaid". Ninety days is a sensible window: long enough to include settled and failed async payments, short enough to stay one job.

Expand the PaymentIntent to see which way it went

GET /v1/checkout/sessions/{cs_id}?expand[]=payment_intent. processing means it is still in flight and there is nothing to do but wait. requires_payment_method or canceled means the payment failed after the session completed, and anything you fulfilled against it needs unwinding.

Check which delayed methods are enabled at all

payment_method_types on the sessions tells you which methods are actually in play. us_bank_account, sepa_debit, boleto, konbini and oxxo all settle asynchronously. Seeing any of them on a live session is enough to know this failure mode applies to you today.

Gate fulfilment on payment_status, not on the event

One condition in the handler: fulfil only when payment_status != "unpaid". That covers paid and no_payment_required, which is Stripe's own reference implementation and the whole fix on the happy path.

Subscribe to both async events and give each a branch

Add checkout.session.async_payment_succeeded and checkout.session.async_payment_failed to the event destination alongside completed. Succeeded is where a delayed order actually gets fulfilled; failed is where it gets cancelled, and without it the failure has nowhere to land.

How to check it worked

Re-run the script over the same window. Any remaining unpaid sessions should be genuinely in flight rather than already failed.

python3 stripe_unpaid_complete_sessions.py --days 90
# processing  cs_test_a1B2  complete but unpaid, and the PaymentIntent is still processing.
# 0 session(s) fulfilled against a payment that has already failed

The full code

One paginated GET plus one expanded retrieve per flagged session, and no writes — a restricted key with read access to Checkout Sessions and PaymentIntents is enough. The classifier is pure and takes four fields off the session, because the distinction that matters here — still in flight versus already failed — is a rule about field values and deserves to be tested as one.

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_unpaid_complete_sessions.py
"""Report Stripe Checkout Sessions that are complete but were never paid.

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

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

# Payment methods that settle after the session completes rather than during it.
DELAYED = ("us_bank_account", "sepa_debit", "boleto", "konbini", "oxxo")

# PaymentIntent states that mean the async payment is already lost.
DEAD_INTENT = ("requires_payment_method", "canceled")


def verdict(status, payment_status, intent_status=None, methods=None):
    """Classify one Checkout Session. Pure, so the rules can be tested offline.

    Takes the session's `status` and `payment_status`, the expanded
    `payment_intent.status` where one was fetched, and `payment_method_types`.
    Returns (state, detail).
    """
    if status != "complete":
        return ("skipped",
                "status is %r; this check only looks at complete sessions" % status)
    if payment_status == "no_payment_required":
        return ("free", "nothing to collect on this session")
    if payment_status == "paid":
        return ("paid", "payment_status is paid; fulfilment is safe")

    delayed = sorted(m for m in (methods or []) if m in DELAYED)
    note = (" Delayed method(s) on the session: %s." % ", ".join(delayed)) if delayed else ""
    if intent_status == "processing":
        return ("processing",
                "complete but unpaid, and the PaymentIntent is still processing. "
                "Wait for checkout.session.async_payment_succeeded before "
                "fulfilling." + note)
    if intent_status in DEAD_INTENT:
        return ("failed",
                "complete but unpaid, and the PaymentIntent is %s: the payment "
                "failed after the session completed. Anything fulfilled against "
                "it has to be unwound." % intent_status + note)
    return ("unpaid",
            "complete but payment_status is unpaid, and the PaymentIntent state is "
            "%s. Do not treat completed as paid." % (intent_status or "unknown") + note)


def get(session, path, params=None):
    r = session.get(API + path, params=params or {}, 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 complete_sessions(session, since, cap):
    """Every session Stripe considers complete in the window."""
    out = []
    params = {"status": "complete", "created[gte]": since, "limit": 100}
    while True:
        page = get(session, "/checkout/sessions", params)
        data = page.get("data", [])
        out.extend(data)
        if not data or not page.get("has_more") or len(out) >= cap:
            break
        params["starting_after"] = data[-1]["id"]
    return out


def intent_status(session, cs_id):
    """Expand the PaymentIntent for one session, so a failure can be told from a wait."""
    cs = get(session, "/checkout/sessions/" + cs_id, {"expand[]": "payment_intent"})
    intent = cs.get("payment_intent")
    if isinstance(intent, dict):
        return intent.get("status")
    return None


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=90,
                    help="how far back to look for complete sessions")
    ap.add_argument("--max-sessions", type=int, default=5000,
                    help="stop paginating after this many sessions")
    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
    failed = 0
    waiting = 0
    for cs in complete_sessions(s, since, args.max_sessions):
        if cs.get("payment_status") != "unpaid":
            continue
        pi = intent_status(s, cs["id"])
        state, detail = verdict(cs.get("status"), cs.get("payment_status"), pi,
                                cs.get("payment_method_types"))
        log.warning("%-11s %-28s %s", state, cs["id"], detail)
        if state == "failed":
            failed += 1
        else:
            waiting += 1

    log.info("%d session(s) fulfilled against a payment that has already failed, "
             "%d still in flight", failed, waiting)
    if failed or waiting:
        log.warning("  repair: gate fulfilment on payment_status != \"unpaid\", not "
                    "on the completed event alone")
        log.warning("  and subscribe the event destination to "
                    "checkout.session.async_payment_succeeded and "
                    "checkout.session.async_payment_failed")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-unpaid-complete-sessions.mjs
/**
 * Report Stripe Checkout Sessions that are complete but were never paid.
 *
 * Read only. GETs only, no writes: give this a RESTRICTED key with read access to
 * Checkout Sessions and PaymentIntents. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// Payment methods that settle after the session completes rather than during it.
export const DELAYED = ['us_bank_account', 'sepa_debit', 'boleto', 'konbini', 'oxxo'];

// PaymentIntent states that mean the async payment is already lost.
const DEAD_INTENT = ['requires_payment_method', 'canceled'];

/**
 * Classify one Checkout Session. Pure, so the rules can be tested offline.
 * status and payment_status are independent: complete does not mean paid.
 */
export function verdict(status, paymentStatus, intentStatus = null, methods = null) {
  if (status !== 'complete') {
    return ['skipped',
      `status is '${status}'; this check only looks at complete sessions`];
  }
  if (paymentStatus === 'no_payment_required') {
    return ['free', 'nothing to collect on this session'];
  }
  if (paymentStatus === 'paid') {
    return ['paid', 'payment_status is paid; fulfilment is safe'];
  }

  const delayed = (methods ?? []).filter((m) => DELAYED.includes(m)).sort();
  const note = delayed.length
    ? ` Delayed method(s) on the session: ${delayed.join(', ')}.`
    : '';
  if (intentStatus === 'processing') {
    return ['processing',
      'complete but unpaid, and the PaymentIntent is still processing. Wait for ' +
      'checkout.session.async_payment_succeeded before fulfilling.' + note];
  }
  if (DEAD_INTENT.includes(intentStatus)) {
    return ['failed',
      `complete but unpaid, and the PaymentIntent is ${intentStatus}: the payment ` +
      'failed after the session completed. Anything fulfilled against it has to ' +
      'be unwound.' + note];
  }
  return ['unpaid',
    'complete but payment_status is unpaid, and the PaymentIntent state is ' +
    `${intentStatus ?? 'unknown'}. Do not treat completed as paid.` + note];
}

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 completeSessions(key, since, cap = 5000) {
  const out = [];
  const params = { status: 'complete', 'created[gte]': since, limit: 100 };
  for (;;) {
    const page = await get(key, '/checkout/sessions', params);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= cap) break;
    params.starting_after = data[data.length - 1].id;
  }
  return out;
}

export async function intentStatus(key, csId) {
  const cs = await get(key, `/checkout/sessions/${csId}`, { 'expand[]': 'payment_intent' });
  const intent = cs.payment_intent;
  return intent && typeof intent === 'object' ? intent.status : null;
}

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 days = Number(process.argv[2] ?? 90);
  const since = Math.floor(Date.now() / 1000) - days * 86400;
  let failed = 0;
  let waiting = 0;
  for (const cs of await completeSessions(key, since)) {
    if (cs.payment_status !== 'unpaid') continue;
    const pi = await intentStatus(key, cs.id);
    const [state, detail] = verdict(cs.status, cs.payment_status, pi,
      cs.payment_method_types);
    console.warn(`${state.padEnd(11)} ${cs.id.padEnd(28)} ${detail}`);
    if (state === 'failed') failed += 1; else waiting += 1;
  }

  console.log(`${failed} session(s) fulfilled against a payment that has already ` +
              `failed, ${waiting} still in flight`);
  if (failed || waiting) {
    console.warn('  repair: gate fulfilment on payment_status != "unpaid", not on ' +
                 'the completed event alone');
    console.warn('  and subscribe the event destination to ' +
                 'checkout.session.async_payment_succeeded and ' +
                 'checkout.session.async_payment_failed');
  }
  process.exitCode = failed ? 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 distinction these tests protect is the one the whole check exists for: unpaid and still processing is a normal ACH payment doing exactly what it is supposed to, and unpaid with a dead PaymentIntent is stock that has already left the warehouse. Collapsing them into one alert makes the check unusable within a week, because the first kind vastly outnumbers the second.

test_stripe_unpaid_complete_sessions.py
from stripe_unpaid_complete_sessions import verdict


def test_a_paid_session_is_safe_to_fulfil():
    assert verdict("complete", "paid")[0] == "paid"


def test_an_open_session_is_not_this_checks_business():
    assert verdict("open", "unpaid")[0] == "skipped"


def test_unpaid_while_the_intent_processes_is_money_in_flight():
    state, detail = verdict("complete", "unpaid", "processing", ["us_bank_account"])
    assert state == "processing"
    assert "us_bank_account" in detail


def test_a_dead_intent_means_fulfilment_has_to_be_unwound():
    # Same status, same payment_status as the test above. Only the intent differs,
    # and it is the difference between waiting and having lost the goods.
    state, detail = verdict("complete", "unpaid", "requires_payment_method")
    assert state == "failed"
    assert "unwound" in detail


def test_no_payment_required_is_not_unpaid():
    assert verdict("complete", "no_payment_required")[0] == "free"
stripe-unpaid-complete-sessions.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-unpaid-complete-sessions.mjs';

test('a paid session is safe to fulfil', () => {
  assert.equal(verdict('complete', 'paid')[0], 'paid');
});

test('an open session is not this check\'s business', () => {
  assert.equal(verdict('open', 'unpaid')[0], 'skipped');
});

test('unpaid while the intent processes is money in flight', () => {
  const [state, detail] = verdict('complete', 'unpaid', 'processing', ['us_bank_account']);
  assert.equal(state, 'processing');
  assert.match(detail, /us_bank_account/);
});

test('a dead intent means fulfilment has to be unwound', () => {
  const [state, detail] = verdict('complete', 'unpaid', 'requires_payment_method');
  assert.equal(state, 'failed');
  assert.match(detail, /unwound/);
});

test('no payment required is not unpaid', () => {
  assert.equal(verdict('complete', 'no_payment_required')[0], 'free');
});

FAQ

Does checkout.session.completed mean the payment succeeded?

No. It means the customer finished the Checkout flow. status: complete explicitly allows payment to still be in progress, which is why payment_status exists as a separate field. For cards the two line up; for delayed methods they do not.

Which payment methods settle asynchronously?

ACH Direct Debit (us_bank_account), SEPA Direct Debit, Boleto, Konbini and OXXO are the common ones. They complete the session immediately and settle days later, so payment_status stays unpaid in between and resolves through the async events.

What is the actual code fix?

One condition: fulfil only when payment_status is not unpaid. That admits paid and no_payment_required and excludes everything still in flight. It is what Stripe's own fulfilment reference does, and it is correct for card payments too, so there is no reason to special-case anything.

What happens if an async payment fails after I shipped?

Stripe sends checkout.session.async_payment_failed and the PaymentIntent lands on requires_payment_method or canceled. Nothing reverses automatically. You need a branch on that event that cancels the order and, if it has already shipped, raises it for a human. Without the subscription, the failure is only visible in the Dashboard.

Should I fulfil on the session or on the PaymentIntent?

Either works if you check the right field, but the session is usually simpler because it carries your client_reference_id and metadata. Handle completed and async_payment_succeeded on the same code path, gate both on payment_status, and make the path idempotent on the session id so the two events cannot double-fulfil.

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.