Skip to content

Diagnostic Stripe

a report run fails after the 200 and the CSV never lands

Finance says the nightly export has been missing for a week. Your job logs say it succeeded every night, and they are not lying: creating a report run returned 200 and the job exited zero. The run failed twenty seconds later, in Stripe, where nobody was looking.

Read-only key Python and Node.js Tests included
A steel warehouse rack
Photo by CHUTTERSNAP on Unsplash
The short answer

Page GET /v1/reporting/report_runs?limit=100&created[gte]=<30 days ago> and sort every run three ways. status == "failed" carries the reason in error. status == "pending" on a run created more than an hour ago is a failure that has not admitted it yet. And a day with no run at all is the worst case, because it means the job did not even reach Stripe.

Then check whether anyone would have been told: GET /v1/webhook_endpoints?limit=100 and look for reporting.report_run.failed in enabled_events. Every clean run in the window is only reassuring if that subscription exists.

The problem in plain words

The Reports API is asynchronous and the asynchrony is easy to miss. POST /v1/reporting/report_runs returns immediately with status: "pending" and no result. Stripe then does the work and moves the run to succeeded, where result holds a File you can download, or to failed, where error holds a sentence explaining why. A job that treats the 200 as the outcome has already declared victory before either of those happens.

So the failure mode is not an alert nobody read. It is an absence: no file in the bucket, no row in the warehouse, no error anywhere in your logs. Finance notices at month-end, which is between two and thirty days after the first missed night, and by then the question "which nights are we missing?" cannot be answered from your side at all.

What makes it worse is that the causes are boring and recurrent. An interval_end computed in local time drifts an hour twice a year. A report type gets a new version with different required parameters. An interval reaches past what Stripe has finalized. Each of these produces a perfectly clear error string on the run object, sitting in an API nobody is polling.

Job posts a runinterval computedlocallyStripe returns200status pendingJob exits zerosuccess recordedRun failserror on theobjectNo file landsfound at month end
The job exits zero on the receipt. Everything that can go wrong is evaluated afterwards, where nobody is looking.

Why it happens

The 200 is a receipt, not a result. It confirms Stripe accepted the request and created a run object. Everything that can actually go wrong — parameters, intervals, availability — is evaluated afterwards. Any integration whose success check is the HTTP status of the create call is checking the one thing that was never in doubt.

Nobody writes the polling loop, because the first run worked. The Reports API is usually integrated by hand, once, against an interval that was definitely available, and it returns a file within seconds. The loop that waits for status to leave pending feels like ceremony until the first night it does not.

A stuck pending looks like patience. A run that has been pending for six hours is not working on it. But a script written to poll "until it is not pending" with no deadline will wait forever, and one written to check once will record the pending state as fine. Both need the same number: an age past which pending means failed.

The event exists and is almost never subscribed to. reporting.report_run.failed and reporting.report_run.succeeded are real event types. Endpoints are typically created for payment and subscription events by whoever built checkout, months before anyone built a finance export, and nobody goes back to add reporting events to the list.

The fix, as a flow

The script reads what Stripe actually did rather than what the job logged, then asks the question the run list cannot answer on its own: which nights produced no run at all.

GET /v1/reporting/report_runsplus enabled_events on the endpointsAll succeeded, event subscribedclearPending over an hourstalled, treat it as failedStatus failedread error, fix the parameterA day with no runthe scheduler never firedClean but unsubscribednext failure is silent
A window of successful runs is still a broken export if one night has no run in it at all.

How to fix it

Read the runs, not your own job logs

GET /v1/reporting/report_runs over the last 30 days is the only record that reflects what Stripe actually did. Filter with created[gte] so the pagination stays bounded, and read status, error, succeeded_at and parameters on each one.

Treat a long pending as a failure

Pick a deadline and apply it. An hour is generous for the standard report types. A run still pending past that is not going to finish, and reporting it as "running" is how a broken night gets counted as a good one.

Look for the days with no run at all

This is the failure the run list cannot show you directly, because the evidence is an absence. Build the set of UTC dates you expected a run for, subtract the dates that have a succeeded run, and report what is left. A missing day means your scheduler never fired or never reached Stripe, which is a different bug from a run that failed.

Read the error string; it usually names the fix

The error field is a sentence, not a code, and it generally names the parameter at fault. Fix that parameter and re-issue POST /v1/reporting/report_runs, then poll GET /v1/reporting/report_runs/{frr_id} until status leaves pending before you call the job done.

Subscribe to the failure event so next time is loud

Add reporting.report_run.failed and reporting.report_run.succeeded to an endpoint's enabled_events. Polling catches today's problem; the subscription is what stops the next one from taking a week to surface. Verify it landed by reading enabled_events back.

How to check it worked

Re-run the script after fixing the parameters and adding the subscription. Every expected day should carry a successful run, and the failure event should be subscribed.

python3 stripe_report_runs.py --days 30
# clear       30 run(s), 0 failed, 0 stalled, no missing days, failures subscribed

The full code

Two paginated GETs and no writes — a restricted key with read access to Reports and Webhook Endpoints is enough, and is what you should give it. Two pure functions do the judging: one classifies a single run, because the difference between pending and stalled is a number rather than a status, and one folds the run states, the missing days and the subscription into a single verdict.

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_report_runs.py
"""Report Stripe report runs that failed, stalled in pending, or never happened.

Read only. Two paginated GETs and no writes: give this a RESTRICTED key with read
access to Reports and Webhook Endpoints. The repair is printed, never performed,
because this script holds a credential to a live payments account.
"""
import argparse
import datetime as dt
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_report_runs")

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

# A run still pending past this is not being worked on. The standard report types
# resolve in seconds; an hour is already several orders of magnitude of slack.
STALL_SECONDS = 3600

FAILURE_EVENT = "reporting.report_run.failed"


def run_state(status, age_seconds, error=None):
    """Classify one report run. Pure, so the pending deadline can be tested offline.

    `age_seconds` is how long ago the run was created. Returns (state, detail).
    """
    if status == "succeeded":
        return ("succeeded", "finished, result file available")
    if status == "failed":
        return ("failed", error or "failed with no error message on the run")
    if status == "pending":
        if age_seconds is not None and age_seconds >= STALL_SECONDS:
            return ("stalled",
                    "pending for %.1f hour(s); nothing is working on it, treat it "
                    "as failed" % (age_seconds / 3600.0))
        return ("running", "pending, still inside the %d second window"
                % STALL_SECONDS)
    return ("unknown", "unrecognised status %r" % (status,))


def verdict(states, missing_days, failure_subscribed):
    """Fold run states, schedule gaps and webhook coverage into one verdict. Pure.

    `states` is the list of per-run states, `missing_days` the expected UTC dates
    with no successful run, `failure_subscribed` whether any endpoint listens for
    reporting.report_run.failed.
    """
    failed = states.count("failed")
    stalled = states.count("stalled")
    if not states:
        return ("silent",
                "no report runs at all in the window; the export never reached "
                "Stripe, so there is nothing here to have failed")
    if failed or stalled:
        return ("failing",
                "%d run(s): %d failed, %d stalled in pending, %d expected day(s) "
                "with no successful run"
                % (len(states), failed, stalled, len(missing_days)))
    if missing_days:
        return ("gaps",
                "%d run(s), none failed, but %d expected day(s) have no successful "
                "run: %s" % (len(states), len(missing_days),
                             ", ".join(missing_days[:5])))
    if not failure_subscribed:
        return ("unwatched",
                "%d run(s), all successful, but nothing subscribes to %s, so the "
                "next failure is silent" % (len(states), FAILURE_EVENT))
    return ("clear",
            "%d run(s), 0 failed, 0 stalled, no missing days, failures subscribed"
            % len(states))


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 has no read access to "
                         "this resource")
    r.raise_for_status()
    return r.json()


def page_all(session, path, params, cap=2000):
    """Collect every page, oldest last: Stripe returns these lists newest first."""
    out = []
    params = dict(params)
    while True:
        page = get(session, path, params)
        data = page.get("data", [])
        out.extend(data)
        if not data or not page.get("has_more") or len(out) >= cap:
            return out
        params["starting_after"] = data[-1]["id"]


def expected_days(now, days):
    """UTC dates a nightly export should have covered, excluding today."""
    today = dt.datetime.utcfromtimestamp(now).date()
    return [(today - dt.timedelta(days=n)).isoformat() for n in range(1, days + 1)]


def failure_is_subscribed(endpoints):
    for ep in endpoints:
        if ep.get("status") == "disabled":
            continue
        events = ep.get("enabled_events") or []
        if FAILURE_EVENT in events or "*" in events:
            return True
    return False


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="how far back to read report runs")
    ap.add_argument("--report-type",
                    help="only consider runs of this report type, e.g. balance.summary.1")
    ap.add_argument("--no-daily", action="store_true",
                    help="do not expect one successful run per day")
    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()
    runs = page_all(s, "/reporting/report_runs",
                    {"limit": 100, "created[gte]": int(now - args.days * 86400)})
    if args.report_type:
        runs = [r for r in runs if r.get("report_type") == args.report_type]

    states = []
    succeeded_days = set()
    for r in runs:
        created = r.get("created")
        age = None if created is None else now - created
        state, detail = run_state(r.get("status"), age, r.get("error"))
        states.append(state)
        if state == "succeeded" and r.get("succeeded_at"):
            succeeded_days.add(
                dt.datetime.utcfromtimestamp(r["succeeded_at"]).date().isoformat())
        if state in ("failed", "stalled", "unknown"):
            log.warning("  %-9s %s  %s  %s", state, r.get("id"),
                        r.get("report_type"), detail)

    missing = ([] if args.no_daily
               else [d for d in expected_days(now, args.days) if d not in succeeded_days])

    endpoints = page_all(s, "/webhook_endpoints", {"limit": 100})
    subscribed = failure_is_subscribed(endpoints)

    state, detail = verdict(states, missing, subscribed)
    line = "%-11s %s" % (state, detail)
    if state == "clear":
        log.info(line)
        return 0

    log.warning(line)
    log.warning("  read the reason off the run, then re-issue it:")
    log.warning("  GET %s/reporting/report_runs/<frr_id>   (read .error, .parameters)", API)
    log.warning("  POST %s/reporting/report_runs with the corrected interval, then "
                "poll until status leaves pending", API)
    if not subscribed:
        log.warning("  and subscribe an endpoint so the next one is loud:")
        log.warning("  POST %s/webhook_endpoints/<we_id> "
                    "enabled_events[]=%s enabled_events[]=reporting.report_run.succeeded",
                    API, FAILURE_EVENT)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-report-runs.mjs
/**
 * Report Stripe report runs that failed, stalled in pending, or never happened.
 *
 * Read only. Two paginated GETs and no writes: give this a RESTRICTED key with
 * read access to Reports and Webhook Endpoints. The repair is printed, never
 * performed.
 */
const API = 'https://api.stripe.com/v1';

// A run still pending past this is not being worked on.
export const STALL_SECONDS = 3600;
export const FAILURE_EVENT = 'reporting.report_run.failed';

/**
 * Classify one report run. Pure, so the pending deadline can be tested offline.
 * `ageSeconds` is how long ago the run was created.
 */
export function runState(status, ageSeconds, error = null) {
  if (status === 'succeeded') return ['succeeded', 'finished, result file available'];
  if (status === 'failed') {
    return ['failed', error || 'failed with no error message on the run'];
  }
  if (status === 'pending') {
    if (ageSeconds !== null && ageSeconds !== undefined && ageSeconds >= STALL_SECONDS) {
      return ['stalled',
        `pending for ${(ageSeconds / 3600).toFixed(1)} hour(s); nothing is working ` +
        'on it, treat it as failed'];
    }
    return ['running', `pending, still inside the ${STALL_SECONDS} second window`];
  }
  return ['unknown', `unrecognised status ${JSON.stringify(status)}`];
}

/** Fold run states, schedule gaps and webhook coverage into one verdict. Pure. */
export function verdict(states, missingDays, failureSubscribed) {
  const failed = states.filter((s) => s === 'failed').length;
  const stalled = states.filter((s) => s === 'stalled').length;
  if (states.length === 0) {
    return ['silent',
      'no report runs at all in the window; the export never reached Stripe, so ' +
      'there is nothing here to have failed'];
  }
  if (failed || stalled) {
    return ['failing',
      `${states.length} run(s): ${failed} failed, ${stalled} stalled in pending, ` +
      `${missingDays.length} expected day(s) with no successful run`];
  }
  if (missingDays.length) {
    return ['gaps',
      `${states.length} run(s), none failed, but ${missingDays.length} expected ` +
      `day(s) have no successful run: ${missingDays.slice(0, 5).join(', ')}`];
  }
  if (!failureSubscribed) {
    return ['unwatched',
      `${states.length} run(s), all successful, but nothing subscribes to ` +
      `${FAILURE_EVENT}, so the next failure is silent`];
  }
  return ['clear',
    `${states.length} run(s), 0 failed, 0 stalled, no missing days, failures subscribed`];
}

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 has no read access here');
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

async function pageAll(key, path, params, cap = 2000) {
  const out = [];
  const p = { ...params };
  for (;;) {
    const page = await get(key, path, p);
    const data = page.data ?? [];
    out.push(...data);
    if (data.length === 0 || !page.has_more || out.length >= cap) return out;
    p.starting_after = data[data.length - 1].id;
  }
}

const utcDay = (unix) => new Date(unix * 1000).toISOString().slice(0, 10);

export function expectedDays(now, days) {
  const out = [];
  for (let n = 1; n <= days; n += 1) out.push(utcDay(now - n * 86400));
  return out;
}

export function failureIsSubscribed(endpoints) {
  return endpoints.some((ep) => ep.status !== 'disabled'
    && (ep.enabled_events ?? []).some((e) => e === FAILURE_EVENT || e === '*'));
}

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 ?? 30);
  const now = Date.now() / 1000;

  const runs = await pageAll(key, '/reporting/report_runs',
    { limit: 100, 'created[gte]': Math.floor(now - days * 86400) });

  const states = [];
  const succeededDays = new Set();
  for (const r of runs) {
    const age = r.created === undefined ? null : now - r.created;
    const [state, detail] = runState(r.status, age, r.error);
    states.push(state);
    if (state === 'succeeded' && r.succeeded_at) succeededDays.add(utcDay(r.succeeded_at));
    if (['failed', 'stalled', 'unknown'].includes(state)) {
      console.warn(`  ${state.padEnd(9)} ${r.id}  ${r.report_type}  ${detail}`);
    }
  }
  const missing = expectedDays(now, days).filter((d) => !succeededDays.has(d));

  const endpoints = await pageAll(key, '/webhook_endpoints', { limit: 100 });
  const subscribed = failureIsSubscribed(endpoints);

  const [state, detail] = verdict(states, missing, subscribed);
  const line = `${state.padEnd(11)} ${detail}`;
  if (state === 'clear') { console.log(line); return; }

  console.warn(line);
  console.warn('  read the reason off the run, then re-issue it:');
  console.warn(`  GET ${API}/reporting/report_runs/<frr_id>   (read .error, .parameters)`);
  console.warn(`  POST ${API}/reporting/report_runs with the corrected interval, then ` +
               'poll until status leaves pending');
  if (!subscribed) {
    console.warn('  and subscribe an endpoint so the next one is loud:');
    console.warn(`  POST ${API}/webhook_endpoints/<we_id> enabled_events[]=${FAILURE_EVENT}` +
                 ' enabled_events[]=reporting.report_run.succeeded');
  }
  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 are about the two judgements a human keeps getting wrong here: that pending is a state rather than a stopwatch, and that a window full of successful runs can still be a broken export if a night is missing from it. Both are decided by the pure functions, so both can be checked without a Stripe key.

test_stripe_report_runs.py
from stripe_report_runs import run_state, verdict


def test_succeeded_run_is_not_flagged():
    state, _ = run_state("succeeded", 120.0)
    assert state == "succeeded"


def test_pending_becomes_stalled_at_the_hour():
    # 59 minutes is still legitimately running; 60 is a run nothing is working on.
    assert run_state("pending", 3599.0)[0] == "running"
    state, detail = run_state("pending", 3600.0)
    assert state == "stalled"
    assert "1.0 hour" in detail


def test_failed_run_without_an_error_string_still_says_something():
    state, detail = run_state("failed", 30.0, None)
    assert state == "failed"
    assert "no error message" in detail


def test_all_successful_but_a_missing_day_is_not_clear():
    state, detail = verdict(["succeeded"] * 29, ["2026-08-14"], True)
    assert state == "gaps"
    assert "2026-08-14" in detail


def test_no_runs_at_all_is_the_loudest_case():
    assert verdict([], [], True)[0] == "silent"
    # Clean runs still are not clear while nothing listens for the failure event.
    assert verdict(["succeeded"], [], False)[0] == "unwatched"
    assert verdict(["succeeded"], [], True)[0] == "clear"
stripe-report-runs.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runState, verdict } from './stripe-report-runs.mjs';

test('succeeded run is not flagged', () => {
  assert.equal(runState('succeeded', 120)[0], 'succeeded');
});

test('pending becomes stalled at the hour', () => {
  assert.equal(runState('pending', 3599)[0], 'running');
  const [state, detail] = runState('pending', 3600);
  assert.equal(state, 'stalled');
  assert.match(detail, /1\.0 hour/);
});

test('failed run without an error string still says something', () => {
  const [state, detail] = runState('failed', 30, null);
  assert.equal(state, 'failed');
  assert.match(detail, /no error message/);
});

test('all successful but a missing day is not clear', () => {
  const [state, detail] = verdict(Array(29).fill('succeeded'), ['2026-08-14'], true);
  assert.equal(state, 'gaps');
  assert.match(detail, /2026-08-14/);
});

test('no runs at all is the loudest case', () => {
  assert.equal(verdict([], [], true)[0], 'silent');
  assert.equal(verdict(['succeeded'], [], false)[0], 'unwatched');
  assert.equal(verdict(['succeeded'], [], true)[0], 'clear');
});

FAQ

Why did creating the report run return 200 if it failed?

Because the 200 only says Stripe accepted the request and created a run object with status pending. The work happens afterwards. The run then moves to succeeded, with a result File, or to failed, with a sentence in error. Neither outcome is known at the moment the create call returns.

How long should I wait before calling a pending run dead?

An hour is a safe default for the standard report types, which normally resolve in seconds. Whatever number you pick, pick one: a poll loop with no deadline waits forever, and a single check with no deadline records a stuck run as healthy.

What is the difference between a failed run and a missing day?

A failed run means your job reached Stripe and Stripe could not produce the report, so error tells you which parameter was wrong. A missing day means no run object exists at all, so the scheduler did not fire or never got a request out. The second one cannot be diagnosed from Stripe's side, only detected.

Which events should I subscribe to?

reporting.report_run.failed at minimum, and reporting.report_run.succeeded if you want to trigger the download rather than poll for it. Check what you already have with GET /v1/webhook_endpoints and read enabled_events; reporting events are usually absent because the endpoint was created for payments.

Does this script need a live secret key?

No. A restricted key with read access to Reports and Webhook Endpoints is enough, and is what it should be given. It reads two lists and prints the repair, so if it leaks nobody can move money with it.

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.