DEV Community

Posting Dude
Posting Dude

Posted on

Under 100 Payments a Month, One Stripe Dispute Is Already a 1% Dispute Rate

Here's some math nobody puts in the Stripe tutorial. If your SaaS does 100 payments a month and one customer disputes a charge, your dispute rate for that month is 1%. Stripe's own docs say the card industry treats dispute activity above 0.75% as excessive.

One dispute. That's all it takes when you're small. To be fair, the card networks judge this over months, not one bad week, so a single dispute isn't getting you fined. But your numbers look bad very fast and there's almost no room.

Stripe even calls this out in its fraud best practices: a business with fewer than 100 payments a month is one where "one or two fraud disputes can have a very outsized impact" on the dispute rate. So the playbook for a tiny SaaS is a bit different from the one you read in big-company posts. Here's what I'd set up, and what each dispute actually costs you.

What a dispute costs (US numbers, from Stripe's pricing page)

  • Dispute received fee: $15. Charged the moment the dispute opens. On standard pricing, outside Mexico, you don't get it back, even if you win.
  • Dispute countered fee: $15. Only if you respond with evidence manually. You get this one back if you win.
  • Smart Disputes: Stripe's AI builds the evidence for you and charges 30% of the disputed amount, only on disputes it wins. The $15 received fee still applies.

So take a $29/month customer:

What you do What you're out
Refund before any dispute exists $29 (plus the original processing fee, which refunds don't return)
Accept the dispute $29 + $15 = $44
Fight and lose $29 + $15 + $15 = $59
Fight and win $15

Fees vary by country (Stripe's India page lists ₹1,000 for each), so check yours. But the shape is the same everywhere: once the dispute exists, even winning costs money, and it counts toward your dispute rate whether you win or lose.

That changes the goal. At 100 payments a month the win isn't "fight every dispute well". It's "have fewer of them exist at all".

Prevention first: the three dispute reasons a small SaaS actually sees

Stripe has a list of dispute reasons. For a subscription product, the ones I'd worry about are these three.

1. unrecognized: they don't know what the charge is.
This one is so often avoidable. If your Stripe statement descriptor still says your legal company name or some old project name, people look at their card statement, see a charge they don't recognize, and call the bank. Stripe's guidance is to use a name or URL customers would associate with the purchase.

The rules: 5 to 22 characters, Latin characters only, at least one letter. If you use a shortened prefix for card charges it's 2 to 10 characters. For subscriptions you can't set a suffix on the PaymentIntent because Stripe creates those charges, so set statement_descriptor on the Product (or on the Invoice) instead. Also turn on receipts so there's an email to search for when they see the charge.

2. subscription_canceled: "I canceled and you kept charging me".
Usually this is someone who thought they canceled, couldn't find the button, or forgot the renewal was coming. Three cheap fixes:

  • Make cancel easy to find. A dark-pattern cancel flow is basically a dispute generator. There's a longer piece on how to design a cancel flow without dark patterns on Aura++, and almost all of it applies here.
  • Send a confirmation email when they cancel, and actually pass the cancel through to Stripe.
  • Turn on renewal reminders. In Billing settings there's "Send emails about upcoming renewals". It only works for subscriptions with collection_method: charge_automatically, which is most small SaaS setups anyway. Annual plans are where this matters most, nobody remembers a charge from 12 months ago.

3. fraudulent: a stolen card.
This is mostly card testing and junk signups, which I covered in a small defense stack for fake signups and card testing. The part that matters for disputes is the next section.

Early fraud warnings: refund faster than you think

Before a fraud dispute shows up, you'll often get an early fraud warning (EFW). Visa, Mastercard and JCB issuers send these when they suspect a payment was fraud. Stripe sends a radar.early_fraud_warning.created event for it.

Stripe's numbers: if you don't refund, about 40% of Visa and Mastercard EFWs become fraud disputes. Their general advice is to refund EFWs on charges roughly at or under your dispute fee, and that it's usually not worth it on charges more than 35% above the fee.

But the same docs list exceptions where refunding more aggressively makes sense, and "new or small business (say, fewer than 100 payments per month)" is one of them. A $29 charge is about twice the $15 fee, so by the general rule you'd maybe wait. At small volume I wouldn't. Refund it and save yourself the dispute.

Two details from the docs worth knowing:

  • Refunding doesn't erase the EFW. Visa counts EFWs in its VAMP monitoring program anyway. What the refund does is stop the same payment from also becoming a dispute, and a transaction that shows up as both is counted twice.
  • Refund the full amount. A fully refunded payment can't be disputed. A partially refunded one can still be disputed for the full amount.

In the Dashboard use "Refund as fraud", which also tells Stripe it was fraud.

The webhook gotcha: a dispute doesn't tell you which subscription

When a dispute comes in you get charge.dispute.created. You probably want to do something in your app: pause access, flag the account, at least email them. The catch is that the Dispute object has charge and a nullable payment_intent, but no invoice or subscription.

On API version 2025-03-31.basil or later, the Charge doesn't have an invoice field anymore either. Stripe's documented path is to look the PaymentIntent up in the invoice payments list:

curl -G https://api.stripe.com/v1/invoice_payments \
  -u "$STRIPE_SECRET_KEY:" \
  -d "payment[type]=payment_intent" \
  -d "payment[payment_intent]=pi_123" \
  -d status=paid
Enter fullscreen mode Exit fullscreen mode

Each result has an invoice. Retrieve it, check that parent.type is subscription_details, and parent.subscription_details.subscription is your subscription id. If payment_intent was null on the dispute, retrieve the charge and use its payment_intent. The same lookup works for EFWs and refunds.

The events I'd subscribe to for this: charge.dispute.created, charge.dispute.closed (won or lost) and radar.early_fraud_warning.created. Skip them and your app doesn't know about a dispute or a fraud warning until someone opens the Dashboard.

When one arrives anyway

You usually get 7 to 21 days to respond depending on the card network, and if you miss the deadline you lose automatically. A few rules from the respond-to-disputes docs that catch people out:

  • You get one submission. You can't edit it or add files after. Gather everything first.
  • Banks don't click links. No "see our terms at...". Screenshot or PDF everything. Max 4.5 MB combined, and Mastercard caps evidence at 19 pages.
  • You can't refund a disputed charge while the dispute is open, even if you and the customer agree. Talking to them still helps, and if they withdraw you still have to submit a response.
  • Amex and Discover sometimes send an inquiry first. Answer it. Resolving at the inquiry stage means no dispute fee, and ignoring one can turn into a chargeback you can't win.

For a digital subscription, the evidence that matters is stuff your app already has, if you log it:

  • Your cancellation policy as shown to the customer, and how and when you showed it (a screenshot of checkout works)
  • Login or usage logs after the charge, with timestamps and IP addresses. Stripe lists this as access_activity_log
  • The renewal reminder or receipt email you sent
  • Any support emails with them

If you don't record something like a last_seen_at or a simple usage log today, this is a good reason to start. "They logged in 14 times after the renewal" is the kind of thing a bank can actually check against the claim.

Test it before it's real

Stripe has test cards for all of this:

  • 4000000000000259: the charge succeeds, then gets disputed as fraudulent
  • 4000000000005423: the charge succeeds, then gets an early fraud warning
  • 4000000000001976: the charge succeeds, then gets an inquiry

Then respond with winning_evidence or losing_evidence as the uncategorized_text evidence (it's the "Additional information" field in the Dashboard) to simulate the outcome. Run each one once and check your webhook handler finds the right subscription, your app does what you wanted with access, and you'd actually notice the email.

If you've already set up the paywall side, this fits next to what each of Stripe's 8 subscription statuses should mean for access, since a lost dispute is one more way a customer ends up not paying.


If you're getting a small SaaS ready to put in front of people, I run Aura++, a launch platform for indie products, and early-stage products are exactly who it's for.

Question for anyone billing on Stripe: when you get an early fraud warning on a small charge, do you refund right away or wait to see if it turns into a dispute? And what's your cutoff amount?

Top comments (0)