Skip to content

Diagnostic Stripe

Checkout Sessions carry no ID that maps back to your order

Support is reconciling payments by matching an email address and an amount against the order table by hand. It works, mostly, until two people buy the same thing on the same day. Then a dispute arrives on one of those charges and nobody can say with confidence which order it was, which is a bad position to answer a dispute from.

Read-only key Python and Node.js Tests included
Close-up of server cooling fans in a vibrant data center.
Photo by Winston Chen on Unsplash
The short answer

Page GET /v1/checkout/sessions?created[gte]=<now-30d> and count the sessions where client_reference_id is null and metadata carries none of your own keys. Those two fields are the only places on a Session that hold an identifier of yours, and both default to empty.

The subset that matters most is sessions with payment_status of paid and neither field set: money taken that points at nothing. Fix it at creation with client_reference_id and metadata[order_id]; for Payment Links, set metadata on the link, which copies onto every Session it creates.

The problem in plain words

Everything works while volume is low, because email plus amount plus a date is very nearly unique when there are eleven orders a day. The reconciliation is manual, but nobody calls it a problem: it is five minutes in the morning.

It stops working in three places at once. Refunds go to the wrong order when two customers share a name. Fulfilment webhooks arrive with nothing to look up, so the handler either guesses or drops the event. And a dispute has to be answered with the product description, the delivery address and the customer's own messages, all of which live in your database behind an order id that the payment does not carry. The evidence exists; the join does not.

Session createdno reference, nometadataCustomer paysmoney arrivesWebhook landsnothing to look upMatched by handemail and amountDispute arrivesevidence cannot bejoined
Email and amount are nearly unique at low volume, which is exactly how long the manual reconciliation survives.

Why it happens

Both fields default to empty and neither is required. A Checkout Session created without them is completely valid, completes normally, and takes the money. There is no warning at creation, no field marked missing in the Dashboard, and nothing in the test-mode flow that behaves differently.

The identifier you do have points the wrong way. You get back a cs_ id and later a pi_ id, and it is tempting to store those on the order and call it done. That resolves Stripe to order, but the incoming direction — a webhook, a dispute, a support enquiry that starts with a charge — still has nothing to go on unless the object itself carries your id.

Payment Links look like they have no place to put one. A link is created once in the Dashboard and reused, so there is no per-order code path to add a reference in. Metadata set on the link is copied onto every Session it creates, which is the piece people miss, and the reason link-driven checkouts are usually the worst offenders on the report.

It is only fixable going forward. Metadata can be added to a Session after the fact, but nobody is going to backfill six months of them by hand, and the identifier you would backfill from is the same email-and-amount guess that made this a problem. Every day the check does not run is another day of unreconcilable payments.

The fix, as a flow

The script takes the metadata keys your own code reads as an argument, because metadata full of campaign tags is not an order id and a truthiness check would call it one.

GET /v1/checkout/sessionsclient_reference_id and metadataReference setreconcilableSome keys missinghalf a joinUnpaid, no referencenothing taken yetPaid, no referenceunattributable money
An abandoned session with no reference is untidy. A paid one is money you cannot attribute to anything.

How to fix it

Count the last 30 days of sessions

GET /v1/checkout/sessions?created[gte]=<unix>&limit=100, paginated. A month is enough to see the shape and short enough that the count means something about the code that runs today rather than the code from last year.

Decide which keys count as an identifier

Pass the metadata keys your own system actually reads — usually order_id, sometimes user_id as well. A session with unrelated metadata such as a UTM source is not reconcilable, and a check that treats any non-empty metadata as sufficient will report a clean bill of health on an account that cannot answer a single dispute.

Separate paid sessions from abandoned ones

An expired session with no reference is untidy. A paid session with no reference is money you cannot attribute. Report them separately or the urgent number drowns in the harmless one, since most sessions in any window are abandoned.

Set both fields at creation, not one

client_reference_id is the field Stripe surfaces in the Dashboard and in exports; metadata is the one that survives into your own tooling and can carry more than one key. Setting both costs nothing and each covers the other's gap.

Fix Payment Links on the link, once

Set metadata on the Payment Link itself. Every Session created from it inherits that metadata, so a link that says which campaign or product it is at least narrows a payment to a cohort even when there is no per-order id to give.

Run it weekly and watch the paid count

The number to hold at zero is paid-and-unlinked. If it moves off zero, a new code path started creating Sessions without a reference, and it is much cheaper to find that in the week it shipped.

How to check it worked

Re-run after the change and check the paid sessions specifically. The unlinked count over recent sessions should fall to zero as the old ones age out of the window.

python3 stripe_checkout_reconciliation.py --days 7
# 128 session(s): 128 linked, 0 partial, 0 unlinked, 0 orphaned

The full code

One paginated GET against /v1/checkout/sessions, no writes. The classifier is pure and takes the expected metadata keys as an argument, because the interesting distinction — metadata that exists but carries none of your keys — is exactly the one a hardcoded truthiness check on metadata would get wrong.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 21 Stripe fixes, free and open source.
stripe_checkout_reconciliation.py
"""Report Stripe Checkout Sessions that carry no identifier of your own.

Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Checkout Sessions. 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_checkout_reconciliation")

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

DEFAULT_KEYS = ("order_id",)


def verdict(session, expected_keys=DEFAULT_KEYS):
    """Classify one Checkout Session. Pure, so the rules can be tested offline.

    `expected_keys` are the metadata keys your own system reads. Metadata that
    exists but holds none of them is not reconcilable, however full it looks.
    Returns (state, detail).
    """
    ref = str(session.get("client_reference_id") or "").strip()
    meta = session.get("metadata") or {}
    present = [k for k in expected_keys if str(meta.get(k) or "").strip()]

    if ref:
        return ("linked", "client_reference_id=%s" % ref)
    if expected_keys and len(present) == len(expected_keys):
        return ("linked", "metadata carries %s" % ", ".join(present))
    if present:
        missing = [k for k in expected_keys if k not in present]
        return ("partial",
                "metadata has %s but is missing %s"
                % (", ".join(present), ", ".join(missing)))
    if session.get("payment_status") == "paid":
        return ("orphaned",
                "paid, with no client_reference_id and none of %s in metadata: "
                "money that points at nothing" % ", ".join(expected_keys))
    return ("unlinked",
            "no identifier of yours, but payment_status is %r so nothing has "
            "been taken yet" % (session.get("payment_status"),))


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 sessions(http, since, limit):
    """Yield Checkout Sessions created since `since`, newest first."""
    seen = 0
    params = {"limit": 100, "created[gte]": int(since)}
    while True:
        page = get(http, "/checkout/sessions", params)
        data = page.get("data", [])
        for s in data:
            yield s
            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("--days", type=int, default=30,
                    help="how far back to read sessions")
    ap.add_argument("--keys", default=",".join(DEFAULT_KEYS),
                    help="comma-separated metadata keys your system reads")
    ap.add_argument("--max-sessions", type=int, default=5000,
                    help="stop paginating after this many sessions")
    ap.add_argument("--show", type=int, default=10,
                    help="how many orphaned session ids to print")
    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

    expected = tuple(k.strip() for k in args.keys.split(",") if k.strip())
    http = requests.Session()
    http.headers.update({"Authorization": "Bearer " + key})

    counts = {"linked": 0, "partial": 0, "unlinked": 0, "orphaned": 0}
    worst = []
    total = 0
    for s in sessions(http, time.time() - args.days * 86400, args.max_sessions):
        total += 1
        state, detail = verdict(s, expected)
        counts[state] = counts.get(state, 0) + 1
        if state == "orphaned" and len(worst) < args.show:
            worst.append((s.get("id", "?"), detail))

    log.info("%d session(s): %d linked, %d partial, %d unlinked, %d orphaned",
             total, counts["linked"], counts["partial"], counts["unlinked"],
             counts["orphaned"])
    for sid, detail in worst:
        log.warning("orphaned  %s  %s", sid, detail)

    if counts["orphaned"] or counts["partial"]:
        log.warning("  repair: POST %s/checkout/sessions "
                    "-d client_reference_id=<your_order_id> "
                    "-d 'metadata[order_id]=<your_order_id>'", API)
        log.warning("  for Payment Links, set metadata on the link itself: it is "
                    "copied onto every Session the link creates")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
stripe-checkout-reconciliation.mjs
/**
 * Report Stripe Checkout Sessions that carry no identifier of your own.
 *
 * Read only. One paginated GET and no writes: give this a RESTRICTED key with
 * read access to Checkout Sessions. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

export const DEFAULT_KEYS = ['order_id'];

/**
 * Classify one Checkout Session. Pure, so the rules can be tested offline.
 * `expectedKeys` are the metadata keys your own system reads.
 */
export function verdict(session, expectedKeys = DEFAULT_KEYS) {
  const ref = String(session.client_reference_id ?? '').trim();
  const meta = session.metadata ?? {};
  const present = expectedKeys.filter((k) => String(meta[k] ?? '').trim());

  if (ref) return ['linked', `client_reference_id=${ref}`];
  if (expectedKeys.length && present.length === expectedKeys.length) {
    return ['linked', `metadata carries ${present.join(', ')}`];
  }
  if (present.length) {
    const missing = expectedKeys.filter((k) => !present.includes(k));
    return ['partial',
      `metadata has ${present.join(', ')} but is missing ${missing.join(', ')}`];
  }
  if (session.payment_status === 'paid') {
    return ['orphaned',
      `paid, with no client_reference_id and none of ${expectedKeys.join(', ')} ` +
      'in metadata: money that points at nothing'];
  }
  return ['unlinked',
    'no identifier of yours, but payment_status is ' +
    `${JSON.stringify(session.payment_status)} so nothing has been taken 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.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

export async function* sessions(key, since, limit = 5000) {
  let seen = 0;
  const params = { limit: 100, 'created[gte]': Math.floor(since) };
  for (;;) {
    const page = await get(key, '/checkout/sessions', params);
    const data = page.data ?? [];
    for (const s of data) { yield s; 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 days = Number(process.argv[2] ?? 30);
  const expected = (process.argv[3] ?? DEFAULT_KEYS.join(','))
    .split(',').map((k) => k.trim()).filter(Boolean);

  const counts = { linked: 0, partial: 0, unlinked: 0, orphaned: 0 };
  const worst = [];
  let total = 0;

  for await (const s of sessions(key, Date.now() / 1000 - days * 86400)) {
    total += 1;
    const [state, detail] = verdict(s, expected);
    counts[state] = (counts[state] ?? 0) + 1;
    if (state === 'orphaned' && worst.length < 10) worst.push([s.id ?? '?', detail]);
  }

  console.log(`${total} session(s): ${counts.linked} linked, ${counts.partial} ` +
              `partial, ${counts.unlinked} unlinked, ${counts.orphaned} orphaned`);
  for (const [id, detail] of worst) console.warn(`orphaned  ${id}  ${detail}`);

  if (counts.orphaned || counts.partial) {
    console.warn(`  repair: POST ${API}/checkout/sessions ` +
                 `-d client_reference_id=<your_order_id> ` +
                 `-d 'metadata[order_id]=<your_order_id>'`);
    console.warn('  for Payment Links, set metadata on the link itself: it is ' +
                 'copied onto every Session the link creates');
    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 case that decides whether this check is worth running is a session with plenty of metadata, none of it yours. A truthiness test on metadata calls that linked and reports zero problems on an account that cannot attribute a single payment, so it gets its own test alongside the empty-string reference that looks set and is not.

test_stripe_checkout_reconciliation.py
from stripe_checkout_reconciliation import verdict


def test_client_reference_id_is_enough_on_its_own():
    state, detail = verdict({"client_reference_id": "ord_918", "payment_status": "paid"})
    assert state == "linked"
    assert "ord_918" in detail


def test_metadata_full_of_someone_elses_keys_is_not_linked():
    # A truthiness check on metadata would call this linked and report nothing.
    state, _ = verdict({"metadata": {"utm_source": "newsletter"},
                        "payment_status": "paid"})
    assert state == "orphaned"


def test_paid_and_unidentified_is_worse_than_abandoned():
    assert verdict({"payment_status": "paid"})[0] == "orphaned"
    assert verdict({"payment_status": "unpaid"})[0] == "unlinked"


def test_some_expected_keys_but_not_all_is_partial():
    state, detail = verdict({"metadata": {"order_id": "42"}, "payment_status": "paid"},
                            ("order_id", "user_id"))
    assert state == "partial"
    assert "user_id" in detail


def test_empty_and_whitespace_references_do_not_count_as_set():
    assert verdict({"client_reference_id": "", "payment_status": "paid"})[0] == "orphaned"
    assert verdict({"client_reference_id": "   ", "payment_status": "paid"})[0] == "orphaned"
    assert verdict({"metadata": {"order_id": " "}, "payment_status": "paid"})[0] == "orphaned"
stripe-checkout-reconciliation.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-checkout-reconciliation.mjs';

test('client_reference_id is enough on its own', () => {
  const [state, detail] = verdict(
    { client_reference_id: 'ord_918', payment_status: 'paid' });
  assert.equal(state, 'linked');
  assert.match(detail, /ord_918/);
});

test('metadata full of someone elses keys is not linked', () => {
  const [state] = verdict(
    { metadata: { utm_source: 'newsletter' }, payment_status: 'paid' });
  assert.equal(state, 'orphaned');
});

test('paid and unidentified is worse than abandoned', () => {
  assert.equal(verdict({ payment_status: 'paid' })[0], 'orphaned');
  assert.equal(verdict({ payment_status: 'unpaid' })[0], 'unlinked');
});

test('some expected keys but not all is partial', () => {
  const [state, detail] = verdict(
    { metadata: { order_id: '42' }, payment_status: 'paid' },
    ['order_id', 'user_id']);
  assert.equal(state, 'partial');
  assert.match(detail, /user_id/);
});

test('empty and whitespace references do not count as set', () => {
  assert.equal(
    verdict({ client_reference_id: '', payment_status: 'paid' })[0], 'orphaned');
  assert.equal(
    verdict({ client_reference_id: '   ', payment_status: 'paid' })[0], 'orphaned');
  assert.equal(
    verdict({ metadata: { order_id: ' ' }, payment_status: 'paid' })[0], 'orphaned');
});

FAQ

What is client_reference_id actually for?

It is a free-text field on a Checkout Session for your own identifier, surfaced in the Dashboard and in exports. Stripe never interprets it. Put your order id in it at creation and the payment carries a pointer back to your database from that moment on.

Should I use client_reference_id or metadata?

Both. client_reference_id is a single value that shows up in the Dashboard where support will look for it. Metadata holds several keys and is what your own webhook handlers read. They cost nothing to set together and each covers a case the other misses.

How do I attach an order id to a Payment Link?

Set metadata on the Payment Link itself; it is copied onto every Checkout Session the link creates. A static link cannot carry a per-order id, so use it to identify the product or campaign, and create Sessions in code when you need true per-order attribution.

Can I add metadata to a Session after it completes?

You can update the Session's metadata afterwards, but you have to already know which order it was, and if you knew that you would not have needed the field. Treat this as fixable going forward only, and reconcile the historical window by hand once.

Why does this matter more for disputes than for reporting?

Because dispute evidence has a deadline. Reporting can wait while somebody matches emails to amounts; a dispute response needs the product description, the delivery address and the customer's messages assembled within days, and all of that lives behind an order id the charge does not carry.

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.