godozo × Stripe

Refund it or fight it: Stripe fraud, decided from your phone

Jul 17, 2026 · Building godozo

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.

godozo · now
🚨 Fraud warning: refund $189.00?
charge: visa ···9583 · made_with_stolen_card
customer: new · first purchase
refund now → no dispute, no fee
↩️ Refund
Let it stand

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 the 200, 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 the switch for 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