Skip to content

Diagnostic Stripe

actionable early fraud warnings were never refunded

A batch of fraud disputes lands in the same week, all on charges from a month earlier. Each of those charges already carried an early fraud warning at the time, sitting in the API with actionable set to true, which is Stripe saying in as many words that you could still refund it. Nobody was subscribed to the event and nobody swept for it, so the window opened and closed unobserved.

Read-only key Python and Node.js Tests included
Red padlock on black computer keyboard
Photo by FlyD on Unsplash
The short answer

Page GET /v1/radar/early_fraud_warnings?created[gte]=<now-90d> and keep the ones where actionable is true. For each, read the charge it names with GET /v1/charges/{id} and flag it when disputed is false, refunded is false and amount_refunded is 0.

An EFW is actionable exactly while it has not received a dispute and has not been fully refunded. That is the only window in which a voluntary refund costs you the goods and nothing else: after it closes the same transaction arrives as a fraud dispute, with the funds withdrawn, the dispute fee charged, and an entry in the ratio the card networks watch.

The problem in plain words

An early fraud warning is the issuer telling you, ahead of any dispute, that the cardholder has reported this transaction as fraud. It is the rarest thing in payments: advance notice with an action attached. The action is a refund, and the cost of taking it is losing a sale you were going to lose anyway.

The reason it gets missed is that nothing about it is loud. No money moves when an EFW arrives. There is no queue in the Dashboard that fills up, no failing job, no customer writing in. The object appears, the actionable flag is true for a while, and then the same transaction reappears weeks later as a dispute where it looks like an ordinary chargeback with no history.

The second cost is the one that is hard to argue back. Both the warning and the resulting chargeback are counted by Visa's VAMP ratio, so ignoring the warning does not save you one entry — it buys you a second one, plus the fee, plus the loss of whatever you shipped in the meantime.

Issuer reportsfraudwarning created,actionable trueNo subscriptionnobody is toldGoods shipmoney stillcapturedWindow closeswarning stopsbeing actionableDispute filedfee, funds and asecond ratio entry
The one moment in payments with advance notice attached, and nothing moves when it arrives so nothing raises an alarm.

Why it happens

The flag means something narrower than it sounds. actionable is true while the warning has received no dispute and has not been fully refunded. It is not a judgement about how convincing the fraud claim is; it is a statement about whether the door is still open. Reading it as a severity score is why the low-value ones get skipped and then escalate anyway.

A partial refund does not close the door. Refunding shipping, or the difference after a restocking fee, leaves the charge partly funded and the warning still actionable. Any check that treats a non-zero amount_refunded as handled will report the account clean while the disputes are still coming.

The join is two calls, and the second one is the one that gets dropped. The EFW object carries a charge id, not the charge's refund state. Everything you need to decide lives on the charge, so a script that reports the warnings without fetching them tells you what you already knew.

Nobody subscribed to the event. radar.early_fraud_warning.created is not in anyone's default enabled_events list, so the notice that was designed to be pushed to you sits waiting to be pulled.

The pattern in fraud_type is the part that changes what you do. One made_with_stolen_card is a bad customer. Fifteen in three days is an attack in progress, and the response to that is a Radar rule, not fifteen refunds.

The fix, as a flow

The script trusts the actionable flag to narrow the set, then spends one request per warning on the charge itself, because the refund state that decides everything lives on the charge and not on the warning.

Actionable warningsjoined to GET /v1/charges/{id}Fully refundedclosed before escalatingAlready disputedpast the window, count the feePartly refundedstill open, finish itUntouchedrefund now, oldest first
A partial refund leaves the warning actionable. Comparing amount_refunded against zero rather than against amount reports a clean account that is not.

How to fix it

List warnings over a window wide enough to include the escalations

GET /v1/radar/early_fraud_warnings?created[gte]=<unix>&limit=100, paginated. Ninety days is a reasonable default: long enough that you can see which of the older warnings became disputes, which is the number that tells you whether the process is working.

Trust the actionable flag rather than recomputing it

Stripe maintains actionable against the dispute and refund state for you. Filter on it first and you cut the number of charge lookups to the population that still has a door open.

Fetch the charge, because the decision lives there

GET /v1/charges/{efw.charge}. Three fields decide it: disputed, refunded and amount_refunded. Compare amount_refunded against amount rather than against zero, so a partial refund is reported as the unfinished thing it is.

Sort by age, then by amount

Age first, because the window is what you are racing. Amount second, because if you can only work half the list today it should be the expensive half. Sorting by amount alone is how a two-week-old warning outlives its window while somebody handles a fresh one worth more.

Group by fraud_type before you start refunding

A cluster of made_with_stolen_card or unauthorized_use_of_card in a short window is a campaign against your checkout. Refunding each one individually treats the symptom and leaves the source running for another week.

Subscribe to the event so the sweep finds nothing

Add radar.early_fraud_warning.created to a webhook endpoint and route it to a person. A daily sweep is a good backstop and a poor primary: the window is measured in days, and half of it is gone by the time a nightly job runs.

How to check it worked

Re-run after the refunds go through. Everything refunded in full drops out of the actionable set, and the flagged count should be zero.

python3 stripe_efw_actionable.py --days 90
# 41 warning(s) read, 0 actionable and unrefunded

The full code

A paginated GET over the warnings and one GET per actionable warning — a restricted key with read access to Early Fraud Warnings and Charges is enough. The classification is a pure function taking the warning and its charge together, because the whole judgement is the join between them, and the partial-refund case is the one that quietly breaks when it is written inline.

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_efw_actionable.py
"""Report Stripe early fraud warnings that can still be refunded.

Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Early Fraud Warnings and Charges. The refund is printed, never issued, because
this script holds a credential to a live payments account and a refund cannot be
undone.
"""
import argparse
import collections
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_efw_actionable")

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


def classify(efw, charge, now):
    """Classify one warning against its charge. Pure. Returns (state, detail).

    `charge` is the charge object the warning names, or None if it could not be
    read. The state that costs money is `actionable`: the issuer has reported
    fraud, no dispute has been filed yet, and the money is still yours to give
    back.
    """
    if not efw.get("actionable", False):
        return ("not_actionable",
                "Stripe no longer counts this as actionable: it has already "
                "been disputed or fully refunded")
    if charge is None:
        return ("unknown", "the charge named by this warning could not be read")

    if charge.get("disputed"):
        return ("escalated",
                "the warning became a dispute. The refund window is closed, the "
                "dispute fee applies, and it now counts twice toward the ratio.")

    amount = charge.get("amount") or 0
    refunded = charge.get("amount_refunded") or 0
    if charge.get("refunded") or (amount and refunded >= amount):
        return ("refunded", "fully refunded before it could escalate")
    if refunded:
        return ("partial",
                "%d of %d refunded. A partial refund does not close the window: "
                "the warning is still actionable and can still become a dispute."
                % (refunded, amount))

    created = efw.get("created")
    if created is None:
        return ("actionable", "unrefunded, with no created timestamp to age it by")
    days = (now - created) / 86400.0
    return ("actionable",
            "%.1f day(s) old, %d %s unrefunded, no dispute filed yet"
            % (days, amount, (charge.get("currency") or "?").upper()))


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")
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def warnings(session, since, limit):
    """Yield early fraud warnings created since `since`, newest first."""
    seen = 0
    params = {"limit": 100, "created[gte]": int(since)}
    while True:
        page = get(session, "/radar/early_fraud_warnings", params)
        data = (page or {}).get("data", [])
        for w in data:
            yield w
            seen += 1
        if not data or not page.get("has_more") or seen >= limit:
            break
        params["starting_after"] = data[-1]["id"]


def charge_id(efw):
    """The warning carries `charge` as an id, or expanded as an object."""
    ch = efw.get("charge")
    return ch.get("id") if isinstance(ch, dict) else ch


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=90,
                    help="how far back to read warnings")
    ap.add_argument("--max-warnings", type=int, default=1000,
                    help="stop paginating after this many warnings")
    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()
    since = now - args.days * 86400
    types = collections.Counter()
    rows = []
    seen = 0

    for w in warnings(s, since, args.max_warnings):
        seen += 1
        types[w.get("fraud_type") or "unknown"] += 1
        cid = charge_id(w)
        # Only actionable warnings are worth a second request; Stripe has
        # already done the dispute and refund bookkeeping behind that flag.
        charge = get(s, "/charges/" + cid) if (w.get("actionable") and cid) else None
        state, detail = classify(w, charge, now)
        if state in ("actionable", "partial", "unknown"):
            rows.append((w.get("created") or 0, w, cid, state, detail))

    # Oldest first: the window is the thing being raced, and a fresh warning
    # worth more can wait a day where a two week old one cannot.
    for _created, w, cid, state, detail in sorted(rows, key=lambda r: r[0]):
        log.warning("%-12s %s  charge=%s  %s  %s",
                    state, w.get("id", "?"), cid, w.get("fraud_type", "?"), detail)
        if state == "unknown":
            continue
        log.warning("  repair: POST %s/refunds -d charge=%s -d reason=fraudulent",
                    API, cid)
        log.warning("  or Dashboard, the payment, Refund as fraud, which also adds "
                    "the card fingerprint and email to your block lists")

    log.info("%d warning(s) read, %d actionable and unrefunded", seen, len(rows))
    if types:
        log.info("by fraud_type: %s",
                 ", ".join("%s=%d" % (k, v) for k, v in types.most_common()))
        top, count = types.most_common(1)[0]
        if seen and count >= 10 and count / seen > 0.5:
            log.warning("%d of %d warnings are %s: that is a campaign, and a Radar "
                        "rule will do more than %d refunds", count, seen, top, count)
    log.info("subscribe to radar.early_fraud_warning.created so this sweep is a "
             "backstop rather than the only notice you get")
    return 1 if rows else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-efw-actionable.mjs
/**
 * Report Stripe early fraud warnings that can still be refunded.
 *
 * Read only. GETs only, no writes: give this a RESTRICTED key with read access
 * to Early Fraud Warnings and Charges. The refund is printed, never issued,
 * because a refund cannot be undone.
 */
const API = 'https://api.stripe.com/v1';

/**
 * Classify one warning against its charge. Pure. Returns [state, detail].
 * `charge` is the charge the warning names, or null if it could not be read.
 */
export function classify(efw, charge, now) {
  if (!efw.actionable) {
    return ['not_actionable',
      'Stripe no longer counts this as actionable: it has already been ' +
      'disputed or fully refunded'];
  }
  if (charge === null || charge === undefined) {
    return ['unknown', 'the charge named by this warning could not be read'];
  }

  if (charge.disputed) {
    return ['escalated',
      'the warning became a dispute. The refund window is closed, the dispute ' +
      'fee applies, and it now counts twice toward the ratio.'];
  }

  const amount = charge.amount ?? 0;
  const refunded = charge.amount_refunded ?? 0;
  if (charge.refunded || (amount && refunded >= amount)) {
    return ['refunded', 'fully refunded before it could escalate'];
  }
  if (refunded) {
    return ['partial',
      `${refunded} of ${amount} refunded. A partial refund does not close the ` +
      'window: the warning is still actionable and can still become a dispute.'];
  }

  const created = efw.created;
  if (created === undefined || created === null) {
    return ['actionable', 'unrefunded, with no created timestamp to age it by'];
  }
  const days = (now - created) / 86400;
  return ['actionable',
    `${days.toFixed(1)} day(s) old, ${amount} ` +
    `${(charge.currency ?? '?').toUpperCase()} unrefunded, no dispute filed yet`];
}

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 === 404) return null;
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

