Skip to content

Diagnostic Stripe

the platform collects zero application fees on its charges

The marketplace is working. Volume is up, sellers are getting paid, the Connect dashboard is busy. The platform's own revenue line reads zero, and has since launch. Nothing errored: every charge did exactly what it was told, which was to pass the whole amount through.

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

Read GET /v1/application_fees?limit=100&created[gte]= over the last 30 days. An empty list on a platform that is taking destination charges means no ApplicationFee object has ever been created, and a fee object only exists when you pass application_fee_amount on the charge.

Then count the other side: GET /v1/charges and look for entries with transfer_data.destination set but application_fee_amount null. That count is how many charges went out at full value. Cross-check with GET /v1/balance_transactions?type=application_fee, which should be empty for the same reason.

The problem in plain words

There is no error to find. Omitting application_fee_amount is a perfectly valid destination charge: the platform takes the payment, the whole amount is transferred to the connected account, and everybody involved gets a success response. The only thing missing is the platform's cut, and nothing in the API is going to raise that as a concern.

The second version is worse, because it looks like it is working. If the integration under-transfers by setting transfer_data.amount to less than the charge, the platform does keep the difference — but no ApplicationFee object is created for it. Revenue is real and invisible: every fee report, every reconciliation built on /v1/application_fees, and the Dashboard's own application fees page all read zero.

Charge createdtransfer_datadestinationNo feeparameterapplication_fee_amountabsentFull amountmovesseller paid infullNo fee objectnothing to reportonRevenue linezerosince launch
Nothing errors. The charge did exactly what it was told, which was to pass everything through to the seller.

Why it happens

The fee object is created by a parameter, not by a setting. There is no platform-level fee percentage that Stripe applies for you. ApplicationFee exists only where application_fee_amount was passed on the PaymentIntent or Charge, in minor units, per charge. Miss the parameter on one code path and that path is free.

Under-transferring is a different mechanism with the same balance effect. Setting transfer_data.amount below the charge total leaves the remainder on the platform. The money is right and the reporting is wrong, because the split happened inside the transfer rather than as a fee. Anyone reconciling platform revenue from application fees will conclude the marketplace is free.

It usually starts as one code path. Subscriptions, invoices, a mobile checkout added later, a manual charge for enterprise deals. Each of these creates charges through a different call, and the one written last is the one that forgot the parameter. Which is why the useful number is not are there fees but what fraction of destination charges have one.

Fee collection depends on the destination's transfers capability. Passing application_fee_amount to an account whose transfers capability is not active fails the whole charge rather than quietly dropping the fee. So a team that adds the parameter without checking capabilities first turns a revenue leak into declined payments, which is a worse Monday.

The fix, as a flow

The script counts fee objects on one side and destination charges on the other, because the useful number is the fraction of charges carrying a fee rather than whether any fee exists at all.

GET /v1/application_feesplus a pass over /v1/chargesEvery charge has a feecollectingSome charges have noneone code path forgotKept via transfer_datarevenue exists, reporting does notNo fees anywhereadd the parameter, check capabilities
Under transferring keeps the money and creates no fee object, so the revenue is real and every fee report reads zero.

How to fix it

List application fees over a window you actually traded in

GET /v1/application_fees?limit=100&created[gte]= for the last 30 days. Zero results on a platform with destination charges in the same window is the finding; zero results on a platform with no destination charges at all just means the window was quiet.

Count the destination charges, split three ways

Paginate GET /v1/charges and sort each charge with transfer_data.destination into one of three buckets: it has application_fee_amount, it has a transfer_data.amount below the charge total, or it has neither. The three buckets are collected revenue, invisible revenue and no revenue.

Cross-check the balance transactions

GET /v1/balance_transactions?type=application_fee should agree with the fee list. If fees exist as objects but never as balance transactions, you are looking at a different problem — fees on charges that were later refunded — and the revenue was collected and given back.

Check the transfers capability before changing anything

For each destination you are about to start charging a fee on, confirm capabilities.transfers is active. Adding application_fee_amount to a charge aimed at an account without it fails the payment outright, so this check is what keeps the fix from becoming an outage.

Add the parameter on every path that creates a charge

The gap is almost never in the main checkout. Enumerate the places charges are created — subscriptions, invoices, the mobile app, the admin tool — and confirm each one passes application_fee_amount alongside transfer_data.destination. Then re-run this script and watch the fraction, not the total.

How to check it worked

Re-run the script over the days since the fix. Every destination charge should carry a fee, and the fee count should track the charge count rather than sitting at zero.

python3 stripe_application_fees.py --days 7
# collecting  312 destination charge(s), 312 with application_fee_amount
# 288 application fee object(s) in the window

The full code

Two paginated GETs and one cheap cross-check, all read-only — a restricted key with read access to Charges, Application fees and Balance transactions covers it. The classifier is pure and takes four counts, because the difference between no fee is being taken and a fee is being taken invisibly is arithmetic on those counts and not something you can see on any single charge.

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_application_fees.py
"""Report destination charges that carry no application fee.

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

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

DAY = 86400


def classify(fee_count, dest_total, dest_with_fee, dest_implicit):
    """Sort a platform's fee collection over one window. Pure, so the states can
    be tested without a network.

    `fee_count` is how many ApplicationFee objects exist in the window.
    `dest_total` is how many charges had transfer_data.destination set,
    `dest_with_fee` how many of those carried application_fee_amount, and
    `dest_implicit` how many instead under-transferred with transfer_data.amount,
    which keeps money on the platform without ever creating a fee object.

    Returns (state, detail).
    """
    counts = (fee_count, dest_total, dest_with_fee, dest_implicit)
    if any(not isinstance(c, int) or c < 0 for c in counts):
        return ("unknown", "counts must be non-negative integers: %r" % (counts,))
    if dest_with_fee + dest_implicit > dest_total:
        return ("unknown",
                "%d charges with a fee and %d implicit against only %d destination "
                "charges: the counts do not agree"
                % (dest_with_fee, dest_implicit, dest_total))

    if dest_total == 0:
        return ("idle",
                "no destination charges in the window, so there is nothing here "
                "that could carry an application fee")

    if fee_count == 0 and dest_implicit and not dest_with_fee:
        return ("invisible",
                "%d of %d destination charge(s) keep money on the platform via "
                "transfer_data[amount] and no ApplicationFee object exists: the "
                "revenue is real but every fee report will read zero"
                % (dest_implicit, dest_total))

    if fee_count == 0:
        return ("zero",
                "%d destination charge(s), none with application_fee_amount: the "
                "full amount went to the connected account every time"
                % dest_total)

    missing = dest_total - dest_with_fee - dest_implicit
    if missing > 0:
        return ("partial",
                "%d of %d destination charge(s) carry no fee at all: one code path "
                "that creates charges is not passing application_fee_amount"
                % (missing, dest_total))

    if dest_implicit:
        return ("mixed",
                "%d charge(s) take the fee explicitly and %d take it implicitly "
                "through transfer_data[amount]: the implicit ones never appear in "
                "/v1/application_fees" % (dest_with_fee, dest_implicit))

    return ("collecting",
            "%d destination charge(s), %d with application_fee_amount"
            % (dest_total, dest_with_fee))


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 count_pages(session, path, since, cap, on_item=None):
    """Paginate a created[gte] list, optionally inspecting each item."""
    seen = 0
    params = {"limit": 100, "created[gte]": since}
    while True:
        page = get(session, path, **params)
        data = page.get("data", [])
        for item in data:
            seen += 1
            if on_item:
                on_item(item)
        if not data or not page.get("has_more") or seen >= cap:
            return seen
        params["starting_after"] = data[-1]["id"]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30, help="window to look at")
    ap.add_argument("--max-charges", type=int, default=5000,
                    help="stop paginating charges after this many")
    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

    fee_count = count_pages(s, "/application_fees", since, 5000)

    tally = {"dest": 0, "fee": 0, "implicit": 0}
    destinations = set()

    def inspect(charge):
        transfer = charge.get("transfer_data") or {}
        dest = transfer.get("destination")
        if not dest:
            return
        tally["dest"] += 1
        destinations.add(dest if isinstance(dest, str) else dest.get("id", "?"))
        if charge.get("application_fee_amount") is not None:
            tally["fee"] += 1
        elif (transfer.get("amount") is not None
              and transfer["amount"] < (charge.get("amount") or 0)):
            tally["implicit"] += 1

    count_pages(s, "/charges", since, args.max_charges, inspect)

    state, detail = classify(fee_count, tally["dest"], tally["fee"],
                             tally["implicit"])
    (log.info if state in ("collecting", "idle") else log.warning)(
        "%-11s %s", state, detail)
    log.info("%d application fee object(s) in the window", fee_count)

    # The fee list and the application_fee balance transactions should agree.
    # Fees that exist as objects but never as balance transactions were
    # collected and then refunded, which is a different problem from this one.
    bt_page = get(s, "/balance_transactions", limit=100, type="application_fee",
                  **{"created[gte]": since})
    if fee_count and not bt_page.get("data"):
        log.warning("fee objects exist but no application_fee balance transaction "
                    "in the window: the fees were taken and refunded back out")

    if state in ("zero", "invisible", "partial", "mixed"):
        log.warning("  repair: pass application_fee_amount in minor units on every "
                    "call that creates a charge with transfer_data[destination], "
                    "including subscriptions and invoices.")
        log.warning("  check first, on each destination: GET %s/accounts/{id} and "
                    "confirm capabilities.transfers is active, because a fee on an "
                    "account without it fails the whole charge.", API)
        log.warning("  %d destination account(s) seen in this window", len(destinations))
        return 1
    return 1 if state == "unknown" else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-application-fees.mjs
/**
 * Report destination charges that carry no application fee.
 *
 * Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
 * access to Charges, Application fees and Balance transactions. The repair is
 * printed, never performed.
 */
const API = 'https://api.stripe.com/v1';
const DAY = 86400;

/**
 * Sort a platform's fee collection over one window. Pure, so the states can be
 * tested without a network. Returns [state, detail].
 */
export function classify(feeCount, destTotal, destWithFee, destImplicit) {
  const counts = [feeCount, destTotal, destWithFee, destImplicit];
  if (counts.some((c) => !Number.isInteger(c) || c < 0)) {
    return ['unknown',
      `counts must be non-negative integers: ${JSON.stringify(counts)}`];
  }
  if (destWithFee + destImplicit > destTotal) {
    return ['unknown',
      `${destWithFee} charges with a fee and ${destImplicit} implicit against ` +
      `only ${destTotal} destination charges: the counts do not agree`];
  }

  if (destTotal === 0) {
    return ['idle',
      'no destination charges in the window, so there is nothing here that ' +
      'could carry an application fee'];
  }

  if (feeCount === 0 && destImplicit && !destWithFee) {
    return ['invisible',
      `${destImplicit} of ${destTotal} destination charge(s) keep money on the ` +
      'platform via transfer_data[amount] and no ApplicationFee object exists: ' +
      'the revenue is real but every fee report will read zero'];
  }

  if (feeCount === 0) {
    return ['zero',
      `${destTotal} destination charge(s), none with application_fee_amount: ` +
      'the full amount went to the connected account every time'];
  }

  const missing = destTotal - destWithFee - destImplicit;
  if (missing > 0) {
    return ['partial',
      `${missing} of ${destTotal} destination charge(s) carry no fee at all: ` +
      'one code path that creates charges is not passing application_fee_amount'];
  }

  if (destImplicit) {
    return ['mixed',
      `${destWithFee} charge(s) take the fee explicitly and ${destImplicit} take ` +
      'it implicitly through transfer_data[amount]: the implicit ones never ' +
      'appear in /v1/application_fees'];
  }

  return ['collecting',
    `${destTotal} destination charge(s), ${destWithFee} with application_fee_amount`];
}

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 countPages(key, path, since, cap = 5000, onItem = null) {
  let seen = 0;
  const params = { limit: 100, 'created[gte]': since };
  for (;;) {
    const page = await get(key, path, params);
    const data = page.data ?? [];
    for (const item of data) {
      seen += 1;
      if (onItem) onItem(item);
    }
    if (data.length === 0 || !page.has_more || seen >= cap) return seen;
    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 ?? 30);
  const since = Math.floor(Date.now() / 1000) - days * DAY;

  const feeCount = await countPages(key, '/application_fees', since);

  const tally = { dest: 0, fee: 0, implicit: 0 };
  const destinations = new Set();

  await countPages(key, '/charges', since, 5000, (charge) => {
    const transfer = charge.transfer_data ?? {};
    const dest = transfer.destination;
    if (!dest) return;
    tally.dest += 1;
    destinations.add(typeof dest === 'string' ? dest : (dest.id ?? '?'));
    if (charge.application_fee_amount !== null
        && charge.application_fee_amount !== undefined) {
      tally.fee += 1;
    } else if (transfer.amount !== null && transfer.amount !== undefined
               && transfer.amount < (charge.amount ?? 0)) {
      tally.implicit += 1;
    }
  });

  const [state, detail] = classify(feeCount, tally.dest, tally.fee, tally.implicit);
  const line = `${state.padEnd(11)} ${detail}`;
  if (state === 'collecting' || state === 'idle') console.log(line);
  else console.warn(line);
  console.log(`${feeCount} application fee object(s) in the window`);

  // Fees that exist as objects but never as balance transactions were collected
  // and then refunded, which is a different problem from this one.
  const btPage = await get(key, '/balance_transactions',
    { limit: 100, type: 'application_fee', 'created[gte]': since });
  if (feeCount && (btPage.data ?? []).length === 0) {
    console.warn('fee objects exist but no application_fee balance transaction in ' +
                 'the window: the fees were taken and refunded back out');
  }

  if (['zero', 'invisible', 'partial', 'mixed'].includes(state)) {
    console.warn('  repair: pass application_fee_amount in minor units on every ' +
                 'call that creates a charge with transfer_data[destination], ' +
                 'including subscriptions and invoices.');
    console.warn(`  check first, on each destination: GET ${API}/accounts/{id} and ` +
                 'confirm capabilities.transfers is active, because a fee on an ' +
                 'account without it fails the whole charge.');
    console.warn(`  ${destinations.size} destination account(s) seen in this window`);
    process.exitCode = 1;
  } else if (state === 'unknown') {
    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 test that matters is invisible: a platform that keeps money by under-transferring has real revenue and zero fee objects, and collapsing that into the same bucket as a platform taking nothing at all sends somebody to add a parameter that would double-charge the seller. The partial test covers the ordinary version, where one code path out of four forgot.

test_stripe_application_fees.py
from stripe_application_fees import classify


def test_no_destination_charges_is_not_a_finding():
    state, _ = classify(0, 0, 0, 0)
    assert state == "idle"


def test_destination_charges_with_no_fees_anywhere():
    state, detail = classify(0, 480, 0, 0)
    assert state == "zero"
    assert "480" in detail


def test_under_transferring_is_revenue_that_no_report_shows():
    # The platform is keeping the difference, so the money is right and every
    # fee report is wrong. Telling someone to "add the fee" here is a mistake.
    state, detail = classify(0, 480, 0, 480)
    assert state == "invisible"
    assert "transfer_data[amount]" in detail


def test_one_code_path_missing_the_parameter():
    state, detail = classify(360, 480, 360, 0)
    assert state == "partial"
    assert "120 of 480" in detail


def test_counts_that_do_not_add_up_are_not_reported_as_healthy():
    state, _ = classify(10, 5, 4, 3)
    assert state == "unknown"
stripe-application-fees.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './stripe-application-fees.mjs';

test('no destination charges is not a finding', () => {
  assert.equal(classify(0, 0, 0, 0)[0], 'idle');
});

test('destination charges with no fees anywhere', () => {
  const [state, detail] = classify(0, 480, 0, 0);
  assert.equal(state, 'zero');
  assert.match(detail, /480/);
});

test('under transferring is revenue that no report shows', () => {
  const [state, detail] = classify(0, 480, 0, 480);
  assert.equal(state, 'invisible');
  assert.match(detail, /transfer_data\[amount\]/);
});

test('one code path missing the parameter', () => {
  const [state, detail] = classify(360, 480, 360, 0);
  assert.equal(state, 'partial');
  assert.match(detail, /120 of 480/);
});

test('counts that do not add up are not reported as healthy', () => {
  assert.equal(classify(10, 5, 4, 3)[0], 'unknown');
});

FAQ

Why is /v1/application_fees empty when the marketplace is busy?

Because an ApplicationFee object is only created when application_fee_amount is passed on the charge. There is no platform-wide fee setting that Stripe applies for you, so a destination charge without that parameter transfers the full amount and leaves no fee behind.

We keep money by setting transfer_data[amount] lower. Is that the same thing?

Financially yes, for reporting no. Under-transferring leaves the remainder on the platform without creating a fee object, so the revenue exists but /v1/application_fees, the application fee balance transactions and the Dashboard's fees page all read zero.

Can I add the fee retroactively to charges that already settled?

No. The fee is part of the charge, so it has to be set when the charge is created. Money already transferred can only be recovered by a separate transfer reversal, which is a different operation with different consequences for the seller.

Why did adding application_fee_amount start failing charges?

Fee collection depends on the destination account's transfers capability being active. Passing the parameter to an account without it fails the entire charge rather than dropping the fee, so check capabilities.transfers on every destination before rolling the change out.

Which code paths usually forget the parameter?

The ones written after the main checkout: subscriptions and invoices, a mobile client, an internal admin tool for enterprise deals. That is why the number to watch is the fraction of destination charges carrying a fee, not whether any fees exist.

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.