Skip to content

Diagnostic Stripe

refunds sit failed or requires_action and nobody notices

Support issued the refund, the ticket was closed, and the money left your Stripe balance. Weeks later the same customer opens a dispute for the same transaction, because it never arrived. You now pay the amount twice and the dispute fee on top, and the only record of what went wrong was a status change on an object nobody was watching.

Read-only key Python and Node.js Tests included
Two server racks
Photo by Eric Stoynov on Unsplash
The short answer

Paginate GET /v1/refunds over the last 180 days and flag every refund whose status is "failed" or "requires_action", grouped by failure_reason. Also flag anything still pending after ten days and read its pending_reason.

Sum amount across the failed ones. That total is money that was debited from your balance and reached nobody, and it is the number that gets this prioritised.

The problem in plain words

Creating a Refund returns a 200 and an object, and most refund code stops reading there. But the object is not terminal at creation. It moves through pending, and it can land on failed or requires_action days later, when the original request is long out of scope and nothing is left holding a reference to it.

The failure is worse than a refund that never happened, because your side is confident it did. The support ticket is closed, the order shows as refunded, the ledger shows the debit. The customer, holding no money, does the only thing left and disputes the charge — which costs you the amount again plus a fee, and lands as a dispute rather than as the refund failure it actually is.

Refund createdAPI returns 200Status goesfailedcard was closedNo handlerlisteningcharge.refund.updatedCustomer waitstold it was sentDispute filedyou pay twice
The support ticket is closed and the ledger shows the debit, so only the customer knows the money never arrived.

Why it happens

The card is gone. expired_or_canceled_card and lost_or_stolen_card are the common failure reasons, and both mean the card that paid you no longer exists. Retrying the same refund will fail the same way every time, so this is not a retry problem; it needs an out-of-band payment.

Nothing subscribes to the update. The status change is announced as charge.refund.updated. Integrations that only handle charge.refunded, or that handle no refund events at all, get the optimistic first answer and never the correction.

Some refunds need the customer to act. requires_action means Stripe has instructions for the recipient, in refund.next_action, that somebody has to pass on. Nobody passes on a link they never knew existed.

The ledger double-counts. A failed refund is re-credited to your balance through failure_balance_transaction. Reconciliation that reads only the original debit shows money leaving that came back, which makes the discrepancy look like a rounding problem rather than a customer who is owed money.

The fix, as a flow

The script reads 180 days of refunds and keeps the ones that never completed, grouped by failure reason and summed by amount, because that total is money debited from your balance that reached nobody.

GET /v1/refundsstatus, failure_reason, agesucceededsettled, nothing to dofailedopen ticket, pay out of bandpending over 10 daysread pending_reason
Failed and requires_action are not the same obligation: one is money you owe, the other is a message you owe.

How to fix it

Scan 180 days of refunds and read the status

A long window matters here because the loss is discovered late, usually by a dispute. Refunds that failed months ago are still unresolved customer obligations even though nothing in your system says so.

Group the failures by failure_reason

expired_or_canceled_card and lost_or_stolen_card need a different payment path. insufficient_funds and declined can sometimes be retried. charge_for_pending_refund_disputed means the customer already escalated and the dispute is now the live thread.

Flag pending refunds older than ten days

Most refunds settle in five to ten business days. Past that, pending_reason says whether it is genuinely still processing, waiting on funds in your balance, or blocked because the original charge has not settled.

Sum the amounts

The count understates this badly. One failed refund on a large order is a bigger liability than twenty small ones, and the summed figure is what tells you whether this is a backlog to work through or a single call to make this afternoon.

Subscribe to charge.refund.updated and treat failed as a ticket

This is the actual fix. A failed refund is an open customer obligation, not a log line, and it should create work in whatever system your support team lives in. For requires_action, follow next_action and send the customer the instructions.

How to check it worked

Re-run the script after the webhook handler ships. Failed refunds will still appear — they are historical — but each one should now correspond to an open ticket rather than to nothing.

python3 stripe_refund_health.py --days 180
# 486 refund(s): 0 failed, 0 needing action, 2 stalled pending

The full code

