Skip to content
All posts

A taxonomy of platform fee leakage

Leakage on Stripe Connect is a finite set of configuration states with deterministic costs. Seven vectors, seven API checks: walk the checklist yourself.

FeeGuard15 min read

A taxonomy of platform fee leakage

This article answers one question for people who own the money on a Stripe Connect platform: exactly which configuration states silently forfeit funds you were entitled to keep or recover? The answer is a finite list of leak vectors, each with a deterministic cost signature and a concrete API check. Walk the checklist against your own account.

Leakage, defined mechanically

Leakage is money the platform was entitled to keep or entitled to recover that left your balance without an error, a log line, or an alert. Nothing failed. Every API call returned 200. The objects Stripe created are all consistent with what was requested — the problem is what was not requested.

Two things that look like leakage are not leakage, and keeping them separate matters when you size the problem:

  • Fraud losses. Radar scores, blocks, and reviews payments for fraud risk before and at payment time (Radar). Fraud is adversarial and probabilistic. Leakage is neither: it follows mechanically from flag defaults.
  • Processing fees you knowingly pay. Stripe's processing fees from the original transaction are not returned when you refund (Refunds). That is a published cost of doing business. It becomes leakage only when you lose additional money around it.

Everything in this article is in the second category's neighborhood: deterministic consequences of refund-time defaults on destination charges, direct charges, and separate charges and transfers. For marketplaces splitting one payment across several parties, the same vectors multiply per payee; we cover that variant in the marketplace refund-leak deep dive.

The master taxonomy

The table below lists every leak vector this site tracks, which charge patterns it affects, what the default does, what one occurrence costs, and how you detect it. Sections that follow expand each detection check into something runnable.

VectorCharge patterns affectedDefault behaviorUnit cost signatureDetection check
L1 Unreversed transfer on refundDestination charges; separate charges and transfersDestination account keeps transferred funds; separate-pattern refunds have no effect on transfers at allMissing reversal up to the full transfer amount (full refund) or the uncovered share (partial)Compare Σ reversals against round((amount_refunded / charge.amount) × transfer.amount)
L2 Unrefunded application feeDirect charges; destination charges refunded without refund_application_feeApplication fees are kept by default on both patternsapplication_fee.amount_refunded stays 0 while charge.amount_refunded growsJoin each ApplicationFee to its charge and compare refunded amounts
L3 Uncovered dispute lossDestination charges; separate charges (any on_behalf_of setting)Stripe debits the disputed amount plus the dispute fee from the platform balance; the seller keeps the transferGap = transfer.amount − Σ reversals after the dispute closes as lostList disputes with status lost, then audit the related transfer
L4 Non-proportional partial-refund reversalDestination charges, partial refundsProportional reversal happens only if you request it; manual flat amounts under-recoverExpected proportional reversal − actual reversal amountSame formula as L1, evaluated per partial refund
L5 Cross-border FX spreadAny pattern where charge currency differs from settlement currencyRefund converts at the live rate on refund day; the original conversion fee is not returnedRefund-day debit minus charge-day settlement valueRecompute expected refund debit from the charge-day rate and diff
L6 Out-of-band refund pathsAll patternsDashboard or manual refunds bypass whatever flag policy lives in your codeL1/L2 signatures appearing only on refunds lacking your metadata conventionsMatch every Refund object against your application's request logs
L7 Integration driftCopied or legacy refund endpointsA call site copied between charge types omits explicit flags and inherits wrong defaultsLeaks cluster at specific endpoints rather than randomlyInventory every refunds.create call site; assert both flags are explicit

Three properties of this list matter. First, it is closed: these seven vectors exhaust the ways default refund behavior moves platform money to someone else, because refunds touch exactly three objects — the charge, any transfer, and any application fee. Second, each vector has a deterministic unit cost: no probabilities anywhere. Third, all seven are detectable from ordinary API reads with a restricted key; nothing here requires special access.

Walk the checklist yourself

Each check below runs against live data with the official stripe-node SDK and a restricted read-only key. The snippets use auto-pagination and stay read-only end to end. FeeGuard automates exactly these joins in its free audit, but the manual method is complete, so run it yourself first.

L1: find refunds whose transfer was never (fully) reversed

Group refunds by charge, sum what was refunded, compute the reversal the formula implies, and compare it to the reversals that actually exist:

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

type Sale = { refunded: number; charge: Stripe.Charge };

const sales = new Map<string, Sale>();

await stripe.refunds
  .list({ created: { gte: cutoff }, limit: 100, expand: ['data.charge'] })
  .autoPagingEach((refund) => {
    const charge = refund.charge as Stripe.Charge;
    if (isString(charge.transfer)) {
      const prior = sales.get(charge.id);
      if (prior) {
        prior.refunded += refund.amount;
      } else {
        sales.set(charge.id, { refunded: refund.amount, charge });
      }
    }
  });

let totalMissing = 0;

for (const [chargeId, sale] of sales) {
  const transferId = sale.charge.transfer as string;
  const transfer = await stripe.transfers.retrieve(transferId);
  const reversals = await stripe.transferReversals.list(transferId, { limit: 100 });

  let reversed = 0;
  for (const reversal of reversals.data) reversed += reversal.amount;

  const expected = Math.round((sale.refunded / sale.charge.amount) * transfer.amount);
  const missing = expected - reversed;

  if (missing > 0) {
    totalMissing += missing;
    console.log(`${chargeId}: expected ${expected}, reversed ${reversed}, missing ${missing}`);
  }
}

console.log(`total under-reversed across window: ${totalMissing}`);

charge.transfer links a destination charge to its transfer; the separate-charges pattern has no such link, so its reconciliation starts from your own transfer records instead. Amounts are integer cents throughout. If any single transfer has more than 100 reversals, paginate transferReversals.list fully before summing. For the mechanics of creating reversals after the fact — partial amounts, insufficient-balance errors, and netting — see the unreversed-transfer solution page.

L2: find sales that were refunded while their application fee was kept

Application fees aren't automatically refunded when issuing a refund — your platform must explicitly refund the fee or the connected account loses that amount (direct charges). The check joins each fee to its charge:

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;

let keptFees = 0;

await stripe.applicationFees
  .list({ created: { gte: cutoff }, limit: 100, expand: ['data.charge'] })
  .autoPagingEach((fee) => {
    const charge = fee.charge as Stripe.Charge;
    const refundedOnSale = charge.amount_refunded ?? 0;

    if (fee.amount_refunded === 0 && refundedOnSale > 0) {
      keptFees += fee.amount - fee.amount_refunded;
      console.log(`${fee.id}: fee ${fee.amount} kept, sale refunded ${refundedOnSale}`);
    }
  });

console.log(`total kept fees: ${keptFees}`);

A nonzero result means sellers funded refunds out of their own balances while your fee stayed intact — the exact case the direct-charges documentation warns about. We treat this vector in detail in the application-fee leak deep dive.

L3: find lost disputes where the seller still holds the transfer

On destination and separate patterns alike, Stripe debits the disputed amount and the dispute fee from the platform balance; recovery from the seller is a manual transfer reversal (Disputes on Connect). Check whether that reversal ever happened:

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

let uncovered = 0;

await stripe.disputes
  .list({ created: { gte: cutoff }, limit: 100, expand: ['data.charge'] })
  .autoPagingEach(async (dispute) => {
    switch (dispute.status) {
      case 'lost':
        break;
      default:
        return;
    }

    const charge = dispute.charge as Stripe.Charge;
    if (isString(charge.transfer) === false) return;

    const transferId = charge.transfer;
    const transfer = await stripe.transfers.retrieve(transferId);
    const reversals = await stripe.transferReversals.list(transferId, { limit: 100 });

    let reversed = 0;
    for (const reversal of reversals.data) reversed += reversal.amount;

    const retained = transfer.amount - reversed;
    if (retained > 0) {
      uncovered += retained;
      console.log(`${dispute.id}: seller retains ${retained} of ${transfer.amount}`);
    }
  });

