Home / Blog / Skip idempotency in your REST API and the invoice goes out twice

Skip idempotency in your REST API and the invoice goes out twice

Payments, orders, message sends: idempotency is non-negotiable. How to wire it up, and the subtleties you cannot skip.

A few years ago, a client’s payment API lit up one morning with a 3.2% duplicate-charge report. More than 400 customers had been charged twice. The root cause was simple: a missing idempotency layer.

Ever since, adding idempotency to every critical API endpoint has been a non-negotiable step. Here’s a practical implementation and the subtleties you shouldn’t skip.

Why you need it

The internet is unreliable. The client sends a request, the network times out. Thirty seconds later the client retries. But the first request did reach the server, the response just never came back.

Now the backend sees the same operation twice:

  • Same customer charged twice
  • Same order written twice
  • Same SMS sent twice

Idempotency fixes this: if the client retries with the same operation key, the server returns the same response without re-running the operation.

Practical implementation

The client generates an Idempotency-Key header (UUID v4) per unique operation. Every retry uses the same key.

On the backend:

  1. When a request comes in, read the header key
  2. Check Redis or the DB for a previously stored response under that key
  3. If it exists, return it directly, do not run the operation again
  4. Otherwise, run the operation, store the result under the key with a 24-hour TTL, and return the response

I use Redis as the store. Fast, and TTL is native.

Don’t miss the race condition

That flow has a hole: if two requests arrive in parallel, neither finds a cache entry, both start processing, and you end up with a duplicate charge anyway.

You need a distributed lock. With Redis’s SETNX you acquire a lock on the key, hold it until the operation completes, then release it. A concurrent request that can’t grab the lock either waits or gets a 409.

Store the full response, not just a success flag

This is a detail but it matters. The client needs to see the same response, not just “yep, it worked”. If the original response returned transaction_id: txn_abc123, the retry has to see the same ID.

So the idempotency store holds the full response body plus the status code, not a boolean flag.

Pick the right TTL

How long should you keep an idempotency key?

  • Too short (1 minute): you miss retries that come minutes later
  • Too long (1 year): storage bloat, and key-collision risk creeps up

Stripe uses 24 hours. That’s a solid default in practice. I use the same. If you have a workflow that runs longer than 24 hours, hold that specific key for longer explicitly.

Where it doesn’t help: over-engineering

Don’t bolt idempotency onto every endpoint. Where you need it:

  • POST /payments, /charges, /refunds
  • POST /orders, /subscriptions
  • POST /messages, /emails, /sms
  • PUT /accounts (anything that mutates balances)

Where you don’t (already idempotent by design):

  • GET endpoints, same result every time
  • PUT /profile (replaces the resource, same input yields the same state)
  • DELETE /items/{id} (after the first delete you get 404)

Simple rule: if the operation produces an external side effect (payment, email, notification), you need idempotency. If it only mutates the DB in an idempotent way (profile update), you don’t.

Response pattern

When the server replays a cached result, add Idempotency-Replayed: true to the response so the client can tell it was served from cache. Useful for debugging.

How to test it

Before shipping, fire the same key three times in a row. All three should return the same response, and the side effect should happen exactly once. Encode that scenario in an integration test.

Takeaway

Idempotency isn’t “nice to have”, it’s hygiene for critical APIs. After getting burnt by a duplicate-charge incident, I now have a standard implementation. I’d recommend you do the same, because one day it’ll happen to you too, and being ready is cheap.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch