Skip to content
All posts

Designing application-fee structures: flat, percentage, hybrid

Flat, percentage, and hybrid application fees behave differently under partial refunds and disputes. The math for each, shown so the choice is informed.

FeeGuard14 min read

Designing application-fee structures: flat, percentage, hybrid

How your application fee survives contact with refunds depends entirely on how you encoded it. This article derives the behavior of percentage, flat, and hybrid structures under partial refunds and lost disputes, line by line, so founders, platform engineers, and finance leads can pick a scheme they will not have to unwind later.

Three ways to encode one integer

An application fee is a single integer you attach at charge time. On a destination charge or a direct charge, you pass application_fee_amount in the charge currency's smallest unit, capped at the charge amount, and Stripe takes no additional fee on the fee itself. If you are new to the object behind this, start with what an application fee is. Stripe stores the integer; it never computes your pricing. That means every rounding decision, floor, and cap below is yours to implement.

The three common encodings, written against integer cents:

  • Percentage: fee = round(p × amount) — for example, 10% of 10000¢ becomes round(0.10 × 10000) = 1000¢.
  • Flat: fee = min(F, amount) — a fixed F cents per order, clamped so it can never exceed the charge.
  • Hybrid: fee = min(cap, max(floor, round(p × amount))) — a percentage bracketed by a floor and a cap.

In code, basis points avoid floating-point drift (1000 bps means 10%):

import Stripe from "stripe";

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

function pctFee(amountCents: number, rateBps: number): number {
  return Math.round((amountCents * rateBps) / 10000);
}

function flatFee(amountCents: number, flatCents: number): number {
  return Math.min(flatCents, amountCents);
}

function hybridFee(
  amountCents: number,
  rateBps: number,
  floorCents: number,
  capCents: number,
): number {
  const scaled = pctFee(amountCents, rateBps);
  return Math.min(capCents, Math.max(floorCents, scaled));
}

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  application_fee_amount: hybridFee(10000, 1000, 200, 3000),
  transfer_data: { destination: "acct_SELLER_ACCOUNT_ID" },
});

On this $100.00 order all three produce a legal integer, but they produce different integers: 1000¢ for the percentage, whatever your flat constant is, and 1000¢ for this hybrid because the scaled value lands between its floor and cap. The differences stay quiet until money starts moving backward.

One proportional rule, three different outcomes

Stripe has one rule for returning application fees, and it does not know which structure you chose. When a refund passes refund_application_fee=true, the fee comes back proportionally to the refunded amount: full refund returns the full fee, a partial refund returns the same share of the fee as the share of the charge refunded. The rule operates on the stored integer, blind to whether that integer came from a percentage, a constant, or a bracketed formula.

Percentage structure, worked: charge $100.00, fee at 10%.

  • Fee charged: round(0.10 × 10000¢) = 1000¢ ($10.00)
  • Buyer refunded 50%: round(0.50 × 1000¢) = 500¢ returned ($5.00)
  • Fee kept: 1000¢ − 500¢ = 500¢ ($5.00)

The kept fee is still exactly 10% of the remaining $50.00 of volume. The structure scales naturally because the fee was a function of value delivered, and value delivered just halved.

Flat structure, worked: charge $100.00, flat fee of $25.00.

  • Fee charged: min(2500¢, 10000¢) = 2500¢ ($25.00)
  • Buyer refunded 50%: round(0.50 × 2500¢) = 1250¢ returned ($12.50)
  • Fee kept: 2500¢ − 1250¢ = 1250¢ ($12.50)

Half your flat revenue comes back even though your cost to serve the order did not halve. Measured against remaining volume, both structures keep their original ratio — proportional return always preserves ratios. The difference is absolute: under the percentage encoding, the giveback tracks value delivered; under the flat encoding, fee revenue per fulfilled order falls 50% while fulfillment cost per fulfilled order stays near 100%. Plan contribution margins against the refund frequency you actually have, not the one you hope for.

Hybrid structures inherit whichever branch produced the fee. A refund inside the floor-to-cap band behaves like the percentage case; a refund on an order where the floor or cap set the fee behaves like a flat case for the band's edges. Test refunds at all three points when you ship a hybrid.

Same order, same partial refund, three ledgers

To compare structures under identical conditions, fix one canonical order and refund it three ways. Inputs and assumptions:

  • US platform, USD, destination charge with transfer_data.destination.
  • Charge $100.00 = 10000¢; assume Stripe's standard US card pricing of 2.9% + $0.30 (Stripe's published pricing), so Stripe's processing fee is $3.20 = 320¢.
  • Partial refund of $40.00 = 4000¢ (40%), with both reverse_transfer=true and refund_application_fee=true.
  • Structures: A = 10% percentage; B = $25.00 flat; C = hybrid 10% with $2.00 floor and $30.00 cap.

One number never moves in any column below: Stripe's processing fee. Per Stripe's refunds documentation: "Stripe's processing fees from the original transaction aren't returned." It is baked into every position that follows rather than broken out as its own row.

Structure A: fee 1000¢, transfer 9000¢. Fee return: round(0.40 × 1000¢) = 400¢. Transfer reversal: round(0.40 × 9000¢) = 3600¢. Platform margin at sale was 96.80 − 90.00 = $6.80, so platform position after refund: 6.80 − 40.00 + 36.00 − 4.00 = −$1.20. Seller: 90.00 − 36.00 + 4.00 = +$58.00.

Structure B: fee 2500¢, transfer 7500¢. Fee return: round(0.40 × 2500¢) = 1000¢. Transfer reversal: round(0.40 × 7500¢) = 3000¢. Platform margin at sale: 96.80 − 75.00 = $21.80, so position: 21.80 − 40.00 + 30.00 − 10.00 = +$1.80. Seller: 75.00 − 30.00 + 10.00 = +$55.00.

Structure C: the scaled fee is 1000¢, inside the band, so every line matches structure A exactly.

Each ledger must sum to zero with the buyer at net cash of −$60.00 (they paid $100.00, got $40.00 back, kept the goods). Check A: −1.20 + 58.00 + 3.20 − 60.00 = 0. Check B: 1.80 + 55.00 + 3.20 − 60.00 = 0. Both hold.

The table below shows the same refund across the three structures:

StructureFee chargedFee returnedFee keptReversal from sellerPlatform positionSeller cash
A: 10%$10.00$4.00$6.00$36.00−$1.20+$58.00
B: flat $25.00$25.00$10.00$15.00$30.00+$1.80+$55.00
C: hybrid 10%, $2–$30$10.00$4.00$6.00$36.00−$1.20+$58.00

Now read the allocation column by column. Under the percentage structure, of the $40.00 the buyer receives, the seller funds $36.00 via reversal (90%) and gets $4.00 back as fee credit (10%). Under the flat structure the seller funds $30.00 (75%) and is credited $10.00 (25%). Nothing about the refund policy changed — only the fee encoding did — yet $6.00 of burden per $40.00 refund moved from seller to platform. The trap runs the other direction too: if your finance team assumes reversals scale off the percentage-era transfer base of $90.00, they will mispredict every flat-era reversal by $6.00. Note also what does not move: proportional return confiscates the identical 60% share of each structure's fee (40% refunded), so leakage risk lives in your flags, not your formula. Re-run these rows against your own orders with the application-fee refund calculator, and see refund_application_fee for the flag mechanics behind the third and fifth columns.

Disputes ignore your structure until you act

Refunds have a proportional machine. Disputes have nothing. When a dispute is created and when it is lost, no automatic application-fee adjustment fires; Stripe debits the disputed funds per the Connect dispute rules and leaves your fee untouched. For destination and separate charges the disputed amount and the dispute fee come out of the platform balance, and recovering either from the seller is a manual transfer reversal. Any compensation you choose to give the seller is a deliberate act: a refund of the collected fee through POST /v1/application_fees/{id}/refunds, documented at Create an application fee refund, and restricted to the application that created the charge. If the moment for that has already passed, the after-the-fact playbook walks the recovery path.

Worked rows, using the same canonical sale state (platform margin +$6.80, seller +$90.00, Stripe +$3.20) and a lost $100.00 dispute. Let D be the dispute fee your account pays — the amount is published per country on Stripe's pricing pages, and it is debited alongside the disputed amount here:

  • Fee kept: platform 6.80 − 100.00 − D = −93.20 − D; seller +90.00; Stripe 3.20 + D. Sum: (−93.20 − D) + 90.00 + (3.20 + D) = 0.
  • Fee manually returned: platform adds −10.00 → −103.20 − D; seller 90.00 + 10.00 = +100.00. Sum: (−103.20 − D) + 100.00 + (3.20 + D) = 0.
Post-loss choicePlatformSellerStripeSum
Keep the fee−$93.20 − D+$90.00+$3.20 + D$0.00
Return the fee manually−$103.20 − D+$100.00+$3.20 + D$0.00

Your structure sets the size of the lever, not the mechanism. A percentage platform holds a $10.00 cushion it can hand back after a loss; a $25.00 flat platform holds $25.00. Decide before disputes arrive who the cushion is for, because Stripe will not decide for you.

Charge type changes where the debit lands, not whether the fee moves automatically. On direct charges, the disputed amount is debited from the connected account, and which side pays the dispute fee depends on your account configuration — under some settings the platform pays it, under others the connected account does. On destination and separate charges the disputed amount and the dispute fee both come from the platform balance regardless of configuration. In neither case does an application-fee adjustment fire on its own; the manual fee refund above remains the only compensation channel.

Edge cases worth engineering around

Zero-decimal currencies change what your integers mean. In zero-decimal currencies such as JPY, amounts are stated in the unit itself: amount: 11000 is ¥11,000. A 10% fee on that charge is application_fee_amount: 1100, meaning ¥1,100 — arithmetic in whole units, where rounding ties essentially never arise. Partial refunds stay whole-unit too: refund ¥4,400 of that charge with refund_application_fee=true and the returned share is round(0.40 × 1100) = 440, or ¥440 exactly. A handful of currencies (ISK, HUF, TWD, UGX) get special-case treatment on that same page, and your fee code should branch on them explicitly rather than assume everything is cents-like.

Rounding ties live in your code, not Stripe's. Take a $45.05 charge = 4505¢ at 10%: 4505 × 0.10 = 450.5¢. Half-up rounding gives 451¢ ($4.51); banker's rounding rounds half to even and gives 450¢ ($4.50). Either is defensible; drifting between them is not, because your historical fees become unreproducible. Pick one convention, encode it once in a function like pctFee above, and treat the convention itself as versioned configuration. When you later reconcile, compute expectations with one stated rule — we use expected return = round((amount_refunded ÷ charge.amount) × fee) — and read one-cent deltas as noise rather than findings. That expectation math is the core of any application-fee leak scan.

Floors and minimums interact badly at small amounts. application_fee_amount is capped at the charge amount, so a $2.00 floor applied blindly to an $0.99 order breaks creation; clamp with min(computed, amount) before sending. Separately, Stripe enforces per-currency minimum charge amounts, published on its currencies page; check them before shipping a floor into a new market, because a floor that exceeds what a local payment method can even be charged is dead configuration.

Choosing between them

The table below summarizes how each structure trades across the factors that actually differ:

FactorPercentageFlatHybrid
Revenue predictability per orderScales with basket sizeFixed and highest at small basketsBounded both directions
Alignment with value deliveredExact by constructionWeakens as baskets growGood inside the band only
Behavior under partial refundsReturns track value returnedFixed share leaves regardless of costsMatches whichever branch set the fee
Dollars exposed per lost disputeSmaller absolute cushionLargest absolute cushionBounded
Operational complexityLowestLowestHighest: two extra constants to govern

Three shapes follow. Marketplaces taking a cut of gross merchandise value fit percentages: take rate tracks basket, refund math self-aligns, and no per-order tuning is needed. Services platforms with a fixed fulfillment cost per booking fit flat-ish fees, with one obligation: model the proportional giveback at your real partial-refund rate, because the fee leaves linearly while your costs do not. Mixed-basket or ticketed businesses fit hybrids, provided the floor and cap are treated as priced decisions with owners — controllers and CFOs should sign off on those two constants the way they sign off on the rate itself, since section three showed they decide who funds every refund.

Changing structures without corrupting history

Structure migrations fail quietly. If month six switches from flat to percentage and your reconciliation logic recomputes June's expected fees with May's formula, every delta it reports is fiction. The fix is cheap at write time: stamp the active structure into the PaymentIntent's metadata so the era travels with the object forever.

await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  application_fee_amount: pctFee(10000, 1000),
  transfer_data: { destination: "acct_SELLER_ACCOUNT_ID" },
  metadata: { fee_structure: "pct_10_v1" },
});

Audits then apply era-correct expectations:

const intent = await stripe.paymentIntents.retrieve("pi_123");
const expectedFee =
  intent.metadata.fee_structure === "pct_10_v1"
    ? pctFee(intent.amount, 1000)
    : null;

Treat names as append-only versions (flat_2500_v1, hybrid_10_200_3000_v1) and never overwrite a definition. Future-you, an auditor, or a monitoring tool can then recompute any historical refund against the exact formula that produced it, which is the only way structure changes stay reversible in reporting even after they are irreversible in banking.

Frequently asked questions

Who computes the application fee — me or Stripe?

You do. Stripe validates that application_fee_amount is a non-negative integer in the charge currency and caps it at the charge amount, but it applies no pricing logic of yours. Every rounding tie, floor, cap, and zero-decimal conversion in your fee is code you own and should version.

What happens to my fee across several partial refunds?

Each refund returns its own proportional slice of the fee. From the worked example: a 1000¢ fee hit by a 20% refund returns round(0.20 × 1000¢) = 200¢; a second 20% refund returns another 200¢; cumulative fee returns stop at the fee total, just as cumulative refunds stop at the charge amount. After enough partials, amount_refunded on the ApplicationFee object tells you exactly what has come back.

Can I return an application fee without refunding the payment?

Yes. The Application Fees Refund API refunds some or all of a collected fee while the buyer's refund status stays whatever it already was — this is the mechanism for compensating a seller after a dispute loss. It can be called partially, multiple times, until the fee is exhausted, and only by the platform application that created the charge.

Which structure loses the least under refunds?

None loses by design: proportional return takes the same share of every structure's fee. What differs is absolute exposure — how many fee dollars exist to be given back per refund, and how well the survivors cover fixed costs. A flat platform that expects meaningful partial-refund volume should hold back a reserve against the giveback, sized at the flat fee times the expected refunded share, because section two showed the revenue leaves even when costs do not. Choose on revenue shape and cost structure, then audit flags; the formulas rarely leak, but the defaults do.

Run the 90-day audit

FeeGuard exists because this arithmetic runs silently on every refund your platform issues. The free audit 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.