Refund it or fight it: Stripe fraud, decided from your phone
Most Stripe events just want you to know. The fraud ones want an answer — and they come with deadlines and fees attached. Refund a flagged charge before the dispute lands and you're out the sale; miss the window and you're out the sale, a dispute fee, and a mark against your account. That's not a notification. That's a decision.
customer: new · first purchase
refund now → no dispute, no fee
The issuer says the card was stolen. You have hours, not days.
In the last post we built a 40-line webhook receiver that turns Stripe events into notify pings. This post upgrades the fraud-shaped ones to godozo's other verb — request_approval, the blocking gate — so the refund-or-fight call is a button under your thumb instead of a dashboard chore you find on Monday. Three moves, in escalating order of judgment required.
First, the architecture rule that makes this safe
A webhook handler answers Stripe in seconds. A human answers in minutes. Those two facts must never share a code path:
Ack the webhook first. Gate after. Stripe treats a slow or non-2xx response as a failed delivery and retries it — so if your handler blocks on a human, every unanswered approval becomes a stack of duplicate events. Send the200, then await the approval, then act through the Stripe API. The gate blocks your follow-up action, never Stripe's delivery. (Same receiver as last time —res.sendStatus(200)sits above theswitchfor exactly this reason.)
Move 1 — Early fraud warning → one-tap refund
When a card issuer reports a charge as fraud, Stripe fires radar.early_fraud_warning.created — often before the formal dispute arrives. That gap is worth real money: refund inside it and the dispute usually never materializes — no fee, no dispute count against your account. Wait, and the same money leaves anyway with the fee and the mark stapled on.
So the handler asks, and your answer is the action:
case 'radar.early_fraud_warning.created': { const charge = await stripe.charges.retrieve(o.charge); const card = charge.payment_method_details?.card; // The run pauses HERE — on our side, after the 200 — until you tap. const r = await gd.requestApproval({ title: `🚨 Fraud warning: refund ${usd(charge.amount)}?`, detail: `${card?.brand} ···${card?.last4} · ${o.fraud_type}\n` + `${charge.billing_details?.email ?? 'no email'} · refund now = no dispute, no fee`, }); if (r.approved) { await stripe.refunds.create({ charge: o.charge }); await gd.notify({ title: '↩️ refunded', message: `${usd(charge.amount)} returned before the dispute could land` }); } break; }
Approve from the couch and the refund fires; the confirmation ping closes the loop. Deny — maybe you recognize the customer, maybe the "fraud" is a spouse who didn't recognize the statement descriptor — and nothing happens. If the dispute has already landed by the time you tap, the refund call errors; at that point you're in Move 3 territory anyway.
Timeouts should do nothing. This gate moves real money on Approve — so silence must not be an answer. request_approval distinguishes an explicit decision from a timeout; act only on the explicit tap. An unanswered warning is still sitting in the Stripe dashboard tomorrow; a wrongly-auto-refunded charge is gone.
Move 2 — Elevated-risk charges → keep it or return it
Radar scores every charge, and the score rides along on the charge itself: outcome.risk_level is normal, elevated, or highest. Radar blocks the worst outright, but elevated is the judgment zone — real enough to succeed, sketchy enough to bite you as a dispute in three weeks. Perfect gate material:
case 'charge.succeeded': { const risk = o.outcome?.risk_level; if (risk !== 'elevated' && risk !== 'highest') break; // normal charges stay silent const r = await gd.requestApproval({ title: `⚠️ ${risk}-risk charge: keep ${usd(o.amount)}?`, detail: `${o.billing_details?.email ?? 'no email'} · ${o.outcome?.seller_message}`, }); // Approve = keep the sale. Only an explicit Deny refunds — never a timeout. if (!r.approved && r.decision) { await stripe.refunds.create({ charge: o.id }); await gd.notify({ title: '↩️ refunded', message: `${usd(o.amount)} — not worth the dispute risk` }); } break; }
Note the polarity: the question is "keep it?", so Approve keeps the money and Deny returns it. Phrase gate titles so the green button is the calm choice — you'll be answering these half-asleep, and the button you tap without thinking should be the one that's easy to undo.
Move 3 — Disputes → a deadline on your phone
Once charge.dispute.created fires, the money and the fee are already withdrawn — there's nothing left to gate, only a fight to win. What matters now is the evidence deadline, which is exactly the kind of date that dies quietly in a dashboard. Put it on your phone with the link that goes straight to the fight:
case 'charge.dispute.created': { const due = new Date(o.evidence_details.due_by * 1000).toDateString(); await gd.notify({ title: `⚔️ dispute: ${usd(o.amount)}`, message: `reason: ${o.reason} · evidence due ${due}\n` + `https://dashboard.stripe.com/disputes/${o.id}`, }); break; }
o.reason does a lot of triage for you: fraudulent on a physical-goods order with a delivery signature is worth fighting; product_not_received on something you never shipped is not. Either way the deadline found you, not the other way around.
The same gate, when the "agent" issues refunds
There's a second fraud surface hiding in all this: your own tooling. The moment a support script — or an AI agent answering support tickets — holds a Stripe key that can create refunds, "customer asks nicely" becomes an attack vector. Same fix as the spend gate: let small refunds flow, and put a blocking gate on big ones. In a shell script it's one line —
godozo gate --title "Refund \$240.00 to dan@example.com?" \ --detail "ticket #4712 · reason: duplicate charge" --timeout 600 \ && stripe refunds create --charge ch_3Nq… --api-key "$STRIPE_SECRET_KEY"
— and for a coding agent, a standing rule in CLAUDE.md / AGENTS.md:
## Stripe safety (godozo)
- Any refund over $50, and ANY payout, dispute closure, or account change:
call godozo request_approval (or `godozo gate`) with the amount, customer,
and reason — and WAIT. Deny or timeout = do not touch Stripe.
- After every refund you issue, godozo notify the amount + charge id,
so there's a receipt trail on my phone and in the audit log.The prompt is the policy; if the refund path runs through a Claude Code tool call, pin it with a gate-hook seatbelt exactly like the destructive-git one — deterministic, fails closed, not up to the model.
Why fraud is the right place for a human
Fraud tooling keeps promising to remove the human, and the human keeps turning out to be load-bearing — because the expensive failures are the ambiguous ones. Radar knows the card looks stolen; it doesn't know that this "suspicious" order is your biggest customer's new procurement person. The right split is the one godozo is built around: machines watch everything, humans decide the ambiguous middle — and the deciding should take one tap, not a login.
One thing left in the series: the events that don't deserve a buzz at all, rolled up into the one message a day you'll actually read. That's the daily digest.
← More from the godozo blog