Skip to content

Diagnostic Stripe

connect_reserved grows as connected accounts go negative

Payouts to the platform's own bank are smaller than the ledger says they should be, and the shortfall grows a little every month. No payout failed, no charge was refunded twice, and the Dashboard's headline balance looks plausible. The money is not missing — it is reserved, against connected accounts whose own balances have gone below zero.

Read-only key Python and Node.js Tests included
A calculator sitting on top of a table next to another calculator
Photo by iSawRed on Unsplash
The short answer

Read GET /v1/balance on the platform and look at connect_reserved. It is an array, one entry per currency, and any entry with amount > 0 is money Stripe is holding out of your available balance to cover connected accounts you are liable for.

Then read the direction of travel from GET /v1/balance_transactions?type=reserve_transaction over the last 90 days, and the damage already taken from type=connect_collection_transfer. A connect_collection_transfer is not a hold: it is Stripe zeroing an account that stayed negative for 180 days by keeping your reserve, which makes it a permanent platform loss.

The problem in plain words

The reserve does not appear as a transaction anyone reviews. It is a field on the balance object, and almost every reconciler ever written reads available[0].amount and stops there. So the platform's own bank deposits quietly shrink while every individual charge, refund and fee reconciles perfectly, which is exactly the pattern that makes finance suspect the ledger rather than Stripe.

What makes it expensive is the clock attached to it. A reserve is recoverable: pay the negative account back and the hold is released. After 180 days it stops being recoverable, because Stripe settles the account by moving your reserve across for good. Nobody gets an invoice for that. It appears as a balance transaction of a type most teams have never listed.

Seller refundsbalance goesnegativeSeller stopstradingnothing earns itbackPlatformreservedheld fromavailable180 days passno one is watchingCollectiontransferreserve gone forgood
Every step succeeds. The only thing that happens is that the platform's own payouts get quietly smaller each month.

Why it happens

The platform is the loss-bearing party by default on most Connect configurations. When controller.losses.payments is "application", a refund or a chargeback on a connected account that has already been paid out lands on you. Stripe covers it by reserving from your balance, because it has nowhere else to take it from.

Negative balances are normal and self-clearing until they are not. An account that keeps trading earns its way back to zero out of its next charges, and the reserve releases on its own. The accounts that hurt are the ones that stopped trading: a seller who churned, a merchant who was suspended, a test account nobody closed. They have no incoming volume, so the negative balance is frozen and the reserve against it never releases.

The setting that would fix it is off by default and account-scoped. With debit_negative_balances enabled, Stripe pulls the shortfall from the connected account's own bank rather than holding your money against it. It is set per account, so it is easy to have it on for the accounts onboarded since somebody thought about this and off for everything older.

180 days is not a warning, it is a settlement. Stripe zeroes an account that has stayed negative that long with a connect_collection_transfer, funded from the reserve it was already holding. From your side the reserve simply disappears and the available balance does not go back up. There is no reversal for it and no invoice announcing it.

The fix, as a flow

The script reads what is held now, then two lists of balance transactions behind it, because a reserve balance is a level and only the movement underneath it says whether the money is coming back.

GET /v1/balanceplus 90 days of balance transactionsNothing reservedclearReserved and releasednormal operationHeld, no movementone dead account, 180 day clockCollection transfer seenalready lost, fix the setting
The same positive number means a business still taking losses or one dead account counting down. Only the balance transactions tell them apart.

How to fix it

Read connect_reserved on the platform balance, per currency

GET /v1/balance returns available, pending and connect_reserved as arrays. A platform trading in two currencies has two entries in each, and a check that reads index zero can miss the whole problem because the reserve happens to sit in the other one.

Get the trend from reserve_transaction, not from one reading

A reserve balance is a level, and a level tells you nothing about direction. GET /v1/balance_transactions?type=reserve_transaction&created[gte]= over 90 days says whether the hold is being topped up, released, or has been frozen at the same figure since a seller left.

