Skip to content

Diagnostic Stripe

inquiries sit unanswered and escalate into chargebacks

Chargebacks appear to arrive from nowhere. Somebody pulls the history for one of them and finds it was visible in the API eleven days earlier, as an inquiry, with a deadline and a place to put evidence. Nothing was wrong with the alerting: the sweep looked for disputes whose status was needs_response, and at that point the status was warning_needs_response.

Read-only key Python and Node.js Tests included
Close-up of computer server rack components
Photo by Đào Hiếu on Unsplash
The short answer

Page GET /v1/disputes and read the warning_ statuses, not just the bare ones. A dispute with status of warning_needs_response is a pre-dispute inquiry: the issuer is asking, no funds have moved, and it is not counted by any card-network monitoring programme yet.

Flag every one where evidence_details.has_evidence is false, sorted by evidence_details.due_by. Answering at this stage prevents the escalation entirely, which saves the funds, the dispute fee, and the entry in the ratio the networks watch. Accepting an inquiry does not resolve it — only evidence does.

The problem in plain words

Stripe models the inquiry and the chargeback as the same object with different statuses. That is accurate to how the networks work and it is a trap for the obvious integration, because the natural filter — status == "needs_response" — is exact-match and silently excludes the entire warning_ family. The alerting is not broken, it is scoped to the half of the lifecycle where you have already lost the cheap option.

What that costs is specific. An inquiry answered in time closes without escalating, so it never becomes a chargeback, never takes the funds, never attracts the dispute fee, and never enters the count that puts an account into a monitoring programme. The same dispute answered a week later, after escalation, does all four even if you win it. The window between those two outcomes is days long and is visible in the API for the whole of it.

Issuer asksstatuswarning_needs_responseEvent deliveredcharge.dispute.createdHandler ignoresitmatchesneeds_responsedue_by passesno evidence sentChargebackfiledfunds, fee and aratio entry
Nothing failed. The event arrived, the handler ran, and the branch that would have paged someone was never written.

Why it happens

The status string looks like a different kind of object. warning_needs_response reads like a warning about something rather than a thing that needs a response. Integrations that branch on a list of statuses tend to have been written against the four everyone knows, and the warning_ family gets added later, if ever.

The webhook has the same shape. charge.dispute.created fires for inquiries too, and the handler that routes on data.object.status will fall through to its default branch. So the event arrived, was processed successfully, and was dropped on purpose by code nobody remembers writing.

Nothing has moved yet, so nothing looks urgent. There is no balance transaction, no fee, no missing money. The only thing that exists is a deadline, and deadlines that are not attached to a visible loss do not get worked.

Accepting is not an option here, and people assume it is. With a chargeback you can deliberately concede and close it. An inquiry has no such door: doing nothing is not conceding, it is waiting for the issuer to escalate on your behalf. Only submitted evidence ends it in your favour.

A snapshot cannot prove which chargebacks began as inquiries. The status advances in place, so once it escalates the object no longer says it was ever a warning_. The only honest way to measure the leak is to record the status you saw at charge.dispute.created and compare later, which is why this script reports the current split rather than inventing a history it cannot read.

The fix, as a flow

The script pages the disputes list with no status filter at all, because a server side filter on needs_response is the exact mistake it exists to find, and then splits the results locally on the escalation line.

GET /v1/disputesunfiltered, split on the statusprefixwarning_closedresolved before escalatingwarning_under_reviewanswered, waitingwarning_needs_responseanswer it, the window is openevidence staged, 0 sentthe work is done, send itneeds_response or lateralready a chargeback
Staged evidence is not submitted evidence. Only submission_count says the response reached the issuer.

How to fix it

Page the disputes list without a status filter

GET /v1/disputes?limit=100, paginated. Do not filter server-side on status; pull them all and split locally. The moment the filter lives in the query string, the next person to widen the set of statuses you care about has to remember it is there.

Split the list on the escalation line, not on urgency

Three statuses are inquiries: warning_needs_response, warning_under_review, warning_closed. Four are chargebacks: needs_response, under_review, won, lost. That line is the whole note — everything to the left is cheap to fix and everything to the right is already counted against you.

Sort the open inquiries by due_by

evidence_details.due_by is a unix timestamp and it is the only ordering that matters. An inquiry with two days left and one with fifteen are the same row in a dashboard sorted by creation date, and very different tickets.

Read submission_count, not has_evidence

