Reverse transfers, explained down to the ledger entry
A reversal debits a connected account and credits the platform. Stripe triggers it on flagged destination refunds; separate transfers need a manual call.
Reverse transfers, explained down to the ledger entry
When a refund pulls money back out of a connected account, when it never does, and which flag or API call decides the outcome: worked ledgers, the available-balance gate, and verification queries, written for platform engineers and finance ops running Stripe Connect.
What a transfer reversal actually is
A transfer moves money from your platform balance to a connected account. A transfer reversal is the child object that moves some or all of it back. The object type is transfer_reversal, it is always created under a parent Transfer, and its fields tell you everything about provenance and effect: amount in the smallest currency unit, a balance_transaction describing the platform-side credit, a transfer pointer to the parent, plus source_refund (the refund that caused it, when one did) and destination_payment_refund (object reference).
Stripe describes the money direction precisely: "Transfer reversals add the specified (or entire) amount back to the platform's available balance, reducing the connected account's available balance accordingly" (separate charges and transfers). Two balances move at once — yours up, theirs down.
Keep the purpose straight, because reversals are not refunds. A Refund object returns money to the buyer through the original payment method. A TransferReversal only re-splits funding between you and the seller; the buyer sees nothing. Reversals also settle directly against balances — there is no card network in the loop, so no network fees and no acquirer reference numbers. Stripe frames legitimate uses narrowly: refunds or disputes related to the original charge, or correcting an erroneous transfer. Reversals can therefore exist with no refund anywhere in the picture — a dispute clawback or a fat-fingered transfer correction both produce one.
Where they appear depends on the pattern. On destination charges the refund flag or the async-failure cleanup creates them for you, and source_refund names the responsible Refund. On separate charges and transfers you create every reversal yourself, so provenance lives only in whatever metadata you attach. Transfers expose amount_reversed next to amount, which makes outstanding debt readable at a glance: what is still owed equals amount minus amount_reversed.
When Stripe reverses automatically
Most of the time, nothing reverses unless you say so. The table below lays out every refund-adjacent pattern and whether Stripe acts without being asked.
| Pattern | Automatic reversal? | Amount pulled back |
|---|---|---|
Destination charge, full refund, reverse_transfer=true | Yes — Stripe creates the reversal inside the refund call | Entire transfer |
Destination charge, partial refund, reverse_transfer=true | Yes | Proportional slice |
| Destination charge, refund without the flag | No | None; the seller keeps transferred funds by design |
| Async payment method fails after a destination charge settles | Yes — Stripe acts on its own | Funds tied to the failed payment |
| Separate charges and transfers | Never | Manual call required |
| Direct charges | Not applicable | No transfer exists |
The flag deserves a precise reading: reverse_transfer=true is not a setting you enable for later. It is an instruction executed during the refund request itself, and Stripe sizes the reversal against the refunded amount. Omit it and the default holds — the seller keeps the funds while your platform balance absorbs the refund (destination charge refunds).
Sizing carries a built-in ceiling worth knowing: the proportional slice is computed against the original transfer, so even a run of partial refunds can never pull back more than the transfer itself. If you transferred less than the usual charge-minus-fee default, maximum recovery scales down accordingly — the formula in the worked ledgers below makes that explicit.
One mental model keeps the table straight: the refund answers the buyer, the reversal answers your margin. Whenever those two questions get answered by different teams — support issuing refunds while finance chases transfers — the second answer tends to go missing. Put both decisions inside one approval path.
The async exception is documented from the other side. On the separate pattern, Stripe states: "Unlike destination charges, Stripe doesn't automatically reverse a transfer if the associated async payment fails." The contrast clause is the point: if an ACH or SEPA payment later bounces on a destination charge, cleanup happens without you; on separate charges and transfers, your balance eats the transfer and recovery is manual work (separate charges and transfers).
The flag versus the standalone API call
There are two ways to create the same object. At refund time, pass reverse_transfer=true and Stripe builds the reversal for you, sized automatically. After the fact, call the standalone endpoint:
POST /v1/transfers/{TRANSFER_ID}/reversals
with an optional amount. Partial reversals are allowed as many times as you wish until the entire transfer has been reversed; omitting amount reverses whatever remains; the call errors once the transfer is fully reversed or if you try to reverse more than is left (create a reversal). The endpoint also accepts refund_application_fee, returning the full fee on a full reversal or a proportional share otherwise.
Who may call what differs by path. On the refund path both flags carry a creator-only restriction: "A transfer can be reversed only by the application that created the charge," just as an application fee can be refunded only by the creating application (Refunds API). That wording targets connected accounts and third parties — it stops anyone but your platform from unwinding money through charges you created. The standalone endpoint sits outside that restriction because the platform owns the transfer: you made it, you unwind it.
Retries need care because reversals move real money. Wrap every POST in an Idempotency-Key header; Stripe honors keys for 24 hours, so a timeout followed by an identical retry cannot double-reverse (idempotency keys). Generate the key from your job ID rather than a timestamp — a scheme like <system>-<ticket>-<attempt>, written once per job row, makes retries reuse the same key while genuinely new decisions get new keys (idempotent reversal jobs).
A minimal reversal, partial by amount:
curl https://api.stripe.com/v1/transfers/tr_Example123/reversals \
-u "$STRIPE_SECRET_KEY:" \
-H "Idempotency-Key: reversal-rec-1042-attempt-1" \
-d amount=3600 \
-d "metadata[ticket]=REC-1042"
Worked ledgers
Inputs, stated before any math:
- US platform, USD throughout.
- Stripe's standard US card pricing assumed: 2.9% + $0.30, per Stripe's published pricing, giving a $3.20 processing fee on $100.00 ($2.90 + $0.30 = $3.20).
- Canonical destination sale: charge $100.00 (10000 cents),
application_fee_amount$10.00 (1000 cents), transfer to seller $90.00 (9000 cents). - Site rounding convention used in every calculation below:
Expected reversal = round((amount_refunded / charge.amount) x transfer.amount)
Missing reversal = expected - sum(existing reversals)
(a) Full refund, no reversal. Both flags false:
sale margin $6.80 (charge net +$96.80, transfer -$90.00)
full refund -$100.00
platform position -$93.20
seller +$90.00 (unchanged)
Conservation: -$93.20 + $90.00 + $3.20 = $0. The default leaves you funding the buyer's entire refund while the seller's payout stands untouched. This is the unreversed-transfer leak in its purest form, and the seller-side view is your view mirrored: nothing moved, so payout schedules will happily send the $90.00 onward while your receivable ages.
(b) Partial refund of $40.00 with reverse_transfer=true. Compute the proportional slice stepwise:
fraction = 4000 / 10000 = 0.40
expected reversal = round(0.40 x 9000c) = 3600c = $36.00
Ledger after the refund and the automatic reversal:
platform $6.80 - $40.00 + $36.00 = $2.80
seller $90.00 - $36.00 = $54.00
buyer recovered $40.00 of $100.00
Stripe still holds $3.20
Conservation across the deltas: platform -$4.00, seller -$36.00, buyer +$40.00, Stripe unchanged — they sum to zero. Rounding happens per refund, so many tiny partials can drift a cent or two from a single large refund covering the same total; reconcile against the expected value computed per refund rather than one blended number. Rounding survives non-round inputs too: a $33.33 refund (3333 cents) yields round((3333 / 10000) x 9000) = round(2999.7) = 3000 cents = $30.00.
(c) Same partial refund, attempted after the seller's payout. The payout has drained the connected account's available balance below $36.00. Now watch the asymmetry between paths. The refund-with-flag path refuses up front: Stripe returns an error instead of creating a pending refund, so no buyer payback starts and nothing hangs in limbo. The standalone reversal path errors identically at the same gate. Contrast this with a direct-charge refund short on funds, which would enter status pending and complete later once the account refills. Destination-plus-reversal gives you a hard no; plan for it.
When the gate trips, order remediations by speed: first let incoming payments refill the available balance, then reduce the next scheduled transfer by the missing amount, then consider connected reserves or external-account debits where configured. Re-issue the reversal with a fresh idempotency key once funds exist to cover it.
The available-balance gate and negative balances
The gate has one documented rule: "It's only possible to reverse a transfer if the connected account's available balance is greater than the reversal amount or has connected reserves enabled" (separate charges and transfers). Available, not pending — freshly landed payments cannot fund a reversal until the rolling schedule releases them.
The gate also interacts with payout timing. Charges land in pending and become available on a rolling schedule — commonly around two days, varying by country and account settings — and each payout draws available down when it runs (payouts). An account can look flush on Monday and be empty by Thursday. Check available, never pending, before any reversal attempt.
When a connected account does go negative, the machinery around it is predictable (account balances):
- Negative transactions offset against future payments on the account where the charge happened.
- No payouts flow while the balance is negative; they resume once it turns positive.
- A negative connected balance older than 180 days triggers a
connect_collection_transferpulling from platform reserves to zero the account — Stripe recommends rejecting accounts that reach this state. - Where supported (US, CA, AU, NZ, SEPA including the UK), setting
debit_negative_balancesto true lets Stripe attempt a debit of the connected account's external bank account.
The practical consequence for recovery timing: a reversal that fails today often succeeds after the next wave of customer payments refills the connected available balance. Netting your recovery against upcoming transfer cycles — reducing the next scheduled transfer instead of demanding cash the account does not have — is usually faster than waiting for a payout-sized cushion (netting playbook).
Recovering historical refunds
Old findings fail for one mechanical reason: the funds left. A payout moved the money to the seller's bank, the available balance dropped below the owed amount, and both the flag path and the standalone path hit the gate described above. The Transfer object still shows the debt — amount minus reversals — but there is nothing liquid behind it.
That constraint dictates operating cadence. Scan for missing reversals on a fixed schedule and work the freshest items first, because recent balances are the likeliest to still cover the debt. Bulk-process findings rather than trickling: group them by connected account, compute Missing = expected − Σ(existing reversals) per transfer, batch the reversal calls with unique idempotency keys, and route each action through human approval with an append-only record so finance can audit who recovered what and when (bulk workflow, post-payout specifics, step-by-step mechanics). For findings already past recovery, the record still matters: net future transfers, document the offset, and close the loop in your ledger.
Carry the evidence inside each finding: the charge ID, refund ID, transfer ID, existing reversal IDs, the expected-versus-actual gap in cents, and the balance transactions on both sides. Approvers then review numbers instead of hunting objects, and the append-only log becomes a replayable history of who authorized which recovery and why.
This triage — join refunds to their transfers, compute the expected proportional reversal, subtract what actually happened — is exactly the detection FeeGuard runs over your last 90 days of Connect activity using a restricted read-only key, with every finding shipped alongside its underlying Stripe evidence.
Verifying a reversal happened
Three independent signals confirm a reversal, and production code should check at least two:
- The
transfer.reversedevent fires when a transfer is reversed in whole or in part (event explainer). - Retrieving the transfer — with
reversalsexpanded, or via the list endpoint — shows the child reversal objects and lets you sum their amounts. - The platform-side balance transaction carries type
reversal, crediting your available balance.
Delivery of webhook events is at-least-once, so treat handlers as idempotent and reconcile against the API as the source of truth (webhooks). The check that matters is comparative: sum the reversals and compare against the expected proportional amount. Retrieving the parent transfer directly also exposes amount_reversed alongside amount, so a quick inspection needs only two fields: what was sent, minus what has come back. Here is a runnable TypeScript check using the official stripe-node SDK:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
function expectedReversalCents(
amountRefundedCents: number,
chargeAmountCents: number,
transferAmountCents: number
): number {
return Math.round((amountRefundedCents / chargeAmountCents) * transferAmountCents);
}
async function sumReversalsCents(transferId: string): Promise<number> {
const reversals = await stripe.transfers.listReversals(transferId, { limit: 100 });
let total = 0;
for (const reversal of reversals.data) {
total += reversal.amount;
}
return total;
}
async function main(): Promise<void> {
const chargeId = process.argv[2] ?? "";
const transferId = process.argv[3] ?? "";
const charge = await stripe.charges.retrieve(chargeId);
const transfer = await stripe.transfers.retrieve(transferId);
const expected = expectedReversalCents(charge.amount_refunded, charge.amount, transfer.amount);
const reversed = await sumReversalsCents(transferId);
const gap = expected - reversed;
console.log(`expected=${expected} reversed=${reversed} gap=${gap}`);
process.exitCode = gap === 0 ? 0 : 1;
}
void main();
Run it as npx tsx check.ts ch_xxx tr_xxx after each refund webhook. On the canonical numbers, a missing reversal prints expected=3600 reversed=0 gap=3600 — the exact cents to recover — and exits nonzero for alerting. Schedule the job from refund.updated so retried or status-changed refunds re-run it automatically, paginate past the first 100 reversals for long histories, and let the nonzero exit code feed your alerting pipeline.
Frequently asked questions
Does Stripe ever reverse a transfer completely on its own?
Yes, in two cases only. A destination-charge refund carrying reverse_transfer=true makes Stripe create the reversal inside the refund call, and an async payment failure on a settled destination charge triggers an automatic reversal of the affected funds. Everything else — all of separate charges and transfers, and any destination refund without the flag — stays put until you act.
Is there a deadline to reverse a transfer after the seller was paid out?
Stripe publishes no deadline; the binding constraint is the connected account's available balance at the moment you call. Once a payout empties it, reversal attempts error until new payments refill the account. If the account keeps transacting, availability rebuilds on its own; dormant accounts can stay unrecoverable indefinitely. Treat that as an operational clock: the sooner your scan runs after a refund, the higher the odds the money is still reachable (how long do you have?).
Does reversing a transfer send the buyer their refund?
No. The buyer-facing movement is the Refund against the charge; the reversal only re-splits funding between platform and connected account. Confusing the two is how platforms end up having refunded buyers while leaving every transfer standing — two objects, two decisions, both required.
Can I reverse part of a transfer more than once?
Yes. Pass an optional amount for a partial reversal and repeat until the transfer is fully reversed; omitting the amount reverses whatever remains. The API errors if you exceed the unreversed remainder or call again after full reversal. Track the running total per transfer in your own records — each API response returns one reversal individually, and the transfer's amount_reversed field is your consolidated view.
Do I get Stripe's processing fee back when I reverse?
No. Reversals move Connect funds between accounts; Stripe's processing fee from the original transaction is not returned. The reversal endpoint's refund_application_fee parameter concerns only your application fee, returning it in full on a full reversal or proportionally otherwise. Budget for the processing cost surviving every refund-and-recovery cycle, exactly as the ledgers above show.
Check your own last 90 days
The gap calculation in this piece — expected proportional reversal minus the sum of actual reversals — runs silently on every destination refund your platform has ever issued. FeeGuard computes it across your last 90 days of Connect activity through a restricted, read-only API key and reports every unreversed transfer, unreclaimed application fee, and uncovered dispute loss with amounts and underlying evidence attached. You get the complete answer first; ongoing monitoring that catches each new occurrence is optional afterward.
FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc.