Skip to content

Diagnostic Stripe

nothing subscribes to disputes or early fraud warnings

The first anyone knows about a chargeback is an email from Stripe about a deadline, or an unexplained dip in the balance. By the time somebody opens the dashboard, several days of the evidence window are gone. Separately and more expensively, the order that caused it shipped a week ago, even though the issuer had already flagged the card.

Read-only key Python and Node.js Tests included
Bicycle parked in a cluttered warehouse with a vintage car.
Photo by Rana Kaname on Unsplash
The short answer

Union the enabled_events arrays from GET /v1/webhook_endpoints and check for two types. charge.dispute.created is the only push signal that a chargeback has been opened. radar.early_fraud_warning.created fires when the issuer flags a payment before it becomes a formal dispute.

Corroborate with GET /v1/disputes?limit=1 and GET /v1/radar/early_fraud_warnings?limit=1. A missing subscription on an account that has already had either one is not a gap in coverage; it is a class of event you have been finding out about by email.

The problem in plain words

Disputes are the one part of a payments integration where the clock is set by somebody else. The card network decides the response deadline, it is short, and it does not extend because your team was not told. Every day between the dispute being opened and somebody noticing is a day removed from the time available to assemble receipts, delivery confirmation and terms acceptance.

The early fraud warning is the part that is genuinely worth money and almost nobody has wired up. It arrives when the issuer has flagged a payment as fraudulent but no chargeback has been filed yet. In that window a refund usually prevents the dispute altogether, which avoids both the chargeback fee and the hit to your dispute rate. Without the event, the warning sits in the API and the order ships.

Issuer flagscardwarning raisedNot subscribedno event sentOrder shipsrefund window goneDispute fileddeadline startsFound by emaildays already spent
The window in which a refund prevents the chargeback closes while the order is still being packed.

Why it happens

Neither type is implied by the payment events you already have. Subscribing to charge.succeeded or payment_intent.succeeded tells Stripe nothing about disputes. enabled_events is an allowlist, and a dispute is a different family with a different prefix that has to be asked for by name.

Email is not a delivery mechanism you can build on. Stripe does notify the account owner, and that notification goes to one inbox, is filtered, is missed on holidays, and cannot open a ticket or halt a shipment. The event can do all three.

Disputes are rare enough to never have been designed for. An integration can run for a year without one. The handler branch is therefore written, if at all, on the day of the first chargeback, under time pressure, by somebody reading the evidence documentation for the first time.

The fraud warning has a shorter useful life than the dispute. A dispute has a deadline measured in days; the window in which a proactive refund still prevents it closes as soon as the issuer files. That makes the early fraud warning the one signal here where hours matter, and it is the one people leave out.

The fix, as a flow

The script keeps the two signals apart rather than reporting one coverage flag, because a chargeback has a deadline and a fraud warning has a remedy, and only one of them is still cheap.

Union of enabled_eventsplus disputes and warningscreated, closed, warningcoveredno closing eventoutcome never recordeddisputes onlyno notice before filingneither subscribeddeadlines found by email
Hearing about chargebacks after they are filed is not the same as being covered, and collapsing the two hides the cheaper fix.

How to fix it

Union enabled_events across every endpoint, in both modes

Coverage is a property of the account. It is common for a Radar or dispute endpoint to be separate from the payment one, and equally common for it to have been planned and never created. A literal "*" covers both types and brings its own problems.

Ask whether it has already happened

GET /v1/disputes?limit=100 and GET /v1/radar/early_fraud_warnings?limit=100. These are two independent facts: an account can have warnings and no disputes, which is the best possible position to be in and the one where the missing subscription costs the most.

Subscribe to the opening and the closing

charge.dispute.created starts the clock; charge.dispute.closed tells you how it ended. Without the second, your own records of win rate come from the Dashboard by hand, and nothing reverses the internal state you set when the dispute opened.

Handle the fraud warning as an action, not a log line

On a warning with no dispute and no full refund yet, a refund usually prevents the chargeback. That decision needs the order state, so the handler branch has to do more than write a row: it has to be able to stop a shipment or reverse a provisioning step.

Backfill from the objects, not the events

Both resources list without a retention limit, unlike /v1/events. Once subscribed, sweep the existing disputes and warnings once to catch anything that arrived while nothing was listening.

How to check it worked

Re-run the script. Both signals should be subscribed on at least one endpoint.

python3 stripe_dispute_events.py
# covered   charge.dispute.created and radar.early_fraud_warning.created subscribed

The full code

Three GETs and no writes — endpoints, disputes and early fraud warnings. A restricted key with read access to Webhook Endpoints, Disputes and Radar covers it. The classifier keeps the two signals separate rather than collapsing them into one coverage flag, because they fail at different times and only one of them still has a cheap remedy available when it arrives.

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_dispute_events.py
"""Report whether dispute and early fraud warning events are subscribed anywhere.

Read only. Three GETs, no writes: give this a RESTRICTED key with read access to
Webhook Endpoints, Disputes and Radar. 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 requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_dispute_events")

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

DISPUTE = "charge.dispute.created"
DISPUTE_CLOSED = "charge.dispute.closed"
FRAUD = "radar.early_fraud_warning.created"


def verdict(subscribed, disputes, warnings):
    """Classify dispute and fraud coverage. Pure, so the rules can be tested.

    `subscribed` is the union of enabled_events across every endpoint, `disputes`
    and `warnings` the counts already on the account. The two signals stay
    separate because they fail at different times. Returns (state, detail).
    """
    events = set(subscribed or [])
    if "*" in events:
        return ("wildcard",
                "a wildcard subscription covers both signals, but it also "
                "delivers every other event type to the same handler.")
    if DISPUTE not in events:
        if disputes:
            return ("blind",
                    "%d dispute(s) on this account and nothing subscribes to %s. "
                    "Every response deadline so far was found by email."
                    % (disputes, DISPUTE))
        return ("unsubscribed",
                "nothing subscribes to %s. No disputes yet, so this is a gap "
                "rather than a deadline already running." % DISPUTE)
    if FRAUD not in events:
        if warnings:
            return ("fraud-blind",
                    "%d early fraud warning(s) already raised and nothing "
                    "subscribes to %s. A refund during that window prevents the "
                    "chargeback outright." % (warnings, FRAUD))
        return ("dispute-only",
                "%s is subscribed but %s is not. You will hear about chargebacks "
                "after they are filed and never before." % (DISPUTE, FRAUD))
    if DISPUTE_CLOSED not in events:
        return ("partial",
                "both opening signals are subscribed but %s is not, so nothing "
                "tells you how a dispute ended." % DISPUTE_CLOSED)
    return ("covered", "%s and %s subscribed" % (DISPUTE, FRAUD))


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 subscribed_events(endpoints):
    """Union of enabled_events across endpoints. Pure, given the endpoint list."""
    union = set()
    for ep in endpoints:
        union.update(ep.get("enabled_events") or [])
    return union


def count(session, path, limit):
    """Count a paginated resource up to `limit`."""
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(session, path, **params)
        data = page.get("data", [])
        seen += len(data)
        if not data or not page.get("has_more") or seen >= limit:
            break
        params["starting_after"] = data[-1]["id"]
    return seen


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-records", type=int, default=500,
                    help="stop counting disputes and warnings after this many")
    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})

    endpoints = get(s, "/webhook_endpoints", limit=100).get("data", [])
    union = subscribed_events(endpoints)
    disputes = count(s, "/disputes", args.max_records)
    warnings = count(s, "/radar/early_fraud_warnings", args.max_records)

    state, detail = verdict(union, disputes, warnings)
    line = "%-13s %s" % (state, detail)
    if state == "covered":
        log.info(line)
        return 0

    log.warning(line)
    log.warning("  %d dispute(s), %d early fraud warning(s) on this account",
                disputes, warnings)
    if state != "wildcard":
        log.warning("  repair: POST %s/webhook_endpoints/%s", API,
                    endpoints[0]["id"] if endpoints else "<we_id>")
        for t in (DISPUTE, DISPUTE_CLOSED, FRAUD):
            if t not in union:
                log.warning("    -d enabled_events[]=%s", t)
        log.warning("    (enabled_events is replaced wholesale: send the existing "
                    "types too)")
    log.warning("  then sweep GET %s/disputes and %s/radar/early_fraud_warnings "
                "once: neither is retention limited the way /v1/events is",
                API, API)
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-dispute-events.mjs
/**
 * Report whether dispute and early fraud warning events are subscribed anywhere.
 *
 * Read only. Three GETs, no writes: give this a RESTRICTED key with read access
 * to Webhook Endpoints, Disputes and Radar. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

const DISPUTE = 'charge.dispute.created';
const DISPUTE_CLOSED = 'charge.dispute.closed';
const FRAUD = 'radar.early_fraud_warning.created';

/**
 * Classify dispute and fraud coverage. Pure, so the rules can be tested.
 */
export function verdict(subscribed, disputes, warnings) {
  const events = new Set(subscribed ?? []);
  if (events.has('*')) {
    return ['wildcard',
      'a wildcard subscription covers both signals, but it also delivers every ' +
      'other event type to the same handler.'];
  }
  if (!events.has(DISPUTE)) {
    if (disputes) {
      return ['blind',
        `${disputes} dispute(s) on this account and nothing subscribes to ` +
        `${DISPUTE}. Every response deadline so far was found by email.`];
    }
    return ['unsubscribed',
      `nothing subscribes to ${DISPUTE}. No disputes yet, so this is a gap ` +
      'rather than a deadline already running.'];
  }
  if (!events.has(FRAUD)) {
    if (warnings) {
      return ['fraud-blind',
        `${warnings} early fraud warning(s) already raised and nothing subscribes ` +
        `to ${FRAUD}. A refund during that window prevents the chargeback outright.`];
    }
    return ['dispute-only',
      `${DISPUTE} is subscribed but ${FRAUD} is not. You will hear about ` +
      'chargebacks after they are filed and never before.'];
  }
  if (!events.has(DISPUTE_CLOSED)) {
    return ['partial',
      `both opening signals are subscribed but ${DISPUTE_CLOSED} is not, so ` +
      'nothing tells you how a dispute ended.'];
  }
  return ['covered', `${DISPUTE} and ${FRAUD} 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 lacks read access to ${path}`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

/** Union of enabled_events across endpoints. Pure, given the endpoint list. */
export function subscribedEvents(endpoints) {
  const union = new Set();
  for (const ep of endpoints ?? []) {
    for (const t of ep.enabled_events ?? []) union.add(t);
  }
  return union;
}

async function countRecords(key, path, limit = 500) {
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, path, params);
    const data = page.data ?? [];
    seen += data.length;
    if (data.length === 0 || !page.has_more || seen >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return seen;
}

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 { data: endpoints = [] } = await get(key, '/webhook_endpoints', { limit: 100 });
  const union = subscribedEvents(endpoints);
  const disputes = await countRecords(key, '/disputes');
  const warnings = await countRecords(key, '/radar/early_fraud_warnings');

  const [state, detail] = verdict(union, disputes, warnings);
  const line = `${state.padEnd(13)} ${detail}`;
  if (state === 'covered') {
    console.log(line);
    return;
  }

  console.warn(line);
  console.warn(`  ${disputes} dispute(s), ${warnings} early fraud warning(s) on this account`);
  if (state !== 'wildcard') {
    console.warn(`  repair: POST ${API}/webhook_endpoints/${endpoints[0]?.id ?? '<we_id>'}`);
    for (const t of [DISPUTE, DISPUTE_CLOSED, FRAUD]) {
      if (!union.has(t)) console.warn(`    -d enabled_events[]=${t}`);
    }
    console.warn('    (enabled_events is replaced wholesale: send the existing types too)');
  }
  console.warn(`  then sweep GET ${API}/disputes and ${API}/radar/early_fraud_warnings ` +
               'once: neither is retention limited the way /v1/events is');
  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 exist to stop the two signals collapsing into one. An account that hears about disputes but not about early fraud warnings is not covered, and calling it covered would delete the only state where a refund still costs less than a chargeback. The order matters too: a missing dispute subscription outranks everything else, because nothing downstream of it can be assessed.

test_stripe_dispute_events.py
from stripe_dispute_events import verdict


def test_no_dispute_subscription_with_disputes_already_filed():
    state, detail = verdict(["charge.succeeded"], 7, 0)
    assert state == "blind"
    assert "7" in detail


def test_no_dispute_subscription_and_no_disputes_yet_is_only_a_gap():
    state, detail = verdict(["charge.succeeded"], 0, 0)
    assert state == "unsubscribed"
    assert "gap" in detail


def test_disputes_covered_but_fraud_warnings_are_not():
    state, detail = verdict(["charge.dispute.created"], 3, 12)
    assert state == "fraud-blind"
    assert "12" in detail


def test_disputes_covered_with_no_warnings_seen_is_still_incomplete():
    state, _ = verdict(["charge.dispute.created"], 3, 0)
    assert state == "dispute-only"


def test_both_opening_signals_without_the_closing_one():
    state, detail = verdict(["charge.dispute.created",
                             "radar.early_fraud_warning.created"], 3, 2)
    assert state == "partial"
    assert "charge.dispute.closed" in detail


def test_all_three_is_covered():
    state, _ = verdict(["charge.dispute.created", "charge.dispute.closed",
                        "radar.early_fraud_warning.created"], 3, 2)
    assert state == "covered"


def test_a_wildcard_is_reported_before_anything_else():
    state, _ = verdict(["*"], 7, 12)
    assert state == "wildcard"
stripe-dispute-events.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-dispute-events.mjs';

test('no dispute subscription with disputes already filed', () => {
  const [state, detail] = verdict(['charge.succeeded'], 7, 0);
  assert.equal(state, 'blind');
  assert.match(detail, /7/);
});

test('no dispute subscription and no disputes yet is only a gap', () => {
  const [state, detail] = verdict(['charge.succeeded'], 0, 0);
  assert.equal(state, 'unsubscribed');
  assert.match(detail, /gap/);
});

test('disputes covered but fraud warnings are not', () => {
  const [state, detail] = verdict(['charge.dispute.created'], 3, 12);
  assert.equal(state, 'fraud-blind');
  assert.match(detail, /12/);
});

test('disputes covered with no warnings seen is still incomplete', () => {
  assert.equal(verdict(['charge.dispute.created'], 3, 0)[0], 'dispute-only');
});

test('both opening signals without the closing one', () => {
  const subs = ['charge.dispute.created', 'radar.early_fraud_warning.created'];
  const [state, detail] = verdict(subs, 3, 2);
  assert.equal(state, 'partial');
  assert.match(detail, /charge\.dispute\.closed/);
});

test('all three is covered', () => {
  const subs = ['charge.dispute.created', 'charge.dispute.closed',
    'radar.early_fraud_warning.created'];
  assert.equal(verdict(subs, 3, 2)[0], 'covered');
});

test('a wildcard is reported before anything else', () => {
  assert.equal(verdict(['*'], 7, 12)[0], 'wildcard');
});

FAQ

What is an early fraud warning, exactly?

A signal from the card issuer that a payment has been reported as fraudulent, raised before any chargeback is filed. It is listed at /v1/radar/early_fraud_warnings and pushed as radar.early_fraud_warning.created, and it is the last cheap moment to act on a bad order.

Should I refund every early fraud warning automatically?

Refunding a warning with no dispute and no existing full refund is the usual policy, because it avoids the chargeback fee and keeps your dispute rate down. It is still a business decision: the handler should be able to consult the order state rather than refunding blindly.

Does Stripe not email me about disputes anyway?

It notifies the account owner, which is one inbox with a filter in front of it. An email cannot open a ticket, halt a shipment or start a timer. Use the notification as a backstop and the event as the mechanism.

Why subscribe to charge.dispute.closed as well?

Because it carries the outcome. Without it your win rate is compiled by hand from the Dashboard, and whatever internal state you set when the dispute opened is never cleared when it resolves.

Can I catch up on disputes that happened before I subscribed?

Yes. Disputes and early fraud warnings both list from their own endpoints with no 30 day retention limit, unlike /v1/events. Sweep both once after subscribing, then rely on the events.

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.