has_evidence flips to true the moment one field is saved, so a half-written response reads as handled. submission_count is what says the response actually went to the issuer. Staged and unsent is the most expensive state in the list, because the work was already done.

Report the inquiry-to-chargeback mix as a number

Count the two families over the same window. A healthy account has more inquiries than chargebacks, because inquiries are the earlier stage of the same funnel. An account with almost no inquiries and plenty of chargebacks is either genuinely unlucky with its issuer mix or, much more often, not looking at the earlier stage at all.

Fix the webhook filter while you are here

The sweep is a safety net; the event is the mechanism. Alert on charge.dispute.created where data.object.status starts with warning_ as well as on the bare statuses, and the sweep should stop finding anything.

How to check it worked

Re-run after the inquiries are answered. Everything answered moves to warning_under_review, and the unanswered count should be zero.

python3 stripe_dispute_inquiries.py
# 31 dispute(s) read: 12 inquiry, 19 chargeback, 0 inquiry needing a response

The full code

One paginated GET against /v1/disputes and nothing else — a restricted key with read access to Disputes is enough, and is what you should give it. The status split is a pure function on its own, separate from the deadline classification, because the bug this note is about is a status list that was too short and that is the piece worth being able to read at a glance.

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_inquiries.py
"""Report Stripe pre-dispute inquiries that nobody has answered.

Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Disputes. The response is printed, never submitted, because this script
holds a credential to a live payments account and dispute evidence can be sent
exactly once per dispute.
"""
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_dispute_inquiries")

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

CRITICAL_HOURS = 72.0

# The inquiry side of the escalation line. No funds have moved and none of these
# are counted by a card network monitoring programme yet.
INQUIRY_OPEN = ("warning_needs_response",)
INQUIRY_ANSWERED = ("warning_under_review",)
INQUIRY_CLOSED = ("warning_closed",)

# The chargeback side. Funds are withdrawn, the dispute fee is charged, and the
# dispute counts toward the ratio whether it is later won or lost.
CHARGEBACK = ("needs_response", "under_review", "won", "lost")


def family(status):
    """Which side of the escalation line a dispute status sits on. Pure.

    This exists as its own function because the bug is almost always here: an
    integration matching only the four bare statuses drops every inquiry on the
    floor, and a list that is too short is invisible in a longer function.
    """
    if status in INQUIRY_OPEN + INQUIRY_ANSWERED + INQUIRY_CLOSED:
        return "inquiry"
    if status in CHARGEBACK:
        return "chargeback"
    return "unknown"


def classify(dispute, now, critical_hours=CRITICAL_HOURS):
    """Classify one dispute. Pure, so the deadline arithmetic can be tested.

    `now` is a unix timestamp. Returns (state, detail).

    The state that costs money is `unanswered`, and its worst variant is
    `staged`: evidence written and never sent, which forfeits the inquiry with
    the work already paid for.
    """
    status = dispute.get("status")
    side = family(status)

    if side == "unknown":
        return ("unknown", "unrecognised status %r" % (status,))
    if side == "chargeback":
        return ("escalated",
                "already a chargeback (%s). The funds and the dispute fee are "
                "gone and it counts toward the network ratio either way." % status)
    if status in INQUIRY_CLOSED:
        return ("closed", "inquiry closed without escalating")
    if status in INQUIRY_ANSWERED:
        return ("answered", "evidence is in and the issuer is reviewing it")

    ed = dispute.get("evidence_details") or {}
    due_by = ed.get("due_by")
    staged = bool(ed.get("has_evidence"))
    sent = ed.get("submission_count") or 0

    if sent:
        return ("answered", "%d submission(s) already sent" % sent)
    if staged:
        return ("staged",
                "evidence is staged but submission_count is 0. Nothing has "
                "reached the issuer, and doing nothing is not the same as "
                "accepting: only evidence closes an inquiry.")
    if due_by is None:
        return ("unanswered", "open inquiry with no due_by to measure against")

    hours = (due_by - now) / 3600.0
    if hours <= 0:
        return ("lapsing",
                "past due_by while unanswered. Expect this to escalate into a "
                "formal chargeback, with the fee and the ratio entry attached.")
    if hours <= critical_hours:
        return ("critical", "%.1f hour(s) left and nothing attached." % hours)
    return ("unanswered", "%.1f day(s) left to answer before escalation" % (hours / 24.0))


