Your Customers Retried a Timed-Out Request, and Now They Have Two Orders
A customer's checkout service sends your API a request to create an order. The order is created, but the response never makes it back: a load balancer times out, a mobile connection drops, a pod restarts mid-request. The client sees a timeout, does what any sensible client does, and retries. Now there are two orders, two shipments, and possibly two charges on someone's card. The ticket that follows says your API "creates duplicates," and an engineer on your side spends an afternoon proving that it did exactly what it was asked to do, twice.
Nobody on that customer's team was careless. They hit the one question your documentation never answered: when a write request fails with no response, is it safe to send it again?
That question shows up as support escalations, refund requests, reconciliation jobs, and finance teams who no longer trust the numbers coming out of the integration. For anything that moves money, inventory, or messages, it's the most expensive question an API can leave open.
The timeout is the hard case
Clear failures are easy. A 400 means fix the request; a 201 means it worked. The dangerous case is the request that ends in an unknown state: a connection reset, a gateway timeout, or a 500 returned after the server may or may not have committed the write.
From the client's side, there are only two moves, and both can be wrong:
- Don't retry, and risk losing a payment or order that never happened.
- Retry, and risk doing it twice.
HTTP gives part of the answer. Stripe's documentation puts it plainly: GET and DELETE requests are "idempotent by definition." Repeating them doesn't change the outcome. But most of the writes that matter, such as creating an order, sending a message, or charging a card, are POST requests, and POST makes no such promise. If your API doesn't say how to retry a POST safely, every customer invents their own answer, and some of them get it wrong in production.
What good idempotency documentation looks like
Stripe's idempotent requests documentation is the model most teams point to, and it's worth reading for how much it commits to in a few paragraphs:
- How to send a key: an
Idempotency-Keyheader, with keys up to 255 characters, and a suggestion to use V4 UUIDs. - What a retry returns: for API v1, Stripe saves the status code and body of the first request once endpoint execution begins, and retries with the same key return that saved response, "including
500errors." - How long keys last: keys can be removed automatically once they're at least 24 hours old, and a reused key after that point is treated as a new request.
- What counts as misuse: if a retry's parameters don't match the original request, Stripe returns an error instead of guessing.
- What isn't saved: requests that fail validation, or that conflict with a request still executing, aren't saved, so they can be retried.
None of that is marketing. Each line is a behavior an integrating engineer needs to know in order to write correct retry code.
There's also standards work in this area. The IETF's httpapi working group has an Internet-Draft, "The Idempotency-Key HTTP Header Field," which proposes specific responses: 400 when a required key is missing, 422 when a key is reused with a different payload, and 409 when a retry arrives while the original request is still in progress. It's still a draft, not an RFC, so don't claim compliance with it. It is a useful checklist of the cases your docs should cover, whatever your API actually returns.
Seven questions your docs must answer
If your API accepts writes that customers will retry, and every API does, your documentation needs a dedicated page that answers these questions:
- Which operations are safe to retry as-is? List them. Don't make developers infer it from HTTP method names.
- How does a client make a write safe to retry? Name the header or field, the key format, and who generates the key.
- What is the key's scope? Per account, per endpoint, per API key? A key that's unique per endpoint behaves differently from one that's unique per account.
- How long is a key remembered? After that window, a "retry" is a brand-new request. Say so.
- What does a retry return? The original response, a fresh response, or an error? What happens if the original request failed?
- What happens on misuse? The same key with a different body, or a retry while the first request is still running.
- What should a client do when the state is unknown? Retry with the same key, or look the object up by a client-supplied reference before trying again.
If you don't support idempotency keys yet, the page is even more important. Tell developers which writes are risky to retry and how to check whether a write succeeded. A client-supplied reference ID that the API stores and lets them search by is often the cheapest fix you can ship.
Show the retry, not just the header
Most idempotency docs show a single curl command with a header in it. That's the easy part. The part developers get wrong is the retry loop, and specifically reusing the same key on every attempt:
key = str(uuid.uuid4()) # generate once per logical operation
for attempt in range(5):
try:
return client.create_order(order, idempotency_key=key)
except (Timeout, ConnectionError, ServerError):
sleep(backoff_with_jitter(attempt))
raise OrderStateUnknown(key)
The bug I'd expect to see most often is generating the key inside the loop, which quietly turns every retry into a new order. Your example should make the correct placement obvious. Pair it with the backoff guidance in your rate-limit docs; I covered why synchronized retries make overloads worse in Your Customers Find Your Rate Limits the Day Their Traffic Spikes.
If you ship SDKs, document whether they retry automatically and whether they attach keys for you. A developer who adds their own retry loop on top of an SDK that already retries can multiply the attempts without realizing it.
Audit your retry story this week
You can find most of the gaps without hiring anyone:
- Search support tickets for "duplicate," "charged twice," "double," and "timeout." Each one is a retry your docs didn't prepare someone for.
- Ask your engineers what actually happens when the same
POSTarrives twice, three seconds apart. If the answer is "it depends on the endpoint," that dependency belongs in the docs. - Test it. Send a write, drop the connection before reading the response, and retry. Then try it with the same key and a changed body. Write down what you see.
- Read your own SDKs for retry behavior and key handling, and confirm the docs match.
Why this takes an engineer
Idempotency documentation can't be written from the spec. Someone has to read the server code, find out where the key is stored and when the response is saved, deliberately break requests mid-flight, and then explain the result to a developer who just needs their checkout to stop double-charging. That's investigative engineering work first and writing second, and it's the kind of work that decades of hands-on development prepare you for.
If your customers are reconciling duplicate orders or charges, I can help. I'll test how your API handles retries, document what's safe and what isn't, and write the retry guide and examples your developers need before the next timeout. You can reach me through the contact page.