Skip to content

Diagnostic Stripe

no statement descriptor, so customers dispute what they see

Every month a handful of disputes arrive with the reason unrecognized or duplicate, from customers who bought the thing, kept the thing, and are perfectly happy with it. They looked at a bank statement, saw a line that meant nothing to them, and did the sensible thing. You pay the dispute fee for each one.

Read-only key Python and Node.js Tests included
A white board with writing on it
Photo by sarah b on Unsplash
The short answer

GET /v1/account and check settings.card_payments.statement_descriptor_prefix and settings.payments.statement_descriptor. If either is empty, what reaches the card networks is a generic default rather than your brand.

Then paginate GET /v1/charges and read calculated_statement_descriptor. More than one distinct value across a recent sample is worse than a bad one, because Visa identifies monitored accounts by the static component of the descriptor and a fragmented prefix splits your volume across phantom merchants.

The problem in plain words

These are not fraud disputes and they are not unhappy customers. They are people who cannot map a line on a statement to a purchase they remember making, which is exactly the situation the unrecognized and duplicate reason codes exist for. Each one costs the disputed amount plus the fee, and each one counts toward the ratios the card networks watch.

The fragmentation problem is quieter and worse. Visa identifies a monitored account by the static part of the statement descriptor. If different payment flows in your integration produce different descriptors, your dispute ratio is computed against fragments of your volume rather than all of it. A small number of disputes measured against a small slice of payments can breach a threshold that your real, whole-account ratio is nowhere near.

Prefix leftemptyformat rulesrejected itGeneric defaultsentnot your brandStatementarrivesline means nothingDispute filedreasonunrecognizedRatiofragmentedVisa sees twomerchants
Nobody in your team ever sees the descriptor. The only people who read it are the customers about to dispute the charge.

Why it happens

Nothing in the payment flow surfaces the descriptor. You never see it. It appears on a document the bank sends the customer, weeks later, in an abbreviated form, next to thirty other lines. The only people who read it are the ones about to dispute the charge.

The default looks like a value. An account with no prefix set still produces a calculated_statement_descriptor on every charge, so the field is populated and a script that only checks for null finds nothing wrong. What it contains is a generic default, not your business name.

The rules are strict enough that a first attempt often fails and is abandoned. Five to twenty-two characters, at least five letters, and none of the four characters Stripe rejects. Somebody tries their full legal entity name, gets an error, and leaves the field empty intending to come back.

Different flows produce different descriptors without anyone deciding to. A Checkout Session, a Payment Link, a subscription invoice and a hand-rolled PaymentIntent can each end up with a different suffix or prefix depending on what was passed at creation. Nobody sees them side by side, so nobody notices they disagree.

The fix, as a flow

The script compares the configured prefix with the descriptors actually sent, because the expensive failure is not an empty setting but two payment flows disagreeing about what your business is called.

GET /v1/accountplus calculated_statement_descriptorOne clear valueconsistentNo prefix setset it in Dashboard settingsSeveral distinct valuesone prefix everywhereToo short or unreadablefails Stripe's own rules
One value is the goal. Several is fragmentation, which splits your dispute ratio across accounts the networks think are separate.

How to fix it

Read the account descriptor settings

One request. settings.card_payments.statement_descriptor_prefix is the static part that appears on card payments; settings.payments.statement_descriptor is the account-level default. Empty on either is the first finding and the cheapest fix.

Sample calculated_statement_descriptor across recent charges

This is the field that says what was actually sent, rather than what you configured. Collect the distinct values over 30 days. One value is the goal; several is fragmentation; blank is the worst case.

Check the value against the format rules

Five to twenty-two characters, at least five letters, and none of the four characters Stripe disallows. A descriptor that is technically present but is an acronym nobody recognises fails the only test that matters, which is whether your customer can read it.

Quantify with the dispute reasons

GET /v1/disputes over 180 days and count the share with reason of unrecognized, general or duplicate. That share is what a clear descriptor is worth, and it is the number that gets the settings page opened.

Set the prefix once, then add a per-payment suffix

Dashboard, Settings, Business, then public business information. Use the website domain or the business name customers know. Keep that prefix identical everywhere so Visa aggregates your volume as one account, and put the order number in statement_descriptor_suffix at payment creation so the customer can match the line to a specific purchase.

How to check it worked

Re-run after the prefix is set. One distinct descriptor across the sample, and it should read as your business rather than as an abbreviation.

python3 stripe_statement_descriptor.py --days 30
# consistent  EXAMPLE STORE across 1,204 charge(s)
# 0.4% of disputes cite unrecognized, general or duplicate

The full code

One account read, one paginated GET over Charges and one over Disputes, no writes — read access to those three is enough. The classifier is pure and takes the configured prefix plus the descriptors actually observed, because the interesting failure is not a missing setting but a setting that disagrees with itself across payment flows.

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_statement_descriptor.py
"""Report Stripe accounts whose statement descriptor is missing or inconsistent.

Read only. Three paginated GETs and no writes: give this a RESTRICTED key with
read access to Account, Charges and Disputes. The repair is printed, never
performed, because this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time

import requests

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

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

MIN_LEN = 5
MAX_LEN = 22
MIN_LETTERS = 5
BANNED = ("<", ">", "'", '"')

# Reason codes a customer gives when the line on the statement meant nothing.
UNRECOGNISED = ("unrecognized", "general", "duplicate")


def verdict(prefix, descriptors):
    """Classify the account's descriptor. Pure, so the format rules test offline.

    `prefix` is the configured static prefix; `descriptors` is every
    calculated_statement_descriptor observed on recent charges. Returns
    (state, detail).
    """
    seen = sorted({(d or "").strip() for d in (descriptors or [])} - {""})
    if not (prefix or "").strip():
        return ("unset",
                "no statement descriptor prefix on the account; %d distinct "
                "descriptor(s) observed on charges" % len(seen))
    if descriptors and not seen:
        return ("blank",
                "a prefix is configured but every charge carried an empty "
                "descriptor: nothing identifying you reaches the networks")
    if len(seen) > 1:
        return ("fragmented",
                "%d distinct descriptors in use (%s): Visa identifies a monitored "
                "account by the static component, so your volume is being split"
                % (len(seen), ", ".join(seen[:3])))
    text = seen[0] if seen else prefix.strip()
    letters = sum(1 for c in text if c.isalpha())
    if len(text) < MIN_LEN or len(text) > MAX_LEN:
        return ("malformed",
                "%r is %d characters; Stripe requires %d to %d"
                % (text, len(text), MIN_LEN, MAX_LEN))
    if letters < MIN_LETTERS:
        return ("malformed",
                "%r has %d letter(s); Stripe requires at least %d"
                % (text, letters, MIN_LETTERS))
    if any(c in text for c in BANNED):
        return ("malformed",
                "%r contains a character Stripe disallows in a descriptor" % text)
    return ("consistent", "%s across the sampled charges" % text)


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
    r.raise_for_status()
    return r.json()


def page(session, path, cap, **params):
    out = []
    params = dict(params)
    params["limit"] = 100
    while True:
        p = get(session, path, **params)
        data = p.get("data", [])
        out.extend(data)
        if not data or not p.get("has_more") or len(out) >= cap:
            break
        params["starting_after"] = data[-1]["id"]
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="how far back to sample charges")
    ap.add_argument("--dispute-days", type=int, default=180,
                    help="how far back to read disputes")
    ap.add_argument("--max-charges", type=int, default=5000,
                    help="stop paginating after this many charges")
    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})

    account = get(s, "/account")
    settings = account.get("settings") or {}
    prefix = ((settings.get("card_payments") or {}).get("statement_descriptor_prefix")
              or (settings.get("payments") or {}).get("statement_descriptor"))

    now = time.time()
    charges = page(s, "/charges", args.max_charges,
                   **{"created[gte]": int(now - args.days * 86400)})
    descriptors = [c.get("calculated_statement_descriptor") for c in charges]
    no_suffix = sum(1 for c in charges if not c.get("statement_descriptor_suffix"))

    state, detail = verdict(prefix, descriptors)
    log.info("%-11s %s (%d charge(s) sampled)", state, detail, len(charges))

    disputes = page(s, "/disputes", 1000,
                    **{"created[gte]": int(now - args.dispute_days * 86400)})
    if disputes:
        blind = sum(1 for d in disputes if d.get("reason") in UNRECOGNISED)
        log.info("%.1f%% of disputes cite unrecognized, general or duplicate (%d of %d)",
                 100.0 * blind / len(disputes), blind, len(disputes))

    if state == "consistent" and not no_suffix:
        return 0

    if state != "consistent":
        log.warning("  set the prefix in Dashboard, Settings, Business, public "
                    "business information: %d to %d characters, at least %d letters, "
                    "and none of %s", MIN_LEN, MAX_LEN, MIN_LETTERS, " ".join(BANNED))
        log.warning("  use the website domain or the business name customers know, "
                    "and keep it identical across every payment flow")
    if no_suffix:
        log.warning("  %d of %d charge(s) carried no statement_descriptor_suffix. Set "
                    "one at payment creation so the line names the order.",
                    no_suffix, len(charges))
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-statement-descriptor.mjs
/**
 * Report Stripe accounts whose statement descriptor is missing or inconsistent.
 *
 * Read only. Three paginated GETs and no writes: give this a RESTRICTED key with
 * read access to Account, Charges and Disputes. The repair is printed, never run.
 */
const API = 'https://api.stripe.com/v1';

export const MIN_LEN = 5;
export const MAX_LEN = 22;
const MIN_LETTERS = 5;
const BANNED = ['<', '>', "'", '"'];
const UNRECOGNISED = new Set(['unrecognized', 'general', 'duplicate']);

/**
 * Classify the account's descriptor. Pure, so the format rules test offline.
 * `descriptors` is every calculated_statement_descriptor seen on recent charges.
 */
export function verdict(prefix, descriptors) {
  const seen = [...new Set((descriptors ?? [])
    .map((d) => (d ?? '').trim())
    .filter((d) => d !== ''))].sort();

  if (!(prefix ?? '').trim()) {
    return ['unset',
      `no statement descriptor prefix on the account; ${seen.length} distinct ` +
      'descriptor(s) observed on charges'];
  }
  if (descriptors && descriptors.length && seen.length === 0) {
    return ['blank',
      'a prefix is configured but every charge carried an empty descriptor: ' +
      'nothing identifying you reaches the networks'];
  }
  if (seen.length > 1) {
    return ['fragmented',
      `${seen.length} distinct descriptors in use (${seen.slice(0, 3).join(', ')}): ` +
      'Visa identifies a monitored account by the static component, so your ' +
      'volume is being split'];
  }
  const text = seen.length ? seen[0] : prefix.trim();
  const letters = [...text].filter((c) => /[a-z]/i.test(c)).length;
  if (text.length < MIN_LEN || text.length > MAX_LEN) {
    return ['malformed',
      `"${text}" is ${text.length} characters; Stripe requires ${MIN_LEN} to ${MAX_LEN}`];
  }
  if (letters < MIN_LETTERS) {
    return ['malformed',
      `"${text}" has ${letters} letter(s); Stripe requires at least ${MIN_LETTERS}`];
  }
  if (BANNED.some((c) => text.includes(c))) {
    return ['malformed',
      `"${text}" contains a character Stripe disallows in a descriptor`];
  }
  return ['consistent', `${text} across the sampled charges`];
}

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();
}

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

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 account = await get(key, '/account');
  const settings = account.settings ?? {};
  const prefix = settings.card_payments?.statement_descriptor_prefix
    ?? settings.payments?.statement_descriptor;

  const days = Number(process.env.DAYS ?? 30);
  const disputeDays = Number(process.env.DISPUTE_DAYS ?? 180);
  const now = Date.now() / 1000;
  const charges = await page(key, '/charges', 5000,
    { 'created[gte]': Math.floor(now - days * 86400) });
  const descriptors = charges.map((c) => c.calculated_statement_descriptor);
  const noSuffix = charges.filter((c) => !c.statement_descriptor_suffix).length;

  const [state, detail] = verdict(prefix, descriptors);
  console.log(`${state.padEnd(11)} ${detail} (${charges.length} charge(s) sampled)`);

  const disputes = await page(key, '/disputes', 1000,
    { 'created[gte]': Math.floor(now - disputeDays * 86400) });
  if (disputes.length) {
    const blind = disputes.filter((d) => UNRECOGNISED.has(d.reason)).length;
    console.log(`${(100 * blind / disputes.length).toFixed(1)}% of disputes cite ` +
                `unrecognized, general or duplicate (${blind} of ${disputes.length})`);
  }

  if (state === 'consistent' && !noSuffix) return;

  if (state !== 'consistent') {
    console.warn('  set the prefix in Dashboard, Settings, Business, public business ' +
                 `information: ${MIN_LEN} to ${MAX_LEN} characters, at least ` +
                 `${MIN_LETTERS} letters, and none of ${BANNED.join(' ')}`);
    console.warn('  use the website domain or the business name customers know, and ' +
                 'keep it identical across every payment flow');
  }
  if (noSuffix) {
    console.warn(`  ${noSuffix} of ${charges.length} charge(s) carried no ` +
                 'statement_descriptor_suffix. Set one at payment creation so the ' +
                 'line names the order.');
  }
  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 interesting case is not the empty setting, which anybody would catch. It is two payment flows producing two different descriptors while both of them look perfectly reasonable on their own, because that is the state that quietly splits your dispute ratio across accounts Visa thinks are separate. The format rules are tested at their boundaries for the ordinary reason: a check that accepts a four-character descriptor is telling you something Stripe will reject.

test_stripe_statement_descriptor.py
from stripe_statement_descriptor import verdict


def test_no_prefix_is_the_first_finding():
    state, detail = verdict(None, ["EXAMPLE STORE"])
    assert state == "unset"
    assert "1 distinct" in detail
    assert verdict("   ", [])[0] == "unset"


def test_two_flows_with_two_descriptors_is_fragmentation():
    state, detail = verdict("EXAMPLE", ["EXAMPLE STORE", "EXAMPLE SUBS", "EXAMPLE STORE"])
    assert state == "fragmented"
    assert "2 distinct" in detail


def test_a_configured_prefix_with_empty_descriptors_is_worse_than_missing():
    assert verdict("EXAMPLE", ["", "  ", None])[0] == "blank"


def test_the_format_rules_are_checked_at_their_boundaries():
    assert verdict("EXAMPLE", ["ABCD"])[0] == "malformed"          # 4 chars
    assert verdict("EXAMPLE", ["ABCDE"])[0] == "consistent"        # 5 is the floor
    assert verdict("EXAMPLE", ["A" * 23])[0] == "malformed"        # 23 chars
    assert verdict("EXAMPLE", ["AB 12"])[0] == "malformed"         # only 2 letters


def test_a_disallowed_character_is_rejected():
    state, detail = verdict("EXAMPLE", ["EXAMPLE<STORE"])
    assert state == "malformed"
    assert "disallows" in detail
stripe-statement-descriptor.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-statement-descriptor.mjs';

test('no prefix is the first finding', () => {
  const [state, detail] = verdict(null, ['EXAMPLE STORE']);
  assert.equal(state, 'unset');
  assert.ok(detail.includes('1 distinct'));
  assert.equal(verdict('   ', [])[0], 'unset');
});

test('two flows with two descriptors is fragmentation', () => {
  const [state, detail] = verdict('EXAMPLE',
    ['EXAMPLE STORE', 'EXAMPLE SUBS', 'EXAMPLE STORE']);
  assert.equal(state, 'fragmented');
  assert.ok(detail.includes('2 distinct'));
});

test('a configured prefix with empty descriptors is worse than missing', () => {
  assert.equal(verdict('EXAMPLE', ['', '  ', null])[0], 'blank');
});

test('the format rules are checked at their boundaries', () => {
  assert.equal(verdict('EXAMPLE', ['ABCD'])[0], 'malformed');
  assert.equal(verdict('EXAMPLE', ['ABCDE'])[0], 'consistent');
  assert.equal(verdict('EXAMPLE', ['A'.repeat(23)])[0], 'malformed');
  assert.equal(verdict('EXAMPLE', ['AB 12'])[0], 'malformed');
});

test('a disallowed character is rejected', () => {
  const [state, detail] = verdict('EXAMPLE', ['EXAMPLE<STORE']);
  assert.equal(state, 'malformed');
  assert.ok(detail.includes('disallows'));
});

FAQ

What actually appears on the customer's statement?

The calculated_statement_descriptor on the charge, which combines the account's static prefix with any per-payment suffix. It is the only field that tells you what was sent; the account settings tell you what you configured, and those two can disagree once different payment flows pass their own values.

Why does one inconsistent descriptor matter so much?

Visa identifies a monitored account by the static component of the descriptor. Two descriptors means your payments are counted as two merchants, so a handful of disputes is measured against half your volume instead of all of it, and a ratio you are comfortably under can be breached on a fragment.

What are the format rules?

Five to twenty-two characters, at least five letters, and none of the four characters Stripe disallows. Beyond the rules, the useful test is whether a customer who bought from you last month would recognise it on a bank statement without thinking about it.

Is the suffix worth setting per payment?

Yes, when there is anything to name. An order number in statement_descriptor_suffix turns an unrecognized dispute into a support email, because the customer can search their inbox for the number they are looking at. It goes on the PaymentIntent at creation.

Will fixing the descriptor change disputes already open?

No. Descriptors are recorded at payment time and existing disputes carry the old one. The change affects payments made afterwards, which is why the reason-code share is worth recording before you make it and again 90 days later.

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.