The forgotten flags: refund_application_fee and reverse_transfer, audited
Two booleans with non-obvious defaults decide every Connect refund's side effects; the complete truth table with dollar outcomes inside.
The forgotten flags: refund_application_fee and reverse_transfer, audited
This is a complete parameter reference for the two boolean flags on every Connect refund — documented behavior, defaults under each charge pattern, their interactions, and the exact dollar outcome of every flag-and-pattern combination, computed on one canonical charge so you can compare your own refunds line by line.
Two booleans decide everything
Every refund on a Stripe Connect platform is a short API call, but its side effects are governed entirely by two optional booleans: refund_application_fee and reverse_transfer. Cross those two flags with Connect's three charge patterns — direct charges, destination charges, separate charges and transfers — and you have the full space of what a refund can do to your balance and your sellers' balances.
The reason these flags deserve an audit rather than a skim: they default to false when omitted, and defaults inherit silently. A refund call written in 2022 by an engineer who has since left, copied into three new endpoints, carries whatever it carried then. Nobody chose that the seller keeps $90.00 of transferred funds while the platform eats a $100.00 refund; the absence of two words chose it.
One canonical example runs through this entire article. Assumptions:
- US platform, USD, standard US card pricing of 2.9% + $0.30 per charge, per Stripe's published pricing.
- Charge of $100.00 (10,000 cents), so the Stripe processing fee is 2.9% × $100.00 + $0.30 = $3.20 (320 cents).
application_fee_amountof $10.00 (1,000 cents) on destination and direct charges.- Destination pattern: the automatic transfer delivers $100.00 − $10.00 = $90.00 to the connected account.
- Separate pattern variant: the platform charges $100.00 and manually transfers $70.00 to the seller.
- Positions below are net cash across sale plus refund; goods kept are excluded from the cash view.
Parameter cards
Two cards, quoting the Refunds API documentation directly.
Card 1: refund_application_fee
| Property | Value |
|---|---|
| Type | Boolean, optional |
| Default when omitted | False in effect — no application fee moves |
| Applies to | Destination charges and direct charges |
| Meaningless for | Separate charges (no ApplicationFee objects exist in that pattern) |
Documented behavior: on a full refund the full fee is refunded; on a partial refund, a proportional amount is. The restriction is stated verbatim: "An application fee can be refunded only by the application that created the charge." On destination charges the destination-charges guide adds the default plainly: "by default the platform account keeps the funds from the application fee," and setting the flag to true "push[es] the application fee funds back to the connected account." On direct charges the direct-charges guide warns that "Application fees aren't automatically refunded when issuing a refund" and that without explicit action "the connected account...loses that amount."
One direction only: a refunded application fee compensates the connected account. It never pays the buyer.
Card 2: reverse_transfer
| Property | Value |
|---|---|
| Type | Boolean, optional |
| Default when omitted | False in effect — no transfer moves |
| Applies to | Destination charges |
| Inert for | Direct charges (no transfer exists at refund time); ignored for separate charges |
Documented behavior, verbatim from the API reference: "The transfer will be reversed proportionally to the amount being refunded," subject to the same creator-only restriction. On destination charges the guide states the default outcome: "by default the destination account keeps the funds that were transferred to it, leaving the platform account to cover the negative balance from the refund." Full refund reverses the entire transfer; partial refund reverses a proportional amount.
A deliberate call looks like this in code, with an idempotency key so a retry cannot double-issue it:
const refund = await stripe.refunds.create(
{
charge: 'ch_3PqXYZexample001',
reverse_transfer: true,
refund_application_fee: false,
},
{ idempotencyKey: 'refund-order-4451' },
);
Both explainers — reverse_transfer and refund_application_fee — carry deeper examples if you want them per event type.
The full truth table
The table below is the entire decision space: four flag combinations against the three patterns, each cell stating the outcome and the final platform position under the canonical numbers ($100.00 charge / $10.00 fee / $90.00 transfer / $3.20 processing fee).
| Flag set | Destination | Direct | Separate |
|---|---|---|---|
| Neither flag (defaults) | Seller keeps transfer, platform funds refund: platform −$93.20, seller +$90.00 | Seller loses fee as well: seller −$13.20, platform +$10.00 | Transfer untouched: platform −$73.20 until manual reversal |
reverse_transfer=true | Funds pulled back: platform −$3.20, seller $0.00 | Nothing to act on: identical to defaults row | Ignored — reverse manually via the Transfers API |
refund_application_fee=true | Not valid alone: the fee refund requires the reversal; forced separately it lands at platform −$103.20, seller +$100.00 | Fee returned: platform $0.00, seller −$3.20 | n/a — no fee object exists |
| Both true | Platform −$13.20, seller +$10.00 — seller over-paid by exactly the fee | Same as fee-only row: platform $0.00, seller −$3.20 | n/a |
Footnote on the both-flags-on-destination cell, because it is the least intuitive result in the table: reversing the transfer returns $90.00 to the platform, and refunding the application fee pushes $10.00 to the connected account. The seller ends the sequence at +$90.00 − $90.00 + $10.00 = +$10.00 while the platform sits at +$6.80 − $100.00 + $90.00 − $10.00 = −$13.20. The buyer is whole and Stripe keeps $3.20; the columns sum to zero. Setting both flags on a destination charge is not a neutral undo — it is a $10.00 payment to the seller on top of a clean unwind. Choose it only when you mean it.
Interaction rules
Four rules govern how the flags behave together and per pattern.
Destination: the fee refund requires the transfer reversal. The destination-charges guide is unconditional: "If you refund the application fee for a destination charge, you must also reverse the transfer." That is why the truth table has no supported fee-only end state in this column. If you want the reversal without returning the fee, set reverse_transfer=true alone. If you want the fee returned later, reverse first and refund the fee separately through the Application Fees Refund API — the after-the-fact procedure is its own walkthrough.
Direct: reverse_transfer has nothing to act on. In this pattern money reached the platform through the application fee at sale time, not through a transfer, and the refund debits the connected account's balance directly. The flag does not error usefully or do anything; the fee side is the only live lever, which is why the direct column of the truth table depends entirely on refund_application_fee.
Separate: the flags do not apply. There are no ApplicationFee objects to refund and no automatic link between the charge and your transfers. The separate-charges-and-transfers guide states that refunding the charge "has no impact on any associated transfers"; recovery means manual reversals via POST /v1/transfers/{id}/reversals, optionally with an amount, and a reversal succeeds only if the connected account's available balance covers it. Until it succeeds, the platform position stays at −$73.20 in the canonical example.
Creator-only: both parameters operate under the rule that "an application fee can be refunded only by the application that created the charge." Your platform must hold the right relationship to the charge — created by your platform's application — for either flag to have effect.
The cost of each combination
Four ledger tables make the destination column concrete. Each movement row sums to zero across parties, which is the double-entry property you can use to check your own exports. Sale positions first, in all tables: charge nets +$96.80 to the platform, transfer sends −$90.00, opening margin +$6.80.
Combination A — defaults, neither flag:
| Movement | Platform | Seller | Stripe | Buyer |
|---|---|---|---|---|
| Charge settles | +$96.80 | $0.00 | +$3.20 | −$100.00 |
| Transfer to seller | −$90.00 | +$90.00 | $0.00 | $0.00 |
| Refund debit | −$100.00 | $0.00 | $0.00 | +$100.00 |
| Final | −$93.20 | +$90.00 | +$3.20 | $0.00 |
Combination B — reverse_transfer=true only:
| Movement | Platform | Seller | Stripe | Buyer |
|---|---|---|---|---|
| Charge settles | +$96.80 | $0.00 | +$3.20 | −$100.00 |
| Transfer to seller | −$90.00 | +$90.00 | $0.00 | $0.00 |
| Refund debit | −$100.00 | $0.00 | $0.00 | +$100.00 |
| Transfer reversal | +$90.00 | −$90.00 | $0.00 | $0.00 |
| Final | −$3.20 | $0.00 | +$3.20 | $0.00 |
Combination C — both flags true:
| Movement | Platform | Seller | Stripe | Buyer |
|---|---|---|---|---|
| Charge settles | +$96.80 | $0.00 | +$3.20 | −$100.00 |
| Transfer to seller | −$90.00 | +$90.00 | $0.00 | $0.00 |
| Refund debit | −$100.00 | $0.00 | $0.00 | +$100.00 |
| Transfer reversal | +$90.00 | −$90.00 | $0.00 | $0.00 |
| Application fee refund | −$10.00 | +$10.00 | $0.00 | $0.00 |
| Final | −$13.20 | +$10.00 | +$3.20 | $0.00 |
Combination D — fee refunded separately, reversal forgotten:
| Movement | Platform | Seller | Stripe | Buyer |
|---|---|---|---|---|
| Charge settles | +$96.80 | $0.00 | +$3.20 | −$100.00 |
| Transfer to seller | −$90.00 | +$90.00 | $0.00 | $0.00 |
| Refund debit | −$100.00 | $0.00 | $0.00 | +$100.00 |
| Application fee refund | −$10.00 | +$10.00 | $0.00 | $0.00 |
| Final | −$103.20 | +$100.00 | +$3.20 | $0.00 |
Read the finals side by side and the asymmetry is the whole story: −$3.20 versus −$93.20 differs by exactly the unreversed $90.00 transfer; −$13.20 versus −$3.20 differs by exactly the $10.00 fee handed back to the seller; −$103.20 is what happens when someone tries to be generous and forgets the mandatory half of the gesture. To price out any specific partial refund against these patterns before issuing it, the application-fee refund calculator follows the same conventions.
Auditing your own defaults
You can establish what your platform actually does in an afternoon.
Step 1 — enumerate refund call sites. Search every repository that touches Stripe:
rg -n "refunds\.create" src/ apps/ services/
Classify each hit as policy-explicit (both flags present, matching a written refund policy) or default-inheriting (flags absent, behavior inherited from Stripe's defaults). Most platforms find at least one of the second kind.
Step 2 — sample recent refunds and compute the gaps. Two quantities tell the whole story:
expected_reversal = round((amount_refunded / charge.amount) x transfer.amount)
missing_reversal = expected_reversal - SUM(existing reversal amounts)
expected_fee_refund = round((amount_refunded / charge.amount) x fee.amount)
fee_gap = expected_fee_refund - fee.amount_refunded
Worked micro-example, partial $40.00 refund (4,000 cents) on the canonical destination charge with the reversal flag set: expected_reversal = round((4,000 ÷ 10,000) × 9,000) = 3,600 cents = $36.00. If existing reversals sum to $36.00, missing_reversal = $0.00. If the fee flag was also set, expected_fee_refund = round(0.40 × 1,000) = 400 cents = $4.00, and fee_gap compares that against the cumulative amount_refunded on the ApplicationFee object.
Step 3 — run it. A short Node script produces the numbers per charge; note that amount_refunded fields are cumulative across multiple refunds, so the script aggregates refunds per charge before computing shares:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const refunds = await stripe.refunds.list({ limit: 100 });
const refundedByCharge = new Map<string, number>();
for (const refund of refunds.data) {
const chargeId =
typeof refund.charge === 'string' ? refund.charge : refund.charge.id;
refundedByCharge.set(
chargeId,
(refundedByCharge.get(chargeId) ?? 0) + refund.amount,
);
}
for (const [chargeId, refundedTotal] of refundedByCharge) {
const charge = await stripe.charges.retrieve(chargeId);
const share = refundedTotal / charge.amount;
if (charge.application_fee) {
const fee = await stripe.applicationFees.retrieve(
charge.application_fee as string,
);
const expectedFeeRefund = Math.round(share * fee.amount);
console.log(`${chargeId} fee_gap_cents=${expectedFeeRefund - fee.amount_refunded}`);
}
if (charge.transfer) {
const transfer = await stripe.transfers.retrieve(charge.transfer as string);
const reversals = await stripe.transfers.listReversals(transfer.id, {
limit: 100,
});
const reversed = reversals.data.reduce((sum, r) => sum + r.amount, 0);
const expectedReversal = Math.round(share * transfer.amount);
console.log(
`${chargeId} missing_reversal_cents=${expectedReversal - reversed}`,
);
}
}
Caveats worth keeping in mind. Separate charges and transfers produce no transfer field on the charge and no application fee, so the script skips them — join those through your own records or metadata instead. Sigma queries answer what happened; detection needs expected-versus-actual joins like the one above, which is why the output lands in a spreadsheet where each nonzero missing_reversal_cents or positive fee_gap_cents gets a dollar sign attached. When a reversal completes after the fact, the transfer.reversed event is the confirmation hook to watch.
Choosing deliberately
Defaults become decisions the moment you see them; the matrix below maps common fault classes to flag values, using the canonical outcomes.
| Situation | Loss belongs to | Destination flags | Direct flags |
|---|---|---|---|
| Seller fault: damaged goods, misshipment | Seller | reverse_transfer=true, refund_application_fee=false → platform −$3.20, seller $0.00 | refund_application_fee=true → seller −$3.20 |
| Platform fault: listing or pricing error | Platform | Neither flag, set knowingly → platform −$93.20, seller kept whole | Omit the fee flag, compensate the seller out-of-band if warranted |
| Fraud or duplicate submissions | Per policy | Follow the fault rules; reason=fraudulent additionally feeds Radar block lists | Same, with reason set |
| Deliberate goodwill top-up | Platform, visibly | Both flags true → platform −$13.20, seller +$10.00 | Fee flag true plus a separate seller credit |
Two disciplines keep the matrix honest. First, name the intent in code: a comment or named constant at each call site stating which row of this table it implements turns every future audit into a diff. Second, write the allocation policy down outside the code — who bears the processing fee, who bears the transfer, who gets the fee back — so the flags implement policy rather than define it; the platform refund policy design playbook walks that drafting exercise. A refund issued with inherited defaults is not cheaper to process. It is just unpriced.
Frequently asked questions
What actually happens if I pass both flags on a destination charge?
Stripe reverses the transfer proportionally and refunds the application fee, pushing the fee funds back to the connected account. On the canonical full refund that leaves the platform at −$13.20 and the seller at +$10.00. It is a valid combination, but on destination charges it over-compensates the seller by the fee amount, so reserve it for cases where returning the fee to the seller is intended policy.
Do the flags do anything on separate charges and transfers?
No. There are no ApplicationFee objects to refund in that pattern, and refunding the charge has no impact on associated transfers. Recovery is entirely manual: reversals through POST /v1/transfers/{id}/reversals, which succeed only when the connected account's available balance can cover them. Treat the flags as absent and reconcile transfers yourself.
Is there an event that confirms the reversal happened?
Yes. transfer.reversed fires when a transfer reversal completes, and refund.updated reports state changes on the refund itself, including whether it settled as a network reversal. For monitoring at scale, compute expected-versus-actual reversal sums per transfer rather than relying on events alone, since at-least-once webhook delivery means handlers must tolerate repeats anyway.
Can I refund an application fee after the original refund already completed?
Yes. The Application Fees Refund API lets you refund a collected fee separately, subject to the creator-only restriction, and on destination charges the fee refund still belongs alongside a transfer reversal. Proportionality applies there too: refunding a fee after a partial charge refund means computing the proportional slice yourself and checking the fee object's cumulative amount_refunded first.
Run the 90-day audit
Every combination in these tables is already sitting in your refund history, decided by whichever defaults your code inherited. 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.
FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.