All docs

charge.refunded — what platforms miss

charge.refunded fires on every refund, but it never reverses the transfer to your connected account. Here is what happens to the money, and how to check.

What the event means

charge.refunded fires when a charge is refunded in full or in part. The event payload contains the charge with an updated amount_refunded, and refunded: true once the full amount has been returned.

For a Connect platform using destination charges, this event is where money silently goes missing. The refund itself always succeeds. What does not happen automatically is the reversal of the transfer that already sent funds to the connected account.

The failure mode

When you create a refund without reverse_transfer: true, Stripe refunds the buyer from the platform balance. The connected account keeps the full original transfer.

Nothing errors. The API returns 200. Your logs are clean. The only signal is that your platform balance is lower than your ledger says it should be — which you discover at month-end close, if at all.

// Leaks money — the connected account keeps the transfer
await stripe.refunds.create({ charge: 'ch_123' });

// Correct — pulls the proportional transfer back
await stripe.refunds.create({
  charge: 'ch_123',
  reverse_transfer: true,
  refund_application_fee: true,
});

How to check it yourself

For each refunded charge that has a transfer, retrieve the transfer and sum its reversals. The expected reversal is proportional to the refund:

expected = (amount_refunded / amount) × transfer.amount

If the sum of actual reversals is below that figure, the difference is sitting in the connected account.

const charge = await stripe.charges.retrieve('ch_123', {
  expand: ['transfer'],
});
const transfer = await stripe.transfers.retrieve(charge.transfer as string);
const reversals = await stripe.transfers.listReversals(transfer.id, { limit: 100 });

const totalReversed = reversals.data.reduce((sum, r) => sum + r.amount, 0);
const expected = Math.round(
  (charge.amount_refunded / charge.amount) * transfer.amount,
);

console.log('shortfall:', expected - totalReversed);

How FeeGuard handles it

FeeGuard runs exactly this calculation on every charge.refunded event, then cross-checks it against the application-fee position so a charge already made whole by a fee refund is not flagged twice.

Detection is delayed five seconds and serialised behind a per-charge lock, because charge.refunded and transfer.reversed routinely arrive within a second of each other in either order. Without that, a reversal still in flight looks identical to a reversal that never happened.

The partial-refund blind spot

Teams that know about reverse_transfer very often set it only on full refunds. Partial refunds are where the leak survives a code review, because the obvious test case — refund an order completely, watch the money come back — passes.

Stripe reverses the same proportion of the transfer as the proportion of the charge refunded. A 30% refund on a $100 charge with a $90 transfer creates a $27 reversal. Omit the flag on partials and you leak 30% of every partially refunded order, indefinitely.

For platforms with a high partial-refund rate — shipping adjustments, partial cancellations, goodwill credits — this is frequently larger in aggregate than the full-refund leak, because partial refunds are more common and each one is individually too small to notice.

Rounding, and why your check needs a tolerance

A charge refunded across several partial refunds accumulates independent roundings. Stripe rounds each proportional reversal on its own; a reconciliation check computed once over the refunded total does not.

The two can legitimately differ by a cent or two. A check without tolerance will report those as findings forever, and a tool that reports $0.02 discrepancies is a tool people mute.

Two minor units is a sensible tolerance. It absorbs rounding noise without hiding anything a human would care about.

The ordering race nobody expects

charge.refunded and transfer.reversed for the same charge arrive within seconds of each other, in either order. Stripe makes no ordering guarantee between them.

A reconciliation check that runs on the refund event before the reversal event lands will read amount_reversed: 0 and compute a shortfall for money that is already on its way back. Every one of those is a false positive, and false positives are how monitoring gets switched off.

Two mitigations, both needed. Delay processing of refund events by a few seconds so an in-flight reversal has time to settle. And serialise all processing for a given charge behind a lock, so two events for the same charge cannot both read pre-reversal state concurrently.

Why the webhook payload is not enough

It is tempting to reconcile straight from event.data.object — the charge is right there, with amount and amount_refunded already populated. Doing so introduces a subtle and persistent class of false positive.

The payload is a point-in-time snapshot captured when Stripe queued the event, not when your worker processes it. Between those two moments — which may be seconds under normal load, or minutes during a retry backoff — a reversal can be created, a second partial refund can land, or the application fee can be refunded separately.

Reconciling against the snapshot means reconciling against state that may no longer exist. The charge you are evaluating has moved on, and every conclusion you draw about it is stale.

Re-fetching costs one API call and removes the entire problem. Use the payload for routing — which charge, which event type, which account — and read live state for anything you intend to act on.

// Fragile: reconciles against a snapshot that may be stale
const charge = event.data.object as Stripe.Charge;
const shortfall = compute(charge);

// Correct: payload routes, live state decides
const charge = await stripe.charges.retrieve(
  (event.data.object as Stripe.Charge).id,
  { expand: ['transfer', 'application_fee', 'refunds'] },
);
const shortfall = compute(charge);

Retries, replays, and counting the same dollar twice

Stripe guarantees at-least-once delivery and will redeliver on any non-2xx response, on timeout, and on a manual replay from the dashboard. Any job queue in front of your handler adds its own retries on top.

Without deduplication, one refund can be evaluated three times and produce three findings for the same dollar — which inflates your recoverable total, triggers three alerts, and, if anything automated is downstream, attempts three reversals.

Two guards are needed and they solve different problems. Deduplicate on the Stripe event id to stop the same delivery being processed twice. Deduplicate findings on (charge, issue type) so that genuinely distinct events about the same charge converge on one record rather than accumulating.

A checklist for your own handler

If you are auditing your refund code rather than adopting a tool, these are the five questions worth answering:

1. Is reverse_transfer set on partial refunds, not just full ones?

2. Is refund_application_fee handled — and handled consistently with your published terms?

3. Does your reconciliation re-read live Stripe state, or trust the webhook payload snapshot? The payload is a point-in-time capture from when Stripe queued the event, and may already be stale.

4. Does it tolerate one or two minor units of rounding difference?

5. Does it handle zero-decimal currencies such as JPY and KRW, where dividing by 100 is wrong by 100×?