L4 through L7 in prose

L4 uses the same rounding rule as L1 applied per partial refund: expected reversal = round((amount_refunded / charge.amount) × transfer.amount), missing = expected − Σ existing reversals. The failure mode is a code path that reverses a flat amount, or nothing, on partials.

L5 needs a baseline comparison rather than an object join; the method is short enough that we give it its own article — see the FX slippage deep dive and the companion piece on refund-day conversion below it.

L6 is organizational: list refunds over the window and match each one to a request your systems made. A refund with none of your metadata conventions and no matching log line came from the Dashboard or a support tool, and whatever flag policy you maintain did not apply to it.

L7 is a static check: grep your codebase for every refunds.create call site and confirm each passes refund_application_fee and reverse_transfer explicitly, chosen per charge type. Defaults differ by pattern, so a call site moved between patterns silently changes meaning.

The whole procedure also exists as a printable sequence in the refund-path audit checklist, for teams that prefer to work it offline.

One charge through the machine

Nothing abstract survives contact with a concrete ledger. Inputs and assumptions, stated plainly:

  • US platform, USD everywhere, standard US card pricing of 2.9% + $0.30 per Stripe's published pricing.
  • One destination charge of $100.00 = 10000¢, with application_fee_amount = $10.00 (1000¢) and transfer_data.destination set, so $90.00 moves to the connected account.
  • Processing fee: 2.9% × 10000¢ + 30¢ = 290¢ + 30¢ = 320¢ = $3.20, kept by Stripe.
  • Buyer later demands a full refund; support issues it with default flags. Then, on a second, identical order, the cardholder disputes the payment and loses.

Step arithmetic, each line on its own:

  • Charge settles net of processing fee: 10000¢ − 320¢ = 9680¢ → +$96.80
  • Transfer to seller: → −$90.00
  • Platform margin after sale: 96.80 − 90.00 = +$6.80
  • Full refund, defaults (no flags): refund debits the platform balance → −$100.00
  • Cumulative position: 6.80 − 100.00 = −$93.20

Conservation check — the books must balance to zero across parties: −93.20 (platform) + 90.00 (seller still holds) + 3.20 (Stripe kept) = 0. The buyer is whole, Stripe is whole, the seller is untouched, and the entire cost landed on the platform because nobody passed reverse_transfer=true.

Now the second order ends in a lost dispute. Stripe debits the disputed amount plus the dispute fee from the platform balance; call the fee F, since its exact value comes from Stripe's published schedule per country and card brand (disputes doc). The cumulative table across both orders, including the recovery step on order one:

StepMovementRunning platform position
Order 1 settles, net of $3.20 fee+$96.80+$96.80
Order 1 transfer to seller−$90.00+$6.80
Order 1 full refund, default flags−$100.00−$93.20
Order 1 transfer reversed manually afterward+$90.00−$3.20
Order 2 margin after sale+$6.80+$3.60
Order 2 lost dispute: debited $100.00 + F−$100.00 − F−$96.40 − F

Conclusion in dollars and cents: the refund-side leak on order one alone cost $93.20 — about 13.7 times the $6.80 margin the sale was supposed to earn (93.20 ÷ 6.80 ≈ 13.7) — and even with full recovery the order nets −$3.20, exactly the processing fee Stripe does not return. The lost dispute on order two contributes another $100.00 + F of loss against its own $6.80 margin, because its $90.00 transfer sits with a seller who has no incentive to volunteer it. Two routine outcomes, one shape of charge, and each one erased many multiples of the profit it was meant to produce before anyone noticed. Had order one been refunded with both flags true, the extra refund_application_fee=true would have pushed another −$10.00 to the platform and +$10.00 to the connected account — fee refunds compensate the seller, never the buyer (destination charges) — which is why flags deserve a written policy rather than muscle memory.

Prioritizing what to chase

You will not chase everything at once, so rank by two mechanical facts.

