You issued the refund. The connected account still has the money.
One refund, followed through every object it touches and every object it leaves alone. Six moments, four of which nobody at the platform ever sees.
Here is a single refund on a Connect platform, followed from the customer's email to the month-end close. The platform is fictional, the numbers are round, and nothing in the sequence is a bug. Every API call succeeds. Every system involved reports that it did what it was asked.
The platform still ends the day down two thousand dollars.
14:02 — the customer asks for their money back
A buyer paid $2,400 for a booking. The platform takes a 12% application fee, so $288 stayed with the platform and $2,112 was transferred to the connected account that fulfils the booking. That transfer happened eleven days ago and has long since been paid out.
The buyer now wants a full refund, and is entitled to one. A support agent opens the order and clicks refund.
14:02 — the refund is created
The code behind that button does what almost every refund path does when it is first written:
await stripe.refunds.create({
charge: chargeId,
});
It returns successfully. status is succeeded. The buyer's card will show the credit within a few business days. From the perspective of the application, the support agent, the buyer and the logs, this transaction is now complete and correct.
14:02 — what moved
Exactly one thing.
The refund debited $2,400 from the platform's Stripe balance and sent it back to the buyer. That is the entire effect of the call above. If you fetch the charge afterwards, it will tell you so plainly:
const charge = await stripe.charges.retrieve(chargeId, {
expand: ['transfer', 'application_fee', 'refunds'],
});
charge.refunded; // true
charge.amount_refunded; // 240000
Two fields, both of them true, both of them describing only the buyer's side of the transaction.
14:02 — what did not move
Two things, and they are the two that determine who paid for this.
The transfer is untouched. charge.transfer still points at a transfer object of $2,112, and that transfer has no reversals against it. The connected account received that money eleven days ago, has been paid out, and as far as its balance is concerned nothing has happened today at all.
const transfer = await stripe.transfers.retrieve(
typeof charge.transfer === 'string' ? charge.transfer : charge.transfer.id,
);
transfer.amount; // 211200
transfer.amount_reversed; // 0
transfer.reversals.data.length; // 0
The application fee is also untouched, which in this case is the one piece of good news — the platform still holds its $288. Whether that is correct is a commercial question rather than a technical one, and it is a genuinely arguable one.
So the position is this. The platform paid out $2,400 to the buyer. It holds $288 in fee revenue from a booking that no longer exists. The connected account holds $2,112 that it was paid for a service it will not now provide. Net, on a transaction that was worth $288 to the platform an hour ago, the platform is down $2,112.
No error. No log entry. Just less cash at the end of the month.
The mechanism behind this is the three-movement structure of a Connect charge, which we have written about separately in the refund that only costs the platform. The short version is that the charge, the application fee and the transfer are three separate objects with three separate lifecycles, and refunding the first one has no opinion whatsoever about the other two.
14:03 — the support ticket closes
This is the part that makes the whole class of failure durable.
Every actor in the sequence has now received a success signal. The agent saw a confirmation. The buyer will see a credit. The application logged a 200. The webhook handler processed charge.refunded without incident. If there is a Slack channel for payment errors, it stayed quiet, because there was no error — a refund without a transfer reversal is a completely valid instruction, and Stripe executes valid instructions without commentary.
There is nothing here for a monitoring system to catch. Availability is fine. Latency is fine. The error rate is zero and it is correctly zero. The only artifact that records the problem is the relationship between four objects that no default dashboard joins together.
Anyone building the check themselves eventually discovers that the relationship is fiddlier than it looks. Three details matter more than they should:
- Read live state, not the webhook payload. The event body is a point-in-time snapshot from when Stripe queued it. By the time your worker runs, a reversal may already exist — created by a retry, by an admin tool, or by the correct code path on a second attempt. Trusting the snapshot produces false positives, and a false positive in a money tool is expensive in a way that is hard to recover from.
- Allow for rounding. Stripe computes proportional reversals independently of whatever you compute, so a charge with several partial refunds against it can legitimately differ from your arithmetic by a cent or two. Reporting a one-cent loss teaches people to ignore you.
- Distinguish "no reversal" from "not a destination charge". If the charge has no linked transfer at all, this is a direct charge or a separate charges-and-transfers flow, and the entire question does not apply. Reporting those as leaks is how a report becomes noise.
Month end — the first time anyone notices
Three weeks later, someone reconciling the month finds that platform revenue is lower than the sum of the application fees, and starts pulling threads.
By then two things have changed. First, there are more of these — every refund since has taken the same path, because the code did not change. Second, and more importantly, the oldest ones may no longer be recoverable. A transfer reversal requires the funds to still be reachable: the connected account needs the balance, and an account that has been paid out, has had a slow month, or has since been restricted may simply not have it. A discrepancy you cannot act on is not a discrepancy. It is a write-off.
That is the argument for detecting this at 14:02 rather than at month end, and it has nothing to do with urgency as a sales device. It is arithmetic about recoverability. The window is widest on the day the refund is issued and narrows continuously from there.
If you want to know whether your own platform does this, the fastest check is the one that needs no tooling at all: take your last fifty refunds, fetch each charge with its transfer expanded, and count how many have amount_reversed of zero. If that number is not close to zero, you already know what the rest of the month looks like.
FeeGuard is an independent product and is not affiliated with, endorsed by, or sponsored by Stripe, Inc. "Stripe" and "Stripe Connect" are trademarks of Stripe, Inc., referenced descriptively.