Documentation
transfer.reversed — confirming a recovery
transfer.reversed confirms funds came back from a connected account. Use it to close reconciliation findings automatically, and avoid the ordering trap.
What the event means
transfer.reversed fires when a reversal is created on a transfer, whether by reverse_transfer on a refund or by a direct transfers.createReversal call.
Why it matters for reconciliation
This is the event that *closes* a finding. A platform that notices an unreversed transfer and fixes it manually in the Stripe dashboard should not still see the discrepancy sitting open in a monitoring tool — that is how people stop trusting the tool.
FeeGuard runs no detector on this event. Instead it resolves any open finding referencing the same transfer, with a note recording that the reversal was observed in Stripe rather than performed by FeeGuard.
The ordering hazard
transfer.reversed and charge.refunded for the same charge arrive within seconds of each other, in either order. A reconciliation check that runs on the refund event before the reversal event lands will compute a shortfall for money already on its way back.
Two mitigations, both load-bearing: delay refund-event processing by a few seconds, and serialise all processing for a given charge behind a distributed lock.
Reversals you did not create
Not every transfer.reversed event originates from your own code. A platform operator can create a reversal directly in the Stripe dashboard, and frequently does — someone spots a problem, fixes it by hand, and never tells the system that owns reconciliation.
A monitoring tool that only closes findings it resolved itself will show that discrepancy sitting open indefinitely. The user knows they fixed it. The dashboard says otherwise. Trust erodes quickly from there, and it erodes precisely among the people who were engaged enough to fix things manually.
Treating this event as authoritative regardless of origin is what keeps the two in agreement. If Stripe says the money came back, the finding is closed, whoever initiated it.
Partial reversals and repeated events
A transfer can be reversed in several increments, each firing its own event. A finding should not close on the first one unless that reversal covered the full outstanding amount.
The correct check is against the aggregate rather than the individual reversal: is transfer.amount_reversed now at or above what the finding said was owed? If not, the finding stays open with a reduced amount.
Closing on the first partial reversal is a quiet and expensive bug — it marks a $900 shortfall resolved because $100 came back, and nobody looks at it again.
const transfer = await stripe.transfers.retrieve(transferId);
const outstanding = transfer.amount - (transfer.amount_reversed ?? 0);
if (outstanding <= TOLERANCE) {
closeFinding(findingId, 'Reversal observed in Stripe');
} else {
updateFinding(findingId, { lossAmount: outstanding });
}The ordering hazard in detail
Stripe makes no ordering guarantee between charge.refunded and transfer.reversed for the same charge. Both are queued independently and delivered independently.
When a refund is created with reverse_transfer: true, both events are generated within the same operation and arrive within seconds of each other — in either order, and sometimes with the reversal arriving first.
A handler that processes the refund event first, reads amount_reversed: 0, and raises a finding will then receive the reversal event and close the finding it just opened. Best case that is churn in the audit log. Worst case an alert already fired, or an automated clawback already ran against money that was on its way back.
Two mitigations, both load-bearing. Delay processing of refund-family events by a few seconds so an in-flight reversal settles first. And serialise all processing for a given charge behind a distributed lock so two events cannot both read pre-reversal state concurrently. Neither alone is sufficient — the delay handles the common case, the lock handles the concurrent one.
Reversals cannot be undone
There is no API to reverse a reversal. Correcting one means creating a fresh transfer back to the connected account, which is a manual operation and a conversation with a seller who was debited in error.
This asymmetry is why every write in a recovery path should carry an idempotency key derived from something stable, and why any handler acting on this event should verify state rather than assume it.
Using it to measure whether recovery works
This event is the only unambiguous evidence that money came back. It is therefore the right basis for the two metrics that tell you whether your recovery process functions at all.
Attempt rate — of findings that were recoverable, how many had a reversal created? A low number means findings are not reaching anyone who acts on them, which is an alerting or ownership problem rather than a detection one.
Collection rate — of reversals created, how many actually collected rather than pushing an account negative? A low number here means you are acting too late, and the fix is timing rather than process.
Platforms frequently discover their detection is excellent and their attempt rate is near zero, because findings land in a dashboard nobody has been asked to own. That is worth knowing before investing in better detection.
Reversal metadata is your audit trail
Stripe lets you attach metadata to a reversal. Using it costs nothing and repeatedly pays off.
Recording which finding a reversal belongs to, and what triggered it, means that months later — when a seller queries a debit, or an auditor asks why funds moved — the answer is in Stripe itself rather than only in your database.
It also makes reconciliation self-healing. A reversal created by your system can be matched back to its finding even if the local record was lost, which matters during incident recovery.
await stripe.transfers.createReversal(transferId, {
amount: outstanding,
description: `Recovery for finding ${findingId}`,
metadata: {
finding_id: findingId,
trigger: 'charge.refunded',
charge_id: chargeId,
},
});