I once shipped a checkout that charged the card the instant the customer hit pay, then ran the order validation afterward. Stock check, address validation, a fraud heuristic, a third-party availability call. When any of those failed, the code did the obvious thing: it refunded the payment
It worked. It also generated a steady trickle of confused, angry emails. "Why did you charge me 89 euros and then refund it three days later?" To the customer, a charge followed by a refund does not read as "we caught a problem". It reads as "this company is sketchy and now my money is stuck in limbo for a week"
The fix was one property I had been ignoring for years: capture_method: manual
The charge-then-refund antipattern
Here is the flow almost every tutorial teaches, and the one I had shipped:
const intent = await stripe.paymentIntents.create({
amount: 8900,
currency: 'eur',
payment_method: paymentMethodId,
confirm: true,
});
// money has now left the customer's account
const order = await validateOrder(cart);
if (!order.ok) {
await stripe.refunds.create({ payment_intent: intent.id });
throw new Error('Order validation failed after charge');
}
The problem is the comment. By the time you run validateOrder, the money is gone. The charge has hit the customer's statement, your statement, and in cross-border cases an FX conversion. If validation fails, you are not undoing a mistake, you are issuing a second financial event that has to settle on its own timeline
That has real costs:
- The customer sees a charge and a refund, days apart, and loses trust
- Refunds can take 5 to 10 business days to land back on the customer's statement
- You may not get every fee back, and FX swings mean the refund amount can differ from the charge
- A pattern of charges-then-refunds is exactly what card networks flag as a risk signal
You did everything to be honest and it still looks bad. The flow is the problem, not your intentions
Authorize, then capture
Cards have always supported a two-step model: authorization places a hold on the funds without moving them, and capture actually moves the money. Hotels and car rental companies have used this forever. The hold sits on the customer's card, the money never leaves until you decide it should, and if you never capture, the hold simply expires
Stripe exposes this with one property on the PaymentIntent:
const intent = await stripe.paymentIntents.create({
amount: 8900,
currency: 'eur',
payment_method: paymentMethodId,
capture_method: 'manual',
confirm: true,
});
// funds are AUTHORIZED, not captured, nothing has moved yet
const order = await validateOrder(cart);
if (order.ok) {
await stripe.paymentIntents.capture(intent.id);
} else {
await stripe.paymentIntents.cancel(intent.id);
}
cancel releases the hold. No charge ever appeared, so there is no refund to explain. The customer sees a pending authorization that quietly drops off. You moved the risky business logic to where it belongs: between the authorization and the capture, while the money is still reversible without a trace
What you get for free
Partial capture. You can capture less than you authorized. Authorize 89 euros for a cart, discover one item is out of stock, capture 64 euros and the rest of the hold releases automatically:
await stripe.paymentIntents.capture(intent.id, {
amount_to_capture: 6400,
});
A real validation window. Stripe holds an uncaptured authorization for about 7 days. That is plenty of time for a synchronous stock check, and enough for some asynchronous flows too. If you never capture, you never charged
A clean audit trail. "Authorized, then cancelled" is a single coherent story in your logs and in the customer's mind. "Charged, then refunded" is two events that have to be reconciled, and that reconciliation is where money quietly goes missing
This is not Stripe-specific
The property name changes, but every serious payment provider exposes the same authorize-then-capture model:
-
Stripe:
capture_method: 'manual'on the PaymentIntent, thenpaymentIntents.capture()orpaymentIntents.cancel() -
Adyen: set a manual capture delay, then call
/paymentsto authorize and/payments/{id}/capturesto capture -
Braintree: pass
submitForSettlement: falsetotransaction.sale, thentransaction.submitForSettlement(id)later -
PayPal: create the order with
intent: 'AUTHORIZE', then capture the authorization
The vocabulary differs, the shape is identical: get a hold, do your work, settle or release
When you should not use it
- Instant digital goods. If you deliver the moment payment succeeds and there is nothing to validate, the extra round trip buys you nothing
- Subscriptions and recurring billing. These run on their own automated capture flow
- Validation slower than the hold. If your checks can take longer than 7 days, the authorization will expire before you capture
- Methods that do not support it. Some non-card payment methods do not offer separate auth and capture, confirm support before you build around it
The rule
If there is any business logic that can fail after the customer has paid but before you are willing to keep their money, that logic belongs between an authorization and a capture
The charge-then-refund flow is the default in almost every example online, which is exactly why it ends up in production. One property moves the risky work to the right side of the money, and the angry emails stop
Originally published on jguillaumesio.com













