Documentation
application_fee.refunded — reading the event correctly
application_fee.refunded fires when a platform fee is returned. What the payload contains, what it leaves out, and why it is never quite enough on its own.
What the event means
application_fee.refunded fires when some or all of an application fee is refunded, whether through refund_application_fee on a refund or a direct applicationFees.createRefund call.
The payload is the ApplicationFee object with an updated amount_refunded. Critically, it tells you nothing about how much of the *charge* was refunded — so it cannot, by itself, tell you whether the fee refund was proportional.
What you have to fetch
To judge proportionality you need the charge. The fee object carries a charge id; retrieve it and compare.
const fee = event.data.object;
const charge = await stripe.charges.retrieve(fee.charge as string);
const shouldHaveRefunded = Math.round(
(charge.amount_refunded / charge.amount) * fee.amount,
);
console.log('fee shortfall:', shouldHaveRefunded - fee.amount_refunded);The aggregate-lag caveat
amount_refunded on the parent object can briefly lag a refund created moments earlier. Summing the refund list as a cross-check and taking the larger of the two biases toward under-reporting the shortfall — the safe direction, since a missed alert costs less than a false clawback.
Why the fee object alone cannot answer the question
The ApplicationFee object knows its own amount and amount_refunded. It does not know how much of the underlying charge was refunded, which is the only thing that makes a fee refund proportional or not.
A fee fully refunded on a charge that was only partially refunded is an over-refund. A fee left untouched on a fully refunded charge is an under-refund. Both look identical from inside the fee object — amount_refunded is just a number with no denominator.
This is why the event is a trigger rather than a source of truth. It tells you a fee moved; it cannot tell you whether the movement was correct.
Two paths produce this event
A fee refund can arise in two ways, and distinguishing them matters because only one implies anything about the charge.
Via refund_application_fee: true on a refund. The buyer got money back and the platform returned its cut proportionally. The charge and the fee moved together.
Via a direct applicationFees.createRefund call. The platform returned its cut without any charge refund — a promotional waiver, a fee correction, or a goodwill gesture to a seller. The buyer is unaffected.
The event payload is identical in both cases. Only the charge reveals which happened, which is another reason any handler must fetch it.
const fee = event.data.object as Stripe.ApplicationFee;
const charge = await stripe.charges.retrieve(fee.charge as string);
// No charge refund → this was a standalone fee refund
const isStandalone = charge.amount_refunded === 0;The aggregate-lag caveat
amount_refunded on the parent fee object is an aggregate Stripe maintains. It can briefly lag a refund created moments earlier, particularly when several refunds are issued in quick succession.
Summing the refund list as a cross-check and taking the larger of the two values biases toward under-reporting a shortfall. That is the safe direction: a missed alert costs less than a fee refund issued against money that already came back.
The same pattern applies to transfer.amount_reversed. Any reconciliation that treats a single aggregate field as authoritative will occasionally act on stale state.
const refunds = await stripe.applicationFees
.listRefunds(fee.id, { limit: 100 })
.autoPagingToArray({ limit: 1000 });
const summed = refunds.reduce((total, r) => total + r.amount, 0);
const refundedTotal = Math.max(fee.amount_refunded ?? 0, summed);Pagination is not optional
A long-lived subscription charge, or an order refunded in many small increments, can accumulate more fee refunds than a single hundred-item page holds.
A truncated sum understates what has already been returned, which produces a false positive on exactly the charges with the most activity — the ones a finance team is most likely to look at closely and least likely to forgive a wrong number on.
Auto-pagination with a sane upper bound costs one extra call in the rare case and removes the failure mode entirely.
What FeeGuard does with it
FeeGuard treats application_fee.refunded as one of two triggers for the same question — is this charge's fee proportionally refunded? — and always resolves it by reading the charge.
Standalone fee refunds with no corresponding charge refund produce no finding, because there is no shortfall to report. The platform chose to return its cut, and that is not a reconciliation error.
Refunds are capped at what was collected
Stripe rejects a fee refund that would take amount_refunded above amount. That guard is useful but it fires late — at the API boundary, after your logic has already decided to attempt it.
Any code computing a refund amount should cap it locally at the outstanding balance rather than relying on Stripe to reject the excess. A rejected call is an error to handle, a retry to consider, and a log line someone has to interpret. A capped call is none of those.
Fee refunds move money to the seller, not the buyer
This trips people up the first time they see it. Refunding an application fee does not return anything to the customer — it returns the platform's cut to the connected account.
On a destination charge the fee was deducted from what the seller received. Refunding it tops them back up. The buyer is entirely uninvolved and their card statement is unchanged.
So "refund the application fee" reads like a customer-service action and is actually a settlement adjustment between platform and seller. Support teams given the ability to do it without that context will occasionally use it trying to help a customer, and quietly transfer platform revenue to a merchant instead.
If your internal tooling exposes this action, label it for what it does: *return our commission to the seller*.
What to store when you see this event
The event is cheap to receive and expensive to reconstruct later, so record enough to answer questions after the fact.
Worth persisting: the fee id, the charge id, the amount refunded in this specific event, the running total afterwards, and whether a corresponding charge refund exists. That last field is what distinguishes a proportional return from a standalone waiver, and it cannot be recovered from Stripe months later without re-fetching everything.
Storing the raw event payload alongside the derived fields costs almost nothing and repeatedly proves its worth when someone asks why a finding was or was not raised on a particular charge.