def money(dispute):
    """Amount at stake, in minor units.

    Not divided by 100, which is wrong for zero-decimal currencies such as JPY.
    A report that reads 100x low on one currency is worse than one that makes
    you read the currency code.
    """
    return "%s %s" % (dispute.get("amount"), (dispute.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")
    r.raise_for_status()
    return r.json()


def disputes(session, limit):
    """Yield disputes, newest first, up to `limit`.

    Deliberately unfiltered. A server-side status filter is how the inquiries
    got missed in the first place.
    """
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(session, "/disputes", params)
        data = page.get("data", [])
        for d in data:
            yield d
            seen += 1
        if not data or not page.get("has_more") or seen >= limit:
            break
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--hours", type=float, default=CRITICAL_HOURS,
                    help="how close to due_by counts as critical")
    ap.add_argument("--max-disputes", type=int, default=1000,
                    help="stop paginating after this many disputes")
    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()
    counts = {"inquiry": 0, "chargeback": 0, "unknown": 0}
    open_inquiries = 0

    # Newest first, so collect and sort by deadline before printing: the order
    # the API returns them in is not the order you should work them in.
    rows = []
    for d in disputes(s, args.max_disputes):
        counts[family(d.get("status"))] += 1
        state, detail = classify(d, now, args.hours)
        if state in ("unanswered", "critical", "staged", "lapsing"):
            open_inquiries += 1
            rows.append(((d.get("evidence_details") or {}).get("due_by") or 0,
                         d, state, detail))

    for _due, d, state, detail in sorted(rows, key=lambda r: r[0]):
        log.warning("%-10s %s  %s  %s", state, d.get("id", "?"), money(d), detail)
        log.warning("  repair: POST %s/disputes/%s "
                    "-d 'evidence[uncategorized_text]=...' "
                    "-d 'evidence[product_description]=...' "
                    "-d 'evidence[shipping_tracking_number]=...'",
                    API, d["id"])
        log.warning("  evidence submits once per dispute, so assemble it all first")

    total = sum(counts.values())
    log.info("%d dispute(s) read: %d inquiry, %d chargeback, %d inquiry needing "
             "a response", total, counts["inquiry"], counts["chargeback"],
             open_inquiries)
    if counts["inquiry"] and counts["chargeback"] > counts["inquiry"]:
        log.info("more chargebacks than inquiries in this window: check that "
                 "charge.dispute.created is routed on statuses starting warning_")
    return 1 if open_inquiries else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-dispute-inquiries.mjs
/**
 * Report Stripe pre-dispute inquiries that nobody has answered.
 *
 * Read only. One paginated GET and no writes: give this a RESTRICTED key with
 * read access to Disputes. The response is printed, never submitted, because
 * dispute evidence can be sent exactly once per dispute.
 */
const API = 'https://api.stripe.com/v1';

export const CRITICAL_HOURS = 72;

const INQUIRY_OPEN = ['warning_needs_response'];
const INQUIRY_ANSWERED = ['warning_under_review'];
const INQUIRY_CLOSED = ['warning_closed'];
const CHARGEBACK = ['needs_response', 'under_review', 'won', 'lost'];

/**
 * Which side of the escalation line a dispute status sits on. Pure.
 */
export function family(status) {
  if (INQUIRY_OPEN.includes(status) || INQUIRY_ANSWERED.includes(status)
      || INQUIRY_CLOSED.includes(status)) {
    return 'inquiry';
  }
  if (CHARGEBACK.includes(status)) return 'chargeback';
  return 'unknown';
}

/**
 * Classify one dispute. Pure, so the deadline arithmetic can be tested.
 * `now` is a unix timestamp in seconds.
 */
export function classify(dispute, now, criticalHours = CRITICAL_HOURS) {
  const status = dispute.status;
  const side = family(status);

  if (side === 'unknown') {
    return ['unknown', `unrecognised status ${JSON.stringify(status)}`];
  }
  if (side === 'chargeback') {
    return ['escalated',
      `already a chargeback (${status}). The funds and the dispute fee are gone ` +
      'and it counts toward the network ratio either way.'];
  }
  if (INQUIRY_CLOSED.includes(status)) {
    return ['closed', 'inquiry closed without escalating'];
  }
  if (INQUIRY_ANSWERED.includes(status)) {
    return ['answered', 'evidence is in and the issuer is reviewing it'];
  }

  const ed = dispute.evidence_details ?? {};
  const dueBy = ed.due_by;
  const staged = Boolean(ed.has_evidence);
  const sent = ed.submission_count ?? 0;

  if (sent) return ['answered', `${sent} submission(s) already sent`];
  if (staged) {
    return ['staged',
      'evidence is staged but submission_count is 0. Nothing has reached the ' +
      'issuer, and doing nothing is not the same as accepting: only evidence ' +
      'closes an inquiry.'];
  }
  if (dueBy === undefined || dueBy === null) {
    return ['unanswered', 'open inquiry with no due_by to measure against'];
  }

  const hours = (dueBy - now) / 3600;
  if (hours <= 0) {
    return ['lapsing',
      'past due_by while unanswered. Expect this to escalate into a formal ' +
      'chargeback, with the fee and the ratio entry attached.'];
  }
  if (hours <= criticalHours) {
    return ['critical', `${hours.toFixed(1)} hour(s) left and nothing attached.`];
  }
  return ['unanswered',
    `${(hours / 24).toFixed(1)} day(s) left to answer before escalation`];
}

/**
 * Amount at stake, in minor units. Not divided by 100, which is wrong for
 * zero-decimal currencies such as JPY.
 */
export function money(dispute) {
  return `${dispute.amount} ${(dispute.currency ?? '?').toUpperCase()}`;
}

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* disputes(key, limit = 1000) {
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/disputes', params);
    const data = page.data ?? [];
    for (const d of data) { yield d; seen += 1; }
    if (data.length === 0 || !page.has_more || seen >= limit) break;
    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 now = Date.now() / 1000;
  const counts = { inquiry: 0, chargeback: 0, unknown: 0 };
  const rows = [];

  for await (const d of disputes(key)) {
    counts[family(d.status)] += 1;
    const [state, detail] = classify(d, now);
    if (['unanswered', 'critical', 'staged', 'lapsing'].includes(state)) {
      rows.push([(d.evidence_details ?? {}).due_by ?? 0, d, state, detail]);
    }
  }

  rows.sort((a, b) => a[0] - b[0]);
  for (const [, d, state, detail] of rows) {
    console.warn(`${state.padEnd(10)} ${d.id ?? '?'}  ${money(d)}  ${detail}`);
    console.warn(`  repair: POST ${API}/disputes/${d.id} ` +
                 `-d 'evidence[uncategorized_text]=...' ` +
                 `-d 'evidence[product_description]=...' ` +
                 `-d 'evidence[shipping_tracking_number]=...'`);
    console.warn('  evidence submits once per dispute, so assemble it all first');
  }

  const total = counts.inquiry + counts.chargeback + counts.unknown;
  console.log(`${total} dispute(s) read: ${counts.inquiry} inquiry, ` +
              `${counts.chargeback} chargeback, ${rows.length} inquiry needing a response`);
  if (counts.inquiry && counts.chargeback > counts.inquiry) {
    console.log('more chargebacks than inquiries in this window: check that ' +
                'charge.dispute.created is routed on statuses starting warning_');
  }
  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

Two cases carry the note. The first is that lost and won are on the chargeback side of the line, because the tempting shortcut — treat anything that is not needs_response as handled — puts them somewhere else and takes the inquiries with them. The second is staged evidence with a submission count of zero, which reads as answered on every field except the one the issuer sees.

test_stripe_dispute_inquiries.py
from stripe_dispute_inquiries import classify, family

NOW = 1_700_000_000


def inquiry(hours_left, **evidence):
    ev = {"due_by": NOW + int(hours_left * 3600)}
    ev.update(evidence)
    return {"id": "du_1", "status": "warning_needs_response",
            "evidence_details": ev}


def test_the_warning_family_is_the_inquiry_side_of_the_line():
    assert family("warning_needs_response") == "inquiry"
    assert family("warning_under_review") == "inquiry"
    assert family("warning_closed") == "inquiry"


def test_settled_chargebacks_stay_on_the_chargeback_side():
    # The shortcut this guards against: "anything not needs_response is done".
    assert family("won") == "chargeback"
    assert family("lost") == "chargeback"
    assert family("needs_response") == "chargeback"
    assert family("sleeping") == "unknown"


def test_an_open_inquiry_with_nothing_attached_reports_days_left():
    state, detail = classify(inquiry(240), NOW)
    assert state == "unanswered"
    assert "10.0 day" in detail


def test_seventy_two_hours_is_the_boundary_and_it_is_inclusive():
    assert classify(inquiry(72), NOW)[0] == "critical"
    assert classify(inquiry(72.1), NOW)[0] == "unanswered"


def test_staged_evidence_that_was_never_submitted_is_its_own_state():
    state, detail = classify(
        inquiry(240, has_evidence=True, submission_count=0), NOW)
    assert state == "staged"
    assert "submission_count" in detail
    # And an inquiry that really was answered is not confused with it.
    assert classify(inquiry(240, has_evidence=True, submission_count=1), NOW)[0] == "answered"


def test_an_escalated_dispute_is_not_reported_as_an_open_inquiry():
    state, detail = classify({"status": "needs_response"}, NOW)
    assert state == "escalated"
    assert "fee" in detail
    assert classify(inquiry(-1), NOW)[0] == "lapsing"
stripe-dispute-inquiries.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, family } from './stripe-dispute-inquiries.mjs';

const NOW = 1_700_000_000;

function inquiry(hoursLeft, evidence = {}) {
  return {
    id: 'du_1',
    status: 'warning_needs_response',
    evidence_details: { due_by: NOW + Math.round(hoursLeft * 3600), ...evidence },
  };
}

test('the warning family is the inquiry side of the line', () => {
  assert.equal(family('warning_needs_response'), 'inquiry');
  assert.equal(family('warning_under_review'), 'inquiry');
  assert.equal(family('warning_closed'), 'inquiry');
});

test('settled chargebacks stay on the chargeback side', () => {
  assert.equal(family('won'), 'chargeback');
  assert.equal(family('lost'), 'chargeback');
  assert.equal(family('needs_response'), 'chargeback');
  assert.equal(family('sleeping'), 'unknown');
});

test('an open inquiry with nothing attached reports days left', () => {
  const [state, detail] = classify(inquiry(240), NOW);
  assert.equal(state, 'unanswered');
  assert.match(detail, /10\.0 day/);
});

test('seventy two hours is the boundary and it is inclusive', () => {
  assert.equal(classify(inquiry(72), NOW)[0], 'critical');
  assert.equal(classify(inquiry(72.1), NOW)[0], 'unanswered');
});

test('staged evidence that was never submitted is its own state', () => {
  const [state, detail] = classify(
    inquiry(240, { has_evidence: true, submission_count: 0 }), NOW);
  assert.equal(state, 'staged');
  assert.match(detail, /submission_count/);
  assert.equal(
    classify(inquiry(240, { has_evidence: true, submission_count: 1 }), NOW)[0],
    'answered');
});

test('an escalated dispute is not reported as an open inquiry', () => {
  const [state, detail] = classify({ status: 'needs_response' }, NOW);
  assert.equal(state, 'escalated');
  assert.match(detail, /fee/);
  assert.equal(classify(inquiry(-1), NOW)[0], 'lapsing');
});

FAQ

What is the difference between a Stripe inquiry and a chargeback?

An inquiry, exposed as a status starting with warning_, is the issuer asking about a transaction before filing anything. No funds have moved, no dispute fee is charged, and it is not counted by the card networks' monitoring programmes. A chargeback is the formal filing: the funds are withdrawn immediately, the fee applies, and it counts toward your ratio whether you later win it or lose it.

Why does my dispute alerting miss inquiries?

Because it almost certainly matches status exactly against needs_response. Stripe uses the same dispute object for both stages and distinguishes them by a warning_ prefix, so an exact-match filter excludes the whole inquiry family without producing any error. The same happens in webhook handlers that switch on data.object.status.

Can I just accept an inquiry to make it go away?

No. There is no equivalent of closing a dispute at the inquiry stage; doing nothing simply lets the issuer escalate. Submitting evidence is the only action that resolves an inquiry in your favour, which is why an unanswered inquiry is a decision nobody made rather than a decision to concede.

Does answering an inquiry guarantee it will not escalate?

No, but it is the only lever you have, and it is a cheap one. The issuer can still file a chargeback afterwards. What answering removes is the case where it escalated purely because nothing came back, which is the population this script is built to find.

Can I measure how many of my chargebacks started as inquiries?

Not from a snapshot. The status advances in place on the same object, so once it escalates there is no field saying it was ever an inquiry. To measure it properly, record data.object.status when charge.dispute.created fires and compare against the final status later. The script reports the current inquiry-to-chargeback mix instead, which is a proxy rather than a proof.

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.