Skip to content

Navid's Blog

Ideas, Experiments, and Lessons Learned

Menu
Menu
Dark photo of a laptop screen showing payment API code during a debugging session

A Customer Got Charged Twice — It Wasn’t the Payment Provider

Posted on September 5, 2026 by Navid

The Scene

Two support tickets landed the same morning, eleven minutes apart, saying the same thing. “I got charged twice for my invoice.”

Annual renewals run through our payment provider. A webhook marks the invoice paid, the receipt goes out, done. So I did what any confident engineer does at 9am: opened a case with the provider and quoted timestamps like I was presenting evidence in court. My theory: their API had a rough week and fired double callbacks. I even told their support “we send each payment exactly once.” I want that sentence back.

The Investigation

The provider replied a few hours later with a one-liner. Both charges were real, both came from our API keys, and one was flagged as a retry. A retry. We don’t retry. At least I was pretty sure we didn’t.

So I went digging. Grepped the gateway logs for the invoice id and there they were, two POSTs to the payments endpoint, 400 milliseconds apart, identical bodies. The first request had succeeded on their side. But our HTTP client had a 90 second timeout, their API was slow that afternoon (their status page admitted as much), so our client decided the request had failed and quietly sent it again.

Here’s the part that stung. The retry wasn’t something I wrote. It was a library default, max_retries=2, sitting in the docs the whole time. I had never opened that page.

The Discovery

Turns out “we don’t retry payments” was a feeling, not a config value. The library had been retrying since day one, and because we never sent an Idempotency-Key, the provider had no way to know that second POST was the same invoice.

The Fix

The fix itself was boring, which is the best kind. We generate a key per invoice and pass it on every create call. The provider dedupes on their side.

key = f"invoice-{invoice.id}"
client.payments.create(payload, idempotency_key=key)

Refunded both customers that afternoon, with apology emails I rewrote three times. Their support guy got an apology too. He was polite about it, which somehow made it worse.

The Lesson

  • Every write call gets an idempotency key. Especially the ones that move money.
  • Read your client library’s defaults. Retries, timeouts, backoff. All of it is configured without you.
  • A timeout is not a failure. The request may have landed anyway.
  • Blaming the vendor first cost me a day. The answer was in our own logs the entire time.

What I’d Do Different

  1. Check our own logs before opening a vendor ticket. Twenty minutes of grepping would have saved a support round trip.
  2. Set retry policy and timeout explicitly, in one place, in code I can find.
  3. Add an integration test that fires the same payment twice and asserts one charge.

Final Thoughts

Still with me? The annoying part is that nothing here was hard. The library did something reasonable, the provider had a bad afternoon, and my code had no seatbelt. That combination sat in production for two years.

So yeah. Two refunds, one config line, and a support ticket I had to withdraw. The provider guy signed off with “happy to help”, and honestly, he was.

External Resources

  • Stripe: Idempotent Requests
  • MDN: Idempotent
  • RFC 9110, HTTP Semantics

Categories

  • AI Experiments
  • Coding
  • Debugging Stories
  • Hot Takes
  • Ideas
  • Lessons Learned
  • Productivity
  • Project Management
  • Systems & Architecture
  • Uncategorized
  • Vibe Coding

Recent Posts

  • 5 ORM Mistakes That Pushed Me Back to Raw SQL
  • Norway Vanished From Our App for Two Days — It Was One Unquoted Line
  • I Ignored a Deprecation Warning for 11 Months — It Worked Until It Didn’t
  • Why I Stopped Time-Blocking (And What I Do Instead)
  • I Vibe-Coded a Tool in One Afternoon — It Died on Its First Real User