One paginated GET against Refunds and no writes — a restricted key with read access to Refunds is enough. The classifier takes the clock as an argument so the ten-day pending rule is testable, and it keeps failed and requires_action in separate states because one of them is money you still owe and the other is a message you still owe.

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_refund_health.py
"""Report Stripe refunds that failed, stalled, or are waiting on the customer.

Read only. One paginated GET, no writes: give this a RESTRICTED key with read
access to Refunds. 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_refund_health")

API = "https://api.stripe.com/v1"
PENDING_SECONDS = 10 * 86400

# Reasons where retrying the same card is pointless: the card is gone.
DEAD_CARD = ("expired_or_canceled_card", "lost_or_stolen_card")


def classify(refund, now, pending_after=PENDING_SECONDS):
    """Classify one refund. Pure, so the rules can be tested without a network.

    Returns (state, detail). `failed` and `requires_action` stay apart on purpose:
    the first is money you still owe the customer, the second is an instruction
    you still owe them.
    """
    status = refund.get("status")
    if status == "failed":
        reason = refund.get("failure_reason") or "unknown"
        if reason in DEAD_CARD:
            return ("failed",
                    "%s: the card no longer exists, so a retry fails the same way. "
                    "Refund out of band." % reason)
        return ("failed",
                "%s: the money left your balance and reached nobody" % reason)
    if status == "requires_action":
        return ("needs-action",
                "the customer has to follow refund.next_action before this completes")
    if status == "pending":
        created = refund.get("created")
        if not isinstance(created, int):
            return ("unknown", "pending with no created timestamp, so it cannot be aged")
        days = int((now - created) // 86400)
        if now - created < pending_after:
            return ("pending", "%dd old, inside the normal settlement window" % days)
        return ("stalled",
                "%dd old and still pending (%s)"
                % (days, refund.get("pending_reason") or "no pending_reason"))
    if status in ("succeeded", "canceled"):
        return ("settled", "status %r" % (status,))
    return ("unknown", "unrecognised status %r" % (status,))


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 refunds(session, since, cap):
    """Yield refunds created since `since`, newest first, up to `cap`."""
    seen = 0
    params = {"limit": 100, "created[gte]": since}
    while True:
        page = get(session, "/refunds", **params)
        data = page.get("data", [])
        for rf in data:
            yield rf
            seen += 1
            if seen >= cap:
                return
        if not page.get("has_more") or not data:
            return
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=180,
                    help="how far back to scan (default 180)")
    ap.add_argument("--pending-days", type=int, default=10,
                    help="age at which a pending refund counts as stalled")
    ap.add_argument("--max-refunds", type=int, default=5000,
                    help="stop paginating after this many refunds")
    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 = int(time.time())
    since = now - args.days * 86400
    pending_after = args.pending_days * 86400

    counts = {}
    by_reason = {}
    lost = 0
    scanned = 0

    for rf in refunds(s, since, args.max_refunds):
        scanned += 1
        state, detail = classify(rf, now, pending_after)
        counts[state] = counts.get(state, 0) + 1
        if state in ("failed", "needs-action", "stalled"):
            log.warning("%s  charge=%s  %s", rf["id"], rf.get("charge") or "?", detail)
        if state == "failed":
            lost += int(rf.get("amount") or 0)
            reason = rf.get("failure_reason") or "unknown"
            by_reason[reason] = by_reason.get(reason, 0) + 1

    failed = counts.get("failed", 0)
    needs = counts.get("needs-action", 0)
    stalled = counts.get("stalled", 0)

    log.info("%d refund(s): %d failed, %d needing action, %d stalled pending",
             scanned, failed, needs, stalled)

    for reason, n in sorted(by_reason.items(), key=lambda kv: -kv[1]):
        log.warning("  %-34s %d", reason, n)

    if failed:
        log.warning("  %d in minor units left your balance and reached nobody", lost)
        log.warning("  repair: subscribe to charge.refund.updated and open a support "
                    "ticket for every status == failed")
        log.warning("  repair: for a dead card, pay the customer out of band; "
                    "retrying the same refund fails identically")
        log.warning("  check: reconcile against failure_balance_transaction so the "
                    "re-credit is not read as a second refund")
    if needs:
        log.warning("  repair: read GET %s/refunds/{id} and send the customer the "
                    "link in next_action", API)
    if stalled:
        log.warning("  check: pending_reason says whether this is settlement, your "
                    "balance, or an unsettled original charge")
    return 1 if (failed or needs or stalled) else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-refund-health.mjs
/**
 * Report Stripe refunds that failed, stalled, or are waiting on the customer.
 *
 * Read only. One paginated GET, no writes: give this a RESTRICTED key with read
 * access to Refunds. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';
const PENDING_SECONDS = 10 * 86400;

// Reasons where retrying the same card is pointless: the card is gone.
const DEAD_CARD = ['expired_or_canceled_card', 'lost_or_stolen_card'];

/**
 * Classify one refund. Pure, so the rules can be tested without a network.
 * `failed` and `requires_action` stay apart on purpose: the first is money you
 * still owe the customer, the second is an instruction you still owe them.
 */
export function classify(refund, now, pendingAfter = PENDING_SECONDS) {
  const status = refund.status;
  if (status === 'failed') {
    const reason = refund.failure_reason ?? 'unknown';
    if (DEAD_CARD.includes(reason)) {
      return ['failed',
        `${reason}: the card no longer exists, so a retry fails the same way. ` +
        'Refund out of band.'];
    }
    return ['failed', `${reason}: the money left your balance and reached nobody`];
  }
  if (status === 'requires_action') {
    return ['needs-action',
      'the customer has to follow refund.next_action before this completes'];
  }
  if (status === 'pending') {
    const created = refund.created;
    if (!Number.isInteger(created)) {
      return ['unknown', 'pending with no created timestamp, so it cannot be aged'];
    }
    const days = Math.floor((now - created) / 86400);
    if (now - created < pendingAfter) {
      return ['pending', `${days}d old, inside the normal settlement window`];
    }
    return ['stalled',
      `${days}d old and still pending (${refund.pending_reason ?? 'no pending_reason'})`];
  }
  if (status === 'succeeded' || status === 'canceled') {
    return ['settled', `status ${JSON.stringify(status)}`];
  }
  return ['unknown', `unrecognised status ${JSON.stringify(status)}`];
}

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* refunds(key, since, cap) {
  let seen = 0;
  const params = { limit: 100, 'created[gte]': since };
  for (;;) {
    const page = await get(key, '/refunds', params);
    const data = page.data ?? [];
    for (const rf of data) {
      yield rf;
      seen += 1;
      if (seen >= cap) return;
    }
    if (!page.has_more || data.length === 0) return;
    params.starting_after = data[data.length - 1].id;
  }
}

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.env.DAYS ?? 180);
  const pendingAfter = Number(process.env.PENDING_DAYS ?? 10) * 86400;
  const now = Math.floor(Date.now() / 1000);
  const since = now - days * 86400;

  const counts = new Map();
  const byReason = new Map();
  let lost = 0;
  let scanned = 0;

  for await (const rf of refunds(key, since, 5000)) {
    scanned += 1;
    const [state, detail] = classify(rf, now, pendingAfter);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    if (['failed', 'needs-action', 'stalled'].includes(state)) {
      console.warn(`${rf.id}  charge=${rf.charge ?? '?'}  ${detail}`);
    }
    if (state === 'failed') {
      lost += rf.amount ?? 0;
      const reason = rf.failure_reason ?? 'unknown';
      byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
    }
  }

  const failed = counts.get('failed') ?? 0;
  const needs = counts.get('needs-action') ?? 0;
  const stalled = counts.get('stalled') ?? 0;

  console.log(`${scanned} refund(s): ${failed} failed, ${needs} needing action, ` +
              `${stalled} stalled pending`);

  for (const [reason, n] of [...byReason].sort((a, b) => b[1] - a[1])) {
    console.warn(`  ${reason.padEnd(34)} ${n}`);
  }

  if (failed) {
    console.warn(`  ${lost} in minor units left your balance and reached nobody`);
    console.warn('  repair: subscribe to charge.refund.updated and open a support ' +
                 'ticket for every status == failed');
    console.warn('  repair: for a dead card, pay the customer out of band; ' +
                 'retrying the same refund fails identically');
    console.warn('  check: reconcile against failure_balance_transaction so the ' +
                 're-credit is not read as a second refund');
  }
  if (needs) {
    console.warn(`  repair: read GET ${API}/refunds/{id} and send the customer the ` +
                 'link in next_action');
  }
  if (stalled) {
    console.warn('  check: pending_reason says whether this is settlement, your ' +
                 'balance, or an unsettled original charge');
  }
  process.exitCode = (failed || needs || stalled) ? 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 tests hold three lines in place. A dead card has to be reported as unretryable, because a retry loop against it is the failure mode that turns one unhappy customer into a monthly job that never converges. A pending refund inside the settlement window is normal and must not be alarmed on. And an unrecognised status has to surface as unknown rather than fall through to settled, since a status Stripe adds later would otherwise be silently treated as money delivered.

test_stripe_refund_health.py
from stripe_refund_health import classify

NOW = 1_800_000_000
DAY = 86400


def test_dead_card_is_reported_as_unretryable():
    state, detail = classify(
        {"status": "failed", "failure_reason": "expired_or_canceled_card"}, NOW)
    assert state == "failed"
    assert "out of band" in detail


def test_other_failures_say_the_money_reached_nobody():
    state, detail = classify(
        {"status": "failed", "failure_reason": "insufficient_funds"}, NOW)
    assert state == "failed"
    assert "reached nobody" in detail


def test_requires_action_is_not_a_failure():
    state, detail = classify({"status": "requires_action"}, NOW)
    assert state == "needs-action"
    assert "next_action" in detail


def test_pending_inside_the_window_is_normal():
    assert classify({"status": "pending", "created": NOW - 3 * DAY}, NOW)[0] == "pending"


def test_long_pending_is_stalled_and_unknown_status_is_not_settled():
    stalled, detail = classify(
        {"status": "pending", "created": NOW - 30 * DAY,
         "pending_reason": "charge_pending"}, NOW)
    assert stalled == "stalled"
    assert "charge_pending" in detail
    # A status Stripe adds later must not be read as money delivered.
    assert classify({"status": "reversed"}, NOW)[0] == "unknown"
stripe-refund-health.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-refund-health.mjs';

const NOW = 1800000000;
const DAY = 86400;

test('dead card is reported as unretryable', () => {
  const [state, detail] = classify(
    { status: 'failed', failure_reason: 'expired_or_canceled_card' }, NOW);
  assert.equal(state, 'failed');
  assert.match(detail, /out of band/);
});

test('other failures say the money reached nobody', () => {
  const [state, detail] = classify(
    { status: 'failed', failure_reason: 'insufficient_funds' }, NOW);
  assert.equal(state, 'failed');
  assert.match(detail, /reached nobody/);
});

test('requires_action is not a failure', () => {
  const [state, detail] = classify({ status: 'requires_action' }, NOW);
  assert.equal(state, 'needs-action');
  assert.match(detail, /next_action/);
});

test('pending inside the window is normal', () => {
  assert.equal(classify({ status: 'pending', created: NOW - 3 * DAY }, NOW)[0], 'pending');
});

test('long pending is stalled and unknown status is not settled', () => {
  const [state, detail] = classify(
    { status: 'pending', created: NOW - 30 * DAY, pending_reason: 'charge_pending' }, NOW);
  assert.equal(state, 'stalled');
  assert.match(detail, /charge_pending/);
  // A status Stripe adds later must not be read as money delivered.
  assert.equal(classify({ status: 'reversed' }, NOW)[0], 'unknown');
});

FAQ

Is a refund final once the API returns success?

No. Creating a refund returns an object in pending or succeeded, and a pending refund can still land on failed or requires_action days later. The only way to know the outcome is to read the refund again or to handle the charge.refund.updated event.

What does expired_or_canceled_card mean for the customer?

It means the card that paid you has been closed or replaced since the charge, so there is no destination for the money. Stripe re-credits the amount to your balance and the customer is still owed it. Retrying the same refund produces the same failure, so the resolution has to be a bank transfer, a credit, or a payment to a card they still hold.

How long should a pending refund take?

Usually five to ten business days depending on the card network and the issuing bank. Past ten days, read pending_reason: processing means it is genuinely in flight, insufficient_funds means your Stripe balance could not cover it, and charge_pending means the original charge has not settled yet.

Why does a failed refund lead to a dispute?

Because from the customer's side nothing distinguishes a failed refund from a refund you never issued. They were told the money was coming, it did not arrive, and the only lever they have left is their bank. You then pay the amount again plus the dispute fee, for a refund you already tried to make.

Does this need more than a read-only key?

No. Read access to Refunds covers every call. The script never creates or cancels a refund, which matters more here than anywhere else in this section: a bug in a script that can issue refunds is a bug that moves money out of your account.

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.