Skip to content
All posts

Application fees: where platform revenue actually lives

An ApplicationFee object records your cut, settles into the platform balance by charge pattern, and survives refunds by default: it stays until you refund it.

FeeGuard14 min read

Application fees: where platform revenue actually lives

If you run a Connect platform, most sales can carry a slice for you: the application fee. This article answers three questions for founders and finance teams: what an application fee is, where the money settles under each Connect charge pattern, and what refunds do to it by default. The default is: nothing.

Three ways to take a cut

Connect gives your platform three mechanisms for keeping revenue from a seller's sale, and they are not interchangeable. At the object level, an application fee is a platform-owned record of money carved out of a charge; see what an application fee is for the short definition. Whether that record exists at all depends on which charge pattern you use.

On direct charges, the charge is created on the connected account with the Stripe-Account header, and the application_fee_amount parameter carves your fee out of the sale. On destination charges, the charge is created on the platform and a portion moves to the seller through transfer_data[destination]. You have two ways to take a cut there: set application_fee_amount and let Stripe compute the transferred remainder, or set transfer_data[amount] yourself and keep whatever you did not send. Under separate charges and transfers, there is no fee parameter at all: you charge the buyer on the platform account and transfer less to the seller.

That distinction matters more than it looks, because only application_fee_amount produces an ApplicationFee object: a queryable, refundable, reportable record of your platform revenue. The other routes leave nothing behind except subtraction.

The choice also decides what is possible at refund time, before you write any refund code. A platform holding fee objects can express refund policy through flags Stripe understands natively. A separate-charges platform implements every policy through transfer sizing and reversals, with no fee flag to flip and no fee object to audit. Pick the mechanism while changing it is still cheap.

The table below maps each pattern to its parameter, whether an ApplicationFee object gets created, and the currency the fee settles in.

PatternParameterApplicationFee object created?Currency the fee settles in
Direct chargesapplication_fee_amountYesThe connected account's settlement currency
Destination chargesapplication_fee_amountYesThe connected account's settlement currency
Destination chargestransfer_data[amount]NoThe platform's default currency
Separate charges and transfersNone; you transfer lessNoThe platform's default currency

Read that last row again if your platform bills sellers through separate charges: your revenue exists only as the gap between the charge and the transfer, and no Stripe object represents it. Reconciliation, reporting, and refund-time decisions all become manual work.

How a fee object comes into existence

Three mechanical rules govern creation, per Stripe's documentation on collecting fees on direct charges and on destination charges.

First, the cap: application_fee_amount cannot exceed the charge amount. A fee equal to the full charge is technically valid; anything larger is rejected.

Second, the currency: the fee is computed in the charge currency. A 5800-cent charge in EUR with an 800-cent fee takes 8.00 EUR, not a converted dollar figure. Amounts travel in the smallest currency unit (cents for USD), and note that zero-decimal currencies such as JPY break the cents habit entirely, since 1000 means 1000 yen, not 10.00.

Third, timing differs by pattern. On destination charges the fee object is created with the charge. On direct charges, creation is asynchronous by default: the application_fee.created event announces the object after the fact. Passing expand[]=application_fee when you create the charge forces synchronous creation, which matters if your code reads the fee immediately.

If you consume the events instead, build for their delivery model: webhook deliveries are at-least-once with retries stretching to days, so handlers must be idempotent. The companion event application_fee.refunded announces fee refunds the same way. Neither event moves money; they announce objects whose money already moved.

Creating a destination charge with a fee:

import Stripe from "stripe";

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

const charge = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  payment_method_types: ["card"],
  transfer_data: { destination: process.env.SELLER_ACCOUNT_ID },
  application_fee_amount: 1000,
});

Creating a direct charge and forcing the fee object to exist before the call returns:

const directCharge = await stripe.charges.create(
  {
    amount: 10000,
    currency: "usd",
    source: "tok_visa",
    application_fee_amount: 1000,
  },
  { stripeAccount: process.env.SELLER_ACCOUNT_ID, expand: ["application_fee"] }
);

Either way, the result shows up under Payments, then Collected fees, in the Dashboard, and as an ApplicationFee object carrying at least amount, amount_refunded, currency, account, charge, refunded, and a balance_transaction. Two of those fields (amount and amount_refunded) are the entire story of the rest of this article.

Settlement: which balance the fee lands in

The fee is not a bookkeeping annotation. It is money that arrives in your platform balance as its own balance transaction, typed application_fee. When a fee is later refunded, a companion transaction typed application_fee_refund moves the money back out. If you reconcile from balance transactions rather than Dashboard views, those two types are your platform-revenue ledger.

Like every balance movement, the fee arrives first as pending and becomes available on your payout schedule, so daily cash reconciliations should expect the ordinary lag between collection and usable funds.

Per Stripe's object reference for application fees, no additional Stripe fee is charged on the fee itself: a 1000-cent fee contributes exactly $10.00 of settled revenue, before your own taxes and cost accounting.

One settlement subtlety trips up cross-border platforms, and it comes down to which parameter you chose. Per Stripe's currency conversion documentation:

  • With application_fee_amount, the platform receives the fee in the connected account's settlement currency. A fee on a charge settling in EUR arrives as EUR.
  • With transfer_data[amount], the retained amount simply never leaves the platform, so your revenue stays in the platform's default currency.

Same take rate, same sale, different arrival currency, and therefore different exposure to conversion costs when you eventually repatriate it. Platforms operating across borders should pick the parameter partly on this basis, not only on convenience.

The default fate on refund: nothing

Here is the behavior that generates the most confused finance questions: refunding a charge does nothing to the application fee, anywhere, by default.

Stripe's direct charges documentation states it plainly:

Application fees aren't automatically refunded when issuing a refund. Your platform must explicitly refund the application fee or the connected account—the account on which the charge was created—loses that amount.

Destination charges behave identically on this axis. By default the destination account keeps the transferred funds and the platform keeps the fee; both survive the refund unless you pass flags, per Stripe's destination charge refund rules. Separate charges and transfers do not even have a fee to keep: the platform simply does not spontaneously give back any part of the gap it retained, and refunding the charge has no impact on any associated transfers.

So on every pattern, a refunded sale leaves your fee sitting in your balance. Note what this means directionally: fee refunds compensate the seller, never the buyer. The buyer's refund comes from the refund itself; the fee question is purely between you and the connected account.

Sit with the ambiguity for a second, because the mechanics cannot resolve it for you. A kept fee attached to a refunded sale is either:

  • Intended policy: your terms say sellers forfeit their service fee on cancellations, so the money staying put is correct; or
  • Silent leakage: your terms promise sellers pro-rated outcomes, and every refund quietly overbills them.

The mechanics are fixed either way. Nothing automatic happens, so whichever outcome occurs is the one you decided on, or failed to decide on. The fix is not a setting; it is a refund policy expressed in flags at refund time, which is the next section.

Refunding a fee deliberately

Stripe gives you three levers, all documented in the Refunds API and in the refund_application_fee flag explainer.

Lever one: refund_application_fee=true on the refund request. Semantics: a full refund refunds the full fee; a partial refund refunds a proportional slice. The restriction is creator-only: per the API reference, "An application fee can be refunded only by the application that created the charge," which for your platform means fees on your own charges, nothing else.

Lever two, destination-specific: if you refund the fee on a destination charge, you must also reverse the transfer. The fee refund pushes fee funds back to the connected account; the transfer reversal pulls the principal back. Stripe enforces the pairing. Decide both halves independently; refunding the fee while leaving the transfer intact hands the seller double compensation.

Lever three: after the fact. If the original refund already went out without the flag, you can still refund the fee directly against the ApplicationFee object via POST /v1/application_fees/{id}/refunds, with an optional amount for partial recovery. The full walkthrough covers edge cases; the application-fee refund explainer covers the object model. The call looks like this:

curl https://api.stripe.com/v1/application_fees/fee_example123456789/refunds \
  -u "$STRIPE_SECRET_KEY:" \
  -d amount=400

Send an Idempotency-Key header with it: Stripe honors idempotency keys for 24 hours, which makes retry loops safe when a job runner times out mid-request. And note that a fee can be refunded only once in total (cumulative fee refunds cannot exceed the original fee), so partial after-the-fact recovery consumes headroom. Check amount_refunded on the fee before you size the call.

A worked lifecycle in integer cents

Assumptions, stated up front:

  • US platform, USD, direct charge created with the Stripe-Account header on the seller's account.
  • Processing cost billed to the connected account, assuming Stripe's standard US card pricing of 2.9% + $0.30, per Stripe's published pricing: 290 + 30 = 320 cents on a $100.00 charge.
  • Charge amount 10000 cents ($100.00); application fee 1000 cents ($10.00).

Sale, day zero:

charge.amount            = 10000
processing fee           =   320
application_fee_amount   =  1000
seller net               = 10000 - 320 - 1000 = 8680   ($86.80)
platform fee entry       = +1000                ($10.00)
ApplicationFee state     = refunded:false, amount_refunded:0

Full refund, default flags, meaning the refund call passes no extra parameters:

refund.amount            = 10000                (debited from the seller)
fee refund               = none
ApplicationFee state     = refunded:false, amount_refunded:0   (unchanged)
platform fee position    = +1000                ($10.00 kept)
seller position          = 8680 - 10000 = -1320 (-$13.20)
buyer                    = made whole

The seller absorbed the refund, the processing fee, and your fee. This is the exact scenario the quoted documentation warns about: the connected account loses the fee amount.

Full refund with refund_application_fee=true:

refund.amount            = 10000
fee refund               = 1000 x (10000 / 10000) = 1000   (full fee)
ApplicationFee state     = refunded:true, amount_refunded:1000
platform fee position    = 1000 - 1000 = 0      ($0.00)
seller position          = -1320 + 1000 = -320  (-$3.20)

The seller ends out exactly the non-returned processing cost, which Stripe keeps. Your fee position goes to zero. Nobody is overcompensated.

Partial refund of 4000 cents ($40.00, a 40% refund) with refund_application_fee=true:

refund fraction          = 4000 / 10000 = 0.40
fee refund               = 1000 x 0.40 = 400     ($4.00)
ApplicationFee state     = refunded:false, amount_refunded:400
platform fee position    = 1000 - 400 = 600      ($6.00)
seller position          = 8680 - 4000 + 400 = 5080 ($50.80)
buyer net outlay         = 100 - 40 = $60.00     (kept the goods)

Note the state in row four: refunded stays false even though 400 cents moved, because the boolean only flips at full refund. Trust amount_refunded, not refunded.

The table consolidates the four states of one fee object across its life.

Eventrefundedamount_refundedPlatform fee positionSeller position
Salefalse0 cents+$10.00+$86.80
Full refund, defaultsfalse0 cents+$10.00-$13.20
Full refund, fee flaggedtrue1000 cents$0.00-$3.20
40% partial, fee flaggedfalse400 cents+$6.00+$50.80

One convention worth fixing early, wherever you compute proportional slices: work in integer cents and round once, as in round((amount refunded / charge amount) x fee amount). Treat any gap between that expectation and the fee object's actual state as a finding worth explaining. Floating-point money is how reconciliation drifts start.

If you want to test policy variants against your own take rates before touching production refunds, the application fee refund calculator runs the same arithmetic interactively.

Reporting and detecting kept fees

Three reporting surfaces exist, in ascending order of effort.

Dashboard: Payments, then Collected fees, lists fee objects with their refunded amounts. Adequate for spot checks; useless at volume.

API: list fees directly with time bounds, which is what a monthly close usually needs:

curl "https://api.stripe.com/v1/application_fees?created[gte]=1754006400&created[lt]=1756684800&limit=100" \
  -u "$STRIPE_SECRET_KEY:"

Sigma: fees are queryable with SQL alongside the rest of your Stripe data. Before you publish any query, confirm the exact table and column names against Stripe's documented data schema for your account, since schema naming evolves and the Sigma overview points you to the current set. Visibility tools answer "what happened"; they do not answer "what is missing."

The missing-thing question needs a join, and the signature is narrow: a refund succeeded while the fee's amount_refunded stayed at 0. Whether that is leakage or policy depends entirely on the intent you codified above, but you cannot decide until you can see it. Scoped to one connected account's direct charges, the check is short:

import Stripe from "stripe";

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

const refunds = await stripe.refunds.list(
  { limit: 100 },
  { stripeAccount: account }
);

for (const refund of refunds.data) {
  if (refund.status === "succeeded") {
    const charge = await stripe.charges.retrieve(
      refund.charge,
      { expand: ["application_fee"] },
      { stripeAccount: account }
    );
    const fee = charge.application_fee;
    if (typeof fee === "object" && fee.amount_refunded === 0) {
      console.log(refund.id, charge.id, fee.id, fee.amount, fee.currency);
    }
  }
}

Every line that prints is a refund where your platform kept its full fee. Run the same loop across all connected accounts and you have a complete kept-fee census. None of it needs write access: Stripe's restricted keys scope permissions per resource, and a read-only key covering charges, refunds, and application fees runs the whole census while being incapable of moving a cent. The deeper methodology lives in the application-fee leak audit. Then, and only then, attach consequences: refund what policy says should be refunded, and encode the policy into the flags your refund code passes from now on.

Frequently asked questions

Does Stripe charge a fee on the application fee itself?

No. Per Stripe's application fee documentation, no additional Stripe fee is applied to the fee. A 1000-cent application fee settles as exactly $10.00 of platform revenue, arriving as an application_fee-typed balance transaction.

If I refund a charge, does the buyer get the application fee back?

The buyer never touches the fee in either direction. The refund returns the purchase amount to the buyer; the application fee is a separate flow between your platform and the connected account. Fee refunds push money back to the seller's side, which is why refunding the fee on top of a full transfer reversal can over-compensate a seller on destination charges.

Why does my fee sometimes settle in a different currency?

Because settlement follows the parameter. With application_fee_amount, the platform receives the fee in the connected account's settlement currency; with transfer_data[amount], the retained portion stays in the platform's default currency. Cross-border platforms should expect mixed-currency fee income under the first pattern.

Can I refund an application fee long after the original refund?

Yes. Post a refund against the fee itself via POST /v1/application_fees/{id}/refunds, optionally with an amount for partial recovery. Only the platform that created the charge can do it, cumulative fee refunds cannot exceed the original fee, and the fee's amount_refunded tells you how much headroom remains.

Check your last 90 days

This arithmetic runs silently on every refund your platform issues: a deliberately kept fee and a forgotten fee are indistinguishable until someone joins the refund ledger to the fee ledger. FeeGuard reads your last 90 days of Connect activity through a restricted, read-only API key and reports every unreclaimed application fee, unreversed transfer, and uncovered dispute loss with the amounts attached. 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.