Recovery probability falls with age. Connected-account balances drain on the payout schedule, and once funds are paid out to the seller's bank, a reversal succeeds only while their available balance covers it. Recent findings can often be recovered outright; old ones migrate to the practical lever, which is netting — reducing future transfers until the debt clears rather than demanding an instant bank-top-up. Batching many small findings into one scheduled pass beats chasing them individually; the operational recipe lives in bulk reversal of historical findings.

Size versus count is a judgment call, not a formula. A single large unreversed transfer justifies a human conversation with the seller; hundreds of small kept fees do not, and should go straight to automated netting. State the tradeoff in your policy before the first awkward email, and send the underlying Stripe evidence with any request so the discussion is about facts.

Two cautions belong in the same paragraph. First, put idempotency keys on every recovery POST so a retried script cannot double-reverse (idempotency). Second, cross-border destination charges created with on_behalf_of carry sequencing risk: Stripe advises waiting until a dispute is lost before recovering those transfers, because winning means retransferring.

Prevention states

Prevention is a set of configuration states, not a habit. The table maps each state to safe or unsafe and says why.

Configuration stateSafe?Why
Every refunds.create call site passes reverse_transfer and refund_application_fee explicitly, per charge-type policySafeNo default is ever consulted; behavior is invariant to refactors
Any call site omits either flagUnsafeDefaults keep the transfer (destination/separate) and the fee (all patterns)
One wrapper service owns all refund creation; other paths removedSafePolicy lives in exactly one place; drift shows up as compile errors, not leaks
Staff can issue Dashboard refunds ad hocUnsafeOut-of-band refunds bypass flag policy entirely (vector L6)
A charge.refunded webhook recomputes expected reversal and fee refund, opening a finding on mismatchSafePost-verification catches leaks even when creation happened out-of-band
Reconciliation of separate-pattern transfers runs only at month-endWeakFunds may already be paid out; recovery degrades to netting
Cross-border on_behalf_of destination refunds wait for dispute loss before reversingSafeMatches Stripe's advised sequencing for cross-border recovery

The webhook row deserves emphasis because it is the only state that also covers humans clicking buttons in the Dashboard: verification happens after the fact, on the object, regardless of origin. Pair it with the wrapper service and the unsafe states stop being reachable in normal operation.

Frequently asked questions

Doesn't Stripe reverse transfers automatically when I refund?

No. On destination charges the default leaves the transferred funds with the connected account, and pulling them back requires reverse_transfer=true. On separate charges and transfers, refunding the charge has no effect on any associated transfers at all. The one automatic reversal Stripe performs is for async payment failures on destination charges — a different event from a refund.

Which single change prevents the most leakage?

Passing reverse_transfer=true on every destination-charge refund where your terms say the seller eats refunds. It converts a −$93.20 outcome into −$3.20 on the canonical $100 order, because the reversal returns the seller's portion while the residual equals the processing fee Stripe does not return. Add refund_application_fee per your fee policy, deliberately.

When I refund an application fee, who gets the money?

The connected account, always. Application-fee refunds push the fee funds back to the connected account that paid them; the buyer is made whole by the refund itself, never by the fee leg. On direct charges the effect is starkest: without an explicit fee refund, the seller absorbs the full refund plus loses your fee on top.

How far back is a finding worth chasing?

Mechanically there is no expiration on the arithmetic — the objects remain readable and the missing amounts stay computable. Practically, recovery options degrade with age: available balances drain through payouts, and old findings resolve through netting against future transfers or, eventually, write-offs. Anything inside the most recent payout cycle is cheap to fix; everything older should be batched and scheduled.

Check your own last 90 days

Every finding above comes from arithmetic that runs silently each time your platform refunds a buyer or absorbs a dispute. FeeGuard exists to surface that arithmetic: the free audit reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreclaimed application fee, and uncovered dispute loss with the amounts attached and the underlying Stripe objects included, so you can verify each line yourself before acting. You get the answer first; ongoing monitoring is optional afterward.

Run the free 90-day audit.

FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.