All posts

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.

FeeGuard14 min read

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_amount of $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

PropertyValue
TypeBoolean, optional
Default when omittedFalse in effect — no application fee moves
Applies toDestination charges and direct charges
Meaningless forSeparate 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

PropertyValue
TypeBoolean, optional
Default when omittedFalse in effect — no transfer moves
Applies toDestination charges
Inert forDirect 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 setDestinationDirectSeparate
Neither flag (defaults)Seller keeps transfer, platform funds refund: platform −$93.20, seller +$90.00Seller loses fee as well: seller −$13.20, platform +$10.00Transfer untouched: platform −$73.20 until manual reversal
reverse_transfer=trueFunds pulled back: platform −$3.20, seller $0.00Nothing to act on: identical to defaults rowIgnored — reverse manually via the Transfers API
refund_application_fee=trueNot valid alone: the fee refund requires the reversal; forced separately it lands at platform −$103.20, seller +$100.00Fee returned: platform $0.00, seller −$3.20n/a — no fee object exists
Both truePlatform −$13.20, seller +$10.00 — seller over-paid by exactly the feeSame as fee-only row: platform $0.00, seller −$3.20n/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:

MovementPlatformSellerStripeBuyer
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:

MovementPlatformSellerStripeBuyer
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:

MovementPlatformSellerStripeBuyer
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:

MovementPlatformSellerStripeBuyer
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.

SituationLoss belongs toDestination flagsDirect flags
Seller fault: damaged goods, misshipmentSellerreverse_transfer=true, refund_application_fee=false → platform −$3.20, seller $0.00refund_application_fee=true → seller −$3.20
Platform fault: listing or pricing errorPlatformNeither flag, set knowingly → platform −$93.20, seller kept wholeOmit the fee flag, compensate the seller out-of-band if warranted
Fraud or duplicate submissionsPer policyFollow the fault rules; reason=fraudulent additionally feeds Radar block listsSame, with reason set
Deliberate goodwill top-upPlatform, visiblyBoth flags true → platform −$13.20, seller +$10.00Fee 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.

Run the free 90-day audit.

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