List connect_collection_transfer separately, because that is the loss

These are the 180-day settlements. Anything in this list is money you have already lost rather than money being held, and the total is the honest number to give finance. It is also the argument for fixing the setting: every one of them started as a recoverable reserve.

Find the accounts responsible

GET /v1/balance with a Stripe-Account header returns that account's own balance, and available[].amount < 0 names the culprit. Confirm you are actually liable with GET /v1/accounts/{id} and controller.losses.payments; where that reads "stripe" the negative balance is not funded from your reserve.

Decide per account: pay it back, or make it pay itself back

A transfer to the account zeroes the balance and releases the reserve immediately, which is the right move for a live seller who will earn it back. For accounts that will never trade again, enabling debit_negative_balances moves the recovery to their bank instead of your balance. Doing neither is choosing the 180-day outcome.

How to check it worked

Re-run the script. connect_reserved should be zero in every currency, and the 90-day connect_collection_transfer total should stay at zero from here on.

python3 stripe_connect_reserve.py
# usd  clear     nothing reserved
# 1 currency bucket(s): 0 growing, 0 held, 0 written off

The full code

Two GETs for the summary and one paginated list per balance transaction type, all read-only — a restricted key with read access to Balance and Balance transactions is enough. The per-account pass is behind a flag because it costs one request per connected account. The classifier is pure and takes three numbers per currency: what is held now, what moved into reserve recently, and what has already been collected, because those three answers have three different repairs.

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_connect_reserve.py
"""Report platform funds held in connect_reserved against negative accounts.

Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
access to Balance, Balance transactions and Connected accounts. 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_connect_reserve")

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

DAY = 86400


def classify(entry, reserved, collected):
    """Sort one currency bucket of connect_reserved. Pure, so the states can be
    tested without a network.

    `entry` is one element of balance.connect_reserved. `reserved` is the total
    reserve_transaction activity seen in that currency over the window and
    `collected` the total connect_collection_transfer activity, both passed as
    magnitudes in minor units: the sign of a balance transaction depends on the
    direction of the movement, and only the size matters here.

    Returns (state, detail).
    """
    amount = entry.get("amount")
    if not isinstance(amount, int):
        return ("unknown", "connect_reserved entry has no numeric amount: %r" % (amount,))

    if collected:
        return ("written-off",
                "%d already moved out as connect_collection_transfer: accounts that "
                "stayed negative for 180 days were settled from your reserve, and "
                "that money is not coming back" % collected)

    if amount > 0 and reserved:
        return ("growing",
                "%d held now and %d of reserve_transaction activity in the window: "
                "accounts are still going negative faster than they earn back"
                % (amount, reserved))

    if amount > 0:
        return ("held",
                "%d held with no reserve_transaction activity in the window: the "
                "negative account behind it has stopped trading, so nothing will "
                "release this before the 180 day settlement" % amount)

    if reserved:
        return ("settled",
                "nothing held now, %d of reserve_transaction activity in the window: "
                "reserves were taken and released as accounts earned back" % reserved)

    return ("clear", "nothing reserved")


def get(session, path, headers=None, **params):
    r = session.get(API + path, params=params, headers=headers, 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 totals_by_currency(session, btype, since, cap):
    """Sum balance transactions of one type per currency, as magnitudes."""
    totals = {}
    seen = 0
    params = {"type": btype, "limit": 100, "created[gte]": since}
    while True:
        page = get(session, "/balance_transactions", **params)
        data = page.get("data", [])
        for bt in data:
            cur = bt.get("currency", "?")
            totals[cur] = totals.get(cur, 0) + abs(bt.get("amount") or 0)
            seen += 1
        if not data or not page.get("has_more") or seen >= cap:
            return totals
        params["starting_after"] = data[-1]["id"]


def negative_accounts(session, cap):
    """Yield (account_id, currency, amount, liable) for accounts below zero.

    One extra GET per connected account, which is why this is behind a flag.
    """
    params = {"limit": 100}
    seen = 0
    while True:
        page = get(session, "/accounts", **params)
        data = page.get("data", [])
        for acct in data:
            seen += 1
            aid = acct.get("id", "")
            controller = acct.get("controller") or {}
            losses = (controller.get("losses") or {}).get("payments")
            bal = get(session, "/balance", headers={"Stripe-Account": aid})
            for entry in bal.get("available", []):
                if (entry.get("amount") or 0) < 0:
                    yield (aid, entry.get("currency", "?"), entry["amount"],
                           losses == "application")
            if seen >= cap:
                return
        if not data or not page.get("has_more"):
            return
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=90,
                    help="window for reserve and collection activity")
    ap.add_argument("--accounts", action="store_true",
                    help="also find the negative connected accounts (one GET each)")
    ap.add_argument("--max-accounts", type=int, default=1000,
                    help="stop the per-account pass after this many accounts")
    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})

    since = int(time.time()) - args.days * DAY
    balance = get(s, "/balance")
    reserved_now = balance.get("connect_reserved") or []
    if not reserved_now:
        log.info("no connect_reserved on this balance: either not a platform, or "
                 "no account has ever gone negative")

    reserved = totals_by_currency(s, "reserve_transaction", since, 5000)
    collected = totals_by_currency(s, "connect_collection_transfer", since, 5000)

    # A currency can have activity in the window and nothing held now, so union
    # the buckets rather than iterating connect_reserved alone.
    currencies = ({e.get("currency", "?") for e in reserved_now}
                  | set(reserved) | set(collected))

    counts = {}
    for cur in sorted(currencies):
        entry = next((e for e in reserved_now if e.get("currency") == cur),
                     {"currency": cur, "amount": 0})
        state, detail = classify(entry, reserved.get(cur, 0), collected.get(cur, 0))
        counts[state] = counts.get(state, 0) + 1
        line = "%-4s %-11s %s" % (cur, state, detail)
        (log.info if state in ("clear", "settled") else log.warning)(line)

    log.info("%d currency bucket(s): %d growing, %d held, %d written off",
             len(currencies), counts.get("growing", 0), counts.get("held", 0),
             counts.get("written-off", 0))

    if args.accounts:
        found = 0
        for aid, cur, amount, liable in negative_accounts(s, args.max_accounts):
            found += 1
            log.warning("%s  %s %d  liable=%s", aid, cur, amount, liable)
        if not found:
            log.info("no connected account is currently below zero")

    if counts.get("growing") or counts.get("held") or counts.get("written-off"):
        log.warning("  repair, per negative account, in order of preference:")
        log.warning("  1. transfer the shortfall to the account to release the "
                    "reserve now: %s/transfers with destination=acct_x", API)
        log.warning("  2. make future shortfalls come out of the account's own "
                    "bank: %s/balance_settings with Stripe-Account, "
                    "payments[debit_negative_balances]=true", API)
        log.warning("  3. for accounts that will never trade again, reject them "
                    "so nothing more accrues: %s/accounts/{id}/reject", API)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
stripe-connect-reserve.mjs
/**
 * Report platform funds held in connect_reserved against negative accounts.
 *
 * Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
 * access to Balance, Balance transactions and Connected accounts. The repair is
 * printed, never performed.
 */
const API = 'https://api.stripe.com/v1';
const DAY = 86400;

/**
 * Sort one currency bucket of connect_reserved. Pure, so the states can be
 * tested without a network. `reserved` and `collected` are magnitudes in minor
 * units, because the sign of a balance transaction depends on the direction of
 * the movement and only the size matters here. Returns [state, detail].
 */
export function classify(entry, reserved, collected) {
  const amount = entry.amount;
  if (typeof amount !== 'number' || !Number.isFinite(amount)) {
    return ['unknown',
      `connect_reserved entry has no numeric amount: ${JSON.stringify(amount)}`];
  }

  if (collected) {
    return ['written-off',
      `${collected} already moved out as connect_collection_transfer: accounts ` +
      'that stayed negative for 180 days were settled from your reserve, and ' +
      'that money is not coming back'];
  }

  if (amount > 0 && reserved) {
    return ['growing',
      `${amount} held now and ${reserved} of reserve_transaction activity in the ` +
      'window: accounts are still going negative faster than they earn back'];
  }

  if (amount > 0) {
    return ['held',
      `${amount} held with no reserve_transaction activity in the window: the ` +
      'negative account behind it has stopped trading, so nothing will release ' +
      'this before the 180 day settlement'];
  }

  if (reserved) {
    return ['settled',
      `nothing held now, ${reserved} of reserve_transaction activity in the ` +
      'window: reserves were taken and released as accounts earned back'];
  }

  return ['clear', 'nothing reserved'];
}

async function get(key, path, params = {}, headers = {}) {
  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}`, ...headers },
  });
  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 totalsByCurrency(key, type, since, cap = 5000) {
  const totals = new Map();
  let seen = 0;
  const params = { type, limit: 100, 'created[gte]': since };
  for (;;) {
    const page = await get(key, '/balance_transactions', params);
    const data = page.data ?? [];
    for (const bt of data) {
      const cur = bt.currency ?? '?';
      totals.set(cur, (totals.get(cur) ?? 0) + Math.abs(bt.amount ?? 0));
      seen += 1;
    }
    if (data.length === 0 || !page.has_more || seen >= cap) return totals;
    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.env.WINDOW_DAYS ?? 90);
  const since = Math.floor(Date.now() / 1000) - days * DAY;

  const balance = await get(key, '/balance');
  const reservedNow = balance.connect_reserved ?? [];
  if (reservedNow.length === 0) {
    console.log('no connect_reserved on this balance: either not a platform, or ' +
                'no account has ever gone negative');
  }

  const reserved = await totalsByCurrency(key, 'reserve_transaction', since);
  const collected = await totalsByCurrency(key, 'connect_collection_transfer', since);

  const currencies = new Set([
    ...reservedNow.map((e) => e.currency ?? '?'),
    ...reserved.keys(), ...collected.keys(),
  ]);

  const counts = {};
  for (const cur of [...currencies].sort()) {
    const entry = reservedNow.find((e) => e.currency === cur)
      ?? { currency: cur, amount: 0 };
    const [state, detail] = classify(entry, reserved.get(cur) ?? 0,
                                     collected.get(cur) ?? 0);
    counts[state] = (counts[state] ?? 0) + 1;
    const line = `${cur.padEnd(4)} ${state.padEnd(11)} ${detail}`;
    if (state === 'clear' || state === 'settled') console.log(line);
    else console.warn(line);
  }

  console.log(`${currencies.size} currency bucket(s): ${counts.growing ?? 0} ` +
              `growing, ${counts.held ?? 0} held, ` +
              `${counts['written-off'] ?? 0} written off`);

  if (counts.growing || counts.held || counts['written-off']) {
    console.warn('  repair, per negative account, in order of preference:');
    console.warn(`  1. transfer the shortfall to the account to release the ` +
                 `reserve now: ${API}/transfers with destination=acct_x`);
    console.warn(`  2. make future shortfalls come out of the account's own bank: ` +
                 `${API}/balance_settings with Stripe-Account, ` +
                 `payments[debit_negative_balances]=true`);
    console.warn(`  3. for accounts that will never trade again, reject them so ` +
                 `nothing more accrues: ${API}/accounts/{id}/reject`);
    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 two states worth pinning apart are growing and held. They look identical on the balance object — both are a positive number sitting in connect_reserved — and they mean opposite things: one is a business still taking losses, the other is a single dead account counting down to a settlement. The third test covers the case where the countdown already finished.

test_stripe_connect_reserve.py
from stripe_connect_reserve import classify


def test_a_collection_transfer_outranks_everything_else():
    # The 180 day settlement already happened. Whatever is held now is a
    # secondary concern next to money that has permanently left.
    state, detail = classify({"currency": "usd", "amount": 4000}, 4000, 25000)
    assert state == "written-off"
    assert "connect_collection_transfer" in detail


def test_reserve_with_recent_activity_is_growing():
    state, detail = classify({"currency": "usd", "amount": 12000}, 9000, 0)
    assert state == "growing"
    assert "12000" in detail


def test_reserve_with_no_activity_is_the_dead_account_case():
    # Same positive amount, no movement behind it: nothing will release this
    # on its own, because the account that caused it has stopped trading.
    state, detail = classify({"currency": "usd", "amount": 12000}, 0, 0)
    assert state == "held"
    assert "180 day" in detail


def test_activity_with_nothing_held_is_normal_operation():
    state, _ = classify({"currency": "eur", "amount": 0}, 30000, 0)
    assert state == "settled"


def test_missing_amount_is_not_silently_clear():
    state, _ = classify({"currency": "usd"}, 0, 0)
    assert state == "unknown"
stripe-connect-reserve.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-connect-reserve.mjs';

test('a collection transfer outranks everything else', () => {
  const [state, detail] = classify({ currency: 'usd', amount: 4000 }, 4000, 25000);
  assert.equal(state, 'written-off');
  assert.match(detail, /connect_collection_transfer/);
});

test('reserve with recent activity is growing', () => {
  const [state, detail] = classify({ currency: 'usd', amount: 12000 }, 9000, 0);
  assert.equal(state, 'growing');
  assert.match(detail, /12000/);
});

test('reserve with no activity is the dead account case', () => {
  const [state, detail] = classify({ currency: 'usd', amount: 12000 }, 0, 0);
  assert.equal(state, 'held');
  assert.match(detail, /180 day/);
});

test('activity with nothing held is normal operation', () => {
  assert.equal(classify({ currency: 'eur', amount: 0 }, 30000, 0)[0], 'settled');
});

test('missing amount is not silently clear', () => {
  assert.equal(classify({ currency: 'usd' }, 0, 0)[0], 'unknown');
});

FAQ

What is connect_reserved actually holding?

Money Stripe has taken out of your available balance to cover connected accounts whose own balance is negative and whose losses you are liable for. It is still your money while it sits there, and it is released as soon as the account behind it returns to zero.

What happens after 180 days?

Stripe zeroes the negative account with a connect_collection_transfer funded from your reserve. At that point the shortfall stops being a hold and becomes a realised platform loss, with no reversal and no invoice. It shows up only as a balance transaction of that type.

How do I find which connected account is causing it?

GET /v1/balance with a Stripe-Account header returns that account's own balance; available[].amount below zero names it. Check GET /v1/accounts/{id} and controller.losses.payments to confirm you are the liable party, because a value of stripe there means the negative balance is not funded from your reserve.

Does debit_negative_balances fix the reserves I already have?

No. It changes what happens next: Stripe recovers future shortfalls from the connected account's own bank instead of holding yours. Existing holds still need the account brought back to zero, which usually means transferring the shortfall to it.

Can a read-only key do this check?

Yes. Balance, Balance transactions and Connected accounts with read access covers every request here, including the per-account balances read through the Stripe-Account header. It cannot move money if it leaks.

Related field notes

Where a reserve belongs in the ledger

A growing reserve is an operational problem and an accounting one: the money is yours, it is not in your bank, and it has to appear somewhere or your books understate assets. It belongs on its own account rather than being netted silently into fees. Payoutproof treats reserves as their own line in the journal for WooCommerce stores, so a reserve movement is visible rather than absorbed.

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.