export async function* warnings(key, since, limit = 1000) {
  let seen = 0;
  const params = { limit: 100, 'created[gte]': Math.floor(since) };
  for (;;) {
    const page = await get(key, '/radar/early_fraud_warnings', params);
    const data = page?.data ?? [];
    for (const w of data) { yield w; seen += 1; }
    if (data.length === 0 || !page.has_more || seen >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
}

/** The warning carries `charge` as an id, or expanded as an object. */
export function chargeId(efw) {
  const ch = efw.charge;
  return ch && typeof ch === 'object' ? ch.id : ch;
}

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 = process.argv.includes('--days')
    ? Number(process.argv[process.argv.indexOf('--days') + 1]) : 90;

  const now = Date.now() / 1000;
  const since = now - days * 86400;
  const types = new Map();
  const rows = [];
  let seen = 0;

  for await (const w of warnings(key, since)) {
    seen += 1;
    const t = w.fraud_type ?? 'unknown';
    types.set(t, (types.get(t) ?? 0) + 1);
    const cid = chargeId(w);
    const charge = w.actionable && cid ? await get(key, `/charges/${cid}`) : null;
    const [state, detail] = classify(w, charge, now);
    if (['actionable', 'partial', 'unknown'].includes(state)) {
      rows.push([w.created ?? 0, w, cid, state, detail]);
    }
  }

  rows.sort((a, b) => a[0] - b[0]);
  for (const [, w, cid, state, detail] of rows) {
    console.warn(`${state.padEnd(12)} ${w.id ?? '?'}  charge=${cid}  ` +
                 `${w.fraud_type ?? '?'}  ${detail}`);
    if (state === 'unknown') continue;
    console.warn(`  repair: POST ${API}/refunds -d charge=${cid} -d reason=fraudulent`);
    console.warn('  or Dashboard, the payment, Refund as fraud, which also adds ' +
                 'the card fingerprint and email to your block lists');
  }

  console.log(`${seen} warning(s) read, ${rows.length} actionable and unrefunded`);
  const ranked = [...types.entries()].sort((a, b) => b[1] - a[1]);
  if (ranked.length) {
    console.log('by fraud_type: ' + ranked.map(([k, v]) => `${k}=${v}`).join(', '));
    const [top, count] = ranked[0];
    if (seen && count >= 10 && count / seen > 0.5) {
      console.warn(`${count} of ${seen} warnings are ${top}: that is a campaign, ` +
                   `and a Radar rule will do more than ${count} refunds`);
    }
  }
  console.log('subscribe to radar.early_fraud_warning.created so this sweep is a ' +
              'backstop rather than the only notice you get');
  process.exitCode = rows.length ? 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 case worth pinning above all the others is the partial refund. It is the one a hurried implementation gets wrong — a non-zero amount_refunded reads as handled — and getting it wrong means the report says the account is clean while the disputes it was meant to prevent are already on their way.

test_stripe_efw_actionable.py
from stripe_efw_actionable import classify

NOW = 1_700_000_000


def warning(**kw):
    w = {"id": "issfr_1", "actionable": True, "charge": "ch_1",
         "fraud_type": "made_with_stolen_card", "created": NOW - 3 * 86400}
    w.update(kw)
    return w


def charge(**kw):
    c = {"id": "ch_1", "amount": 4500, "currency": "usd", "amount_refunded": 0,
         "refunded": False, "disputed": False}
    c.update(kw)
    return c


def test_an_untouched_actionable_warning_is_flagged_with_its_age():
    state, detail = classify(warning(), charge(), NOW)
    assert state == "actionable"
    assert "3.0 day" in detail


def test_a_partial_refund_does_not_close_the_window():
    # The trap: amount_refunded is non-zero, so a naive check calls this done.
    state, detail = classify(warning(), charge(amount_refunded=500), NOW)
    assert state == "partial"
    assert "still actionable" in detail


def test_a_full_refund_is_the_outcome_this_check_exists_for():
    assert classify(warning(), charge(refunded=True, amount_refunded=4500), NOW)[0] == "refunded"
    # Refunded to the last minor unit without the boolean set is the same thing.
    assert classify(warning(), charge(amount_refunded=4500), NOW)[0] == "refunded"


def test_a_disputed_charge_is_past_the_window_not_pending_in_it():
    state, detail = classify(warning(), charge(disputed=True), NOW)
    assert state == "escalated"
    assert "fee" in detail


def test_the_actionable_flag_and_an_unreadable_charge_are_both_respected():
    assert classify(warning(actionable=False), charge(), NOW)[0] == "not_actionable"
    assert classify(warning(), None, NOW)[0] == "unknown"
stripe-efw-actionable.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-efw-actionable.mjs';

const NOW = 1_700_000_000;

const warning = (kw = {}) => ({
  id: 'issfr_1',
  actionable: true,
  charge: 'ch_1',
  fraud_type: 'made_with_stolen_card',
  created: NOW - 3 * 86400,
  ...kw,
});

const charge = (kw = {}) => ({
  id: 'ch_1',
  amount: 4500,
  currency: 'usd',
  amount_refunded: 0,
  refunded: false,
  disputed: false,
  ...kw,
});

test('an untouched actionable warning is flagged with its age', () => {
  const [state, detail] = classify(warning(), charge(), NOW);
  assert.equal(state, 'actionable');
  assert.match(detail, /3\.0 day/);
});

test('a partial refund does not close the window', () => {
  const [state, detail] = classify(warning(), charge({ amount_refunded: 500 }), NOW);
  assert.equal(state, 'partial');
  assert.match(detail, /still actionable/);
});

test('a full refund is the outcome this check exists for', () => {
  assert.equal(
    classify(warning(), charge({ refunded: true, amount_refunded: 4500 }), NOW)[0],
    'refunded');
  assert.equal(
    classify(warning(), charge({ amount_refunded: 4500 }), NOW)[0], 'refunded');
});

test('a disputed charge is past the window not pending in it', () => {
  const [state, detail] = classify(warning(), charge({ disputed: true }), NOW);
  assert.equal(state, 'escalated');
  assert.match(detail, /fee/);
});

test('the actionable flag and an unreadable charge are both respected', () => {
  assert.equal(classify(warning({ actionable: false }), charge(), NOW)[0],
               'not_actionable');
  assert.equal(classify(warning(), null, NOW)[0], 'unknown');
});

FAQ

What does actionable mean on an early fraud warning?

It means the warning has not yet received a dispute and the charge has not been fully refunded, so a voluntary refund is still possible. It is not a confidence score about the fraud claim. Once a dispute is filed or the charge is refunded in full, the flag goes false because there is nothing left to do.

Does refunding an early fraud warning remove it from my dispute rate?

No, and this is worth being clear about. Visa's VAMP ratio counts early fraud warnings alongside disputes, so the warning is counted whether or not you refund. What refunding avoids is the second count: the chargeback that the warning would otherwise become, plus its fee and the goods you would ship in the meantime.

Is a partial refund enough?

No. The warning stays actionable until the charge is refunded in full, and it can still become a dispute. A refund of shipping, or of the amount after a restocking fee, leaves you with all of the original exposure and a false sense that the item was handled.

How quickly do warnings turn into disputes?

There is no published guarantee, which is exactly why a nightly sweep is a backstop rather than a process. Subscribe to radar.early_fraud_warning.created and route it somewhere a person looks; the sweep is there to catch what the subscription missed while it was misconfigured.

Should I refund every actionable warning automatically?

That is a business decision, and it is the reason this script prints the refund instead of issuing it. Automating refunds from a script holding a live key means one bad filter refunds a day of legitimate revenue, irreversibly. Read the fraud_type breakdown first: a cluster is an attack that wants a Radar rule, not a queue of individual refunds.

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.