
When to automate refunds, and when to keep them manual
Automating refunds is easy, and early on it deletes your best customer feedback. Why manual refunds pay, and the four signals it is time to automate.

Keep refunds manual until they stop teaching you anything: a refund request is the highest-signal message a customer will send you, and a one-click button deletes it before you read it. Automate when the surprises stop.
I build billing systems for a living. Automating a refund is not hard: you call the provider’s refund endpoint, you revoke the entitlement when it confirms, you write the audit row, you send the email. I have shipped that flow more than once and I would ship it again.
And when an early-stage founder asks me to build it, I usually tell them to wait.
Not because the code is risky, and not because a human is more reliable than an API call. Because in the first few hundred customers, a refund request is the most information-dense message you will ever receive, and a one-click button is a machine for deleting it before anyone reads it.
A refund request is feedback you cannot buy
Almost nobody tells you why they left. They churn silently, the renewal fails, the seat goes quiet. Surveys go unanswered. The people who never bought never explain themselves.
A refund request is different. Someone paid you money, used the thing, decided it was wrong, and then cared enough to come back and ask for the money returned. That person is, briefly, willing to talk to you. They have a reason, and it is fresh and specific. There is no other moment in the funnel where a stranger tells you the truth about your product at that resolution.
An automated refund flow takes that moment and converts it into a row in a table. You get a number. You do not get the sentence that would have changed your roadmap.
At scale that trade is correct: once volume is high enough, the marginal request stops teaching you anything new and the support cost becomes real. In the first few hundred customers, you are trading away the thing you most need for a cost you can still absorb by hand.
Early on a company is short of almost everything at once: money, time, proof, and honest signal about whether the idea is right. The refund conversation is one of the few places where three of those arrive in the same message.
Ask one question when a refund request arrives
Keeping refunds manual is a smaller practice than it sounds. When a refund request arrives, a person replies and asks what happened.
Not “please state your reason for requesting a refund as per our policy.” Not a form with five options you wrote. A short, plain message from a human: what went wrong, or what did you expect that this did not do.
Two things matter about who sends it.
Put your best communicator on refunds, not your fastest responder. In most teams I have worked inside, refunds go to whoever has capacity, which usually means whoever is least busy, which often means whoever is most junior. That is backwards. A refund reply is the hardest message your support surface produces: the customer is already disappointed, is expecting friction, and is braced for you to make this difficult. It needs somebody who reads tone well, who does not get defensive about the product, and who can tell the difference between “this is broken” and “this is not for me.” It is the same instinct behind hiring boring for the code that moves money: you are selecting for judgment under pressure, not for speed.
They must be someone who is allowed to say yes. If the person answering believes they are measured on refunds prevented, they will apply pressure and the customer will feel it. The question exists to understand, not to build a case. A customer who senses they are being handled stops telling you anything useful and escalates instead.
That is the whole mechanism: one honest question, from someone good at asking it, who is free to accept the answer.
The three reasons behind most refund requests
The reason behind a refund request is usually one of three.
- A glitch they hit and assumed was the product. A webhook that never fired, so their upgrade did not apply. A license that failed to activate on a staging domain. A charge that looked duplicated because retry logic was noisy. This is the version I care most about, because it is not really a refund request. It is a bug report with money attached, from a customer telling you about a failure your monitoring missed. These are exactly the payment edge cases that bite at 2am, found by the least convenient method available.
- A feature that exists but was not findable. They wanted the thing, the thing is there, nothing in the product pointed at it. That is an onboarding failure wearing a refund costume, and it is repeating silently for every customer who did not bother to write in.
- A mismatch between what your marketing implied and what you built. This one is the most painful to hear and the most valuable, because it sits upstream of everything. No support process fixes a promise you should not have made.
The first two are recoverable, and this is where the conversation earns its cost. When the cause is a glitch or something in the product I can actually fix, saying “that was a bug, here is the fix, it is deployed, do you still want the refund?” resolves it roughly seven times in ten, across the subscription and licensing products I maintain. Not because anyone was talked round. Because the problem went away. That is repair, not persuasion.
The third is not recoverable by a support reply, and pretending otherwise is how a refund queue turns into a retention funnel. It is also the one that should change something upstream, in the same way that deciding whether to build or buy your billing is a positioning decision before it is a technical one.
What one refund request actually built
The clearest example I have comes from the affiliate plugin I maintain.
A refund request arrived with no explanation attached, just the request. So we asked what happened.
The answer was specific: they needed a disclosure line to appear automatically wherever an affiliate link was rendered on their site. Not on a policy page. At the link. That is not a preference, it is what the guidance actually requires: the FTC is explicit that a disclosure belongs with the endorsement message itself, and that disclosures are likely to be missed if they sit only on an About page or anywhere a reader has to click through to find. Their own review had flagged it, and the plugin did not do it.
That was not a bug and not a discoverability problem. It was a real gap, and the customer was right.
So I told them exactly that: it is missing, you are correct that it should exist, and here is a beta build that does it. They installed it, it solved their problem, and they withdrew the refund request themselves. I never asked them to.
Then comes the part I think matters more, and it is the part founders usually get wrong in the other direction.
I did not ship that feature to everyone the following week.
One customer asking for something is a data point, not a roadmap. Building it into the product properly meant deciding where the disclosure text lives, how it is translated, whether it is per-link or per-page, what happens on themes that strip markup, and whether it should be on by default for everyone who updates. That is a real feature with real edges, and I had exactly one request for it. So the beta stayed with the customer who needed it, and I waited to see whether anyone else asked.
Others did. When the same request arrived from unrelated customers, it stopped being one person’s edge case and became a documented, tested, default-on feature. The refund conversation did not just save one subscription. It found the feature roughly two releases before the roadmap would have.
One honest caveat, because this move can be abused. A beta handed over to stop a refund is a promise. If you use it to buy time and then never ship the real thing, you have not saved a customer, you have converted a refund into a slower and more personal betrayal. Give the build only if you would be comfortable being held to it.
Why “I changed my mind” is a complete refund request
Here is the part that matters more than any recovery rate.
If the answer is “I changed my mind,” that is a complete answer. It is not an objection to handle. There is no follow-up question. You refund it.
The pressure to do otherwise is real and worth naming. A refund at an early stage genuinely hurts. When monthly revenue is small a single refund is a visible dent, and it arrives feeling like a verdict on the work. Founders take it personally because at that stage the product is not separate from them yet. That feeling is legitimate.
It is also entirely your problem, not the customer’s.
A refund hurts the founder. That does not give the founder any right to hurt the customer’s pocket.
Someone who does not want your product should not be holding your invoice. Every extra step you put between them and their money converts your discomfort into their cost, and they can tell exactly what you are doing.
This is also why I am wary of the “convince them” framing even though convincing works. The moment recovery becomes the goal rather than a by-product of fixing something real, the conversation becomes a retention funnel, and customers recognize one instantly. You keep a little revenue and lose the thing that made the question worth asking.
The practical test I use: would I be comfortable if the customer published this entire email thread? If any part of the reply only works because they will not compare notes with anyone, it should not be sent.
Refund because they asked, not because it is cheaper
Set aside how it feels, because the arithmetic agrees anyway.
A customer denied a refund often does not give up; they open a dispute with their card issuer instead. That is materially worse for you. Stripe’s documentation is explicit that a dispute pulls the payment amount and a network dispute fee from your balance while you assemble evidence, and your dispute rate is a number the card networks watch. You can end up paying more than the refund, plus your own time, plus a metric that follows you.
There is also a version that shows up in no dashboard: the person who got an easy refund and a straight answer sometimes returns when the product finally fits, and meanwhile does not tell people you were difficult about money. I cannot put a number on that and will not pretend to. The downside is easier to price, because a public complaint about a company that would not return sixty dollars costs far more than sixty dollars.
One caution, since the arithmetic is the weaker argument: if “it is cheaper than a chargeback” becomes your reason for refunding, you have rebuilt the retention funnel with better maths. Refund because they asked. That the economics agree is a convenience, not the justification.
Keeping the decision manual is not an excuse for a sloppy refund path
None of this changes what the code must do. Whoever presses the button, the execution has to be boring and correct.
// $requestKey is supplied by the caller and is unique per decision, not derived
// from the amount: two legitimate partial refunds of the same value are not duplicates.
public function refund(Payment $payment, Money $amount, string $reason, string $requestKey): Refund
{
// 1. Record the intent first, and let a unique index on (payment_id, request_key)
// be the actual guard. A double-clicked button and a retried job land here twice.
try {
$refund = Refund::create([
'payment_id' => $payment->id,
'request_key' => $requestKey,
'amount_cents' => $amount->cents(), // integer minor units, never a float
'reason' => $reason, // what the customer actually said
'status' => 'pending',
]);
} catch (UniqueConstraintViolationException) {
// Already handled this exact decision. Return it, do not refund twice.
return Refund::where('payment_id', $payment->id)
->where('request_key', $requestKey)
->firstOrFail();
}
// 2. Call the provider OUTSIDE any transaction. A network call must never hold a
// row lock, and a rolled-back commit must never strand a refund that really happened.
$result = $this->gateway->refund($payment->charge_id, $amount, $requestKey);
// 3. Confirm and revoke together, so you can never have returned the money while
// the account still works. The webhook runs this same step idempotently, which
// is what recovers the case where the response above is lost in flight.
DB::transaction(function () use ($refund, $result) {
$refund->update(['status' => 'succeeded', 'gateway_ref' => $result->id]);
$refund->payment->subscription->revokeAccess();
});
return $refund->refresh();
}
Three details do the work. The idempotency key comes from the decision rather than the amount, because the provider will happily let you issue several partial refunds against one charge so long as they do not exceed the original, and keying on value would make two legitimate refunds collide. The amount is derived rather than divided and stored as integer minor units, which is exactly the arithmetic a tired human gets wrong at a keyboard. And the gateway call sits outside the transaction, because the one failure you cannot recover from is a provider that has moved real money while your database rolled back and forgot.
That is also why the refund path is where I read a codebase’s soul. Keeping the decision human raises the bar on the code rather than lowering it, because now a person is triggering it under time pressure with a customer waiting.
When to automate refunds: four signals
Four signals say you have outgrown manual refunds:
- The last thirty refund conversations taught you nothing new. When the answers stop surprising you, the signal is extracted and you are paying for it out of habit.
- Response time has become the complaint. If people wait a day for a refund because a human is asleep, the process now produces the resentment it was meant to prevent.
- Someone is deciding by rule rather than judgment. The moment your team is applying a flowchart, the human adds nothing a function could not, and you should ship the function.
- The volume is genuinely painful. This is the weakest reason on its own, and the one most often reached for first.
I am not arguing for a human in the loop forever. Manual refunds cost real time and that cost grows, and past a certain volume the human is adding nothing a function could not.
When you do automate, keep a smaller version of the conversation: one optional free-text field, read by an actual person weekly. The value was never in the gatekeeping. It was in reading the sentence.
Automating refunds is a solved engineering problem and usually the wrong thing to solve first. The request is the most honest message a customer will ever send you, and it is worth a human reply for exactly as long as those replies keep teaching you something. Ask what happened, put your best communicator on it rather than your quickest, fix the thing if it is fixable, and refund without friction the moment someone says they changed their mind. Automate when the surprises stop, not when the discomfort starts. The discomfort of a refund belongs to you. The money belongs to them.
FAQ
When the last thirty refund conversations produced nothing you did not already know, when response time has become the complaint, or when your team is deciding by flowchart rather than judgment. Volume alone is the weakest reason, though it is the one most often used first. Automate when the surprises stop.
It is if you do it badly. The test is whether the person replying is allowed to say yes immediately. If they are measured on refunds prevented, you have built a retention funnel and customers will read it as one. If they are measured on understanding what happened, you have built a feedback channel.
Refund them. The question is an invitation, not a condition. Making an answer mandatory turns a request into an interrogation and guarantees the answer you get is not the true one.
Less so. The lower the amount, the weaker the case for spending a human reply on it, and trials rarely produce a refund request at all. This matters most for annual plans, licenses, and anything large enough that the customer thought carefully before both buying and asking.
No. One request is a data point, not a roadmap. Give that customer a beta or a workaround if you can, then wait to see whether the same request arrives from unrelated customers before you build it properly. If you hand over a beta, treat it as a promise you will be held to.
Only when you can remove the reason. If the cause was a bug you have now fixed, saying so and offering the choice is fair. If the cause is that they no longer want the product, there is nothing to remove and nothing to discuss.