Ch. 19 · System Design

Designing Long-Running APIs: 202 Accepted and Idempotency Keys

How an export API uses 202 Accepted, an operation resource and an idempotency key so a client that lost a response can recover safely.

~7 min readbeginnerupdated Oct 11, 2026

A mobile app calls POST /v1/exports to build a CSV of last month’s transactions. Thirty seconds later the connection drops. The user taps “Try again”. Did the first request create an export? Will the second one create another? Is the server still working on the first?

Most API designs are drawn for the happy path, where every request gets a response. The interesting part of an API contract is what it promises when the response never arrives. A good contract gives the client a way to turn “I don’t know what happened” into a definite answer without guessing.

Success, failure and the third outcome

When you call a remote API, you get one of three results, and a client has to handle each differently:

What the client sees What it means What the client should do
201, 202, 200 with a body The server accepted or finished the work Use the result or follow the operation
400, 404, 409, 422 The server understood and refused Fix the request; repeating it changes nothing
Timeout, connection reset, 502, 503, 504 Unknown: it may or may not have run Find out, or retry in a way that can’t double up

The third row is the one people forget. A timeout doesn’t mean the request failed. It means the client stopped waiting. The server may have committed the export a millisecond before the load balancer cut the connection. Treating that as a failure and resending a plain POST is how users end up with two exports, two orders or two charges.

So the contract needs two things: a way for the client to ask “what happened to my request?”, and a way to resend the request that the server recognises as a repeat.

Modelling the export as a resource

Building a big CSV can take minutes, which is longer than any sensible HTTP timeout. Instead of holding the connection open, the server records the request, returns straight away, and gives the client something to follow. The request itself becomes a resource:

POST /v1/exports HTTP/1.1
Idempotency-Key: 5f0c2a9e-0d1b-4a5e-9a51-3c1f6e2f8a10
Content-Type: application/json

{"format": "csv", "range": "2026-09"}
http
HTTP/1.1 202 Accepted
Location: /v1/exports/exp_7Qm2
Retry-After: 5

{"id": "exp_7Qm2", "status": "pending", "createdAt": "2026-10-11T09:14:03Z"}
http

202 Accepted says exactly what happened: the server took responsibility for the work, and it hasn’t finished. That’s different from 201 Created, which says the thing exists now. The Location header points at the operation, and Retry-After tells the client how long to wait before checking.

The client then polls the operation:

GET /v1/exports/exp_7Qm2 HTTP/1.1

HTTP/1.1 200 OK
{"id": "exp_7Qm2", "status": "succeeded", "resultUrl": "https://files.example.com/exp_7Qm2.csv", "expiresAt": "2026-10-12T09:16:40Z"}
http

The operation moves through a small state machine: pending, then running, then either succeeded or failed. Write those states into the contract. A client that sees failed should get a reason it can act on, such as {"code": "range_too_large", "retryable": false}, not just a red flag. A failed export is also a successful API call: the GET returns 200 because the client asked for the operation’s state and got it. Returning 500 here would tell the client that the status endpoint is broken, which isn’t true.

A common misconception in interviews is that 202 plus a status URL is enough. It isn’t. It handles “the work takes a long time”, but it doesn’t handle “the 202 itself got lost”. If the response to the POST disappears, the client never learns the operation id, so it has nothing to poll. That’s the gap the idempotency key fills.

Idempotency keys: making the retry recognisable

The client generates a random key once per logical request (one tap of “Export”) and sends the same key on every retry of that request. The server stores the key alongside the response it returned. When the same key shows up again, the server doesn’t create a new export; it returns the stored 202 with the original id. The lost response is recovered by asking again.

The server-side logic is short. Here’s the core of it in Node, with an in-memory map standing in for a database table that has a unique constraint on the key:

createExport(body, idemKey) {
  const fingerprint = createHash('sha256').update(JSON.stringify(body)).digest('hex');
  if (idemKey) {
    const seen = keys.get(idemKey);
    if (seen) {
      if (seen.fingerprint !== fingerprint) return { status: 422, body: { code: 'idempotency_key_reused' } };
      return seen.response;                       // replay: same id, same status code
    }
  }
  const id = `exp_${++seq}`;
  jobs.set(id, { id, status: 'pending', body });
  const response = { status: 202, headers: { Location: `/v1/exports/${id}`, 'Retry-After': '5' }, body: { id, status: 'pending' } };
  if (idemKey) keys.set(idemKey, { fingerprint, response });
  return response;
}
JavaScript

The fingerprint matters. If a buggy client reuses a key for a different body (CSV last time, JSON this time), silently replaying the old response would hand it the wrong export. Rejecting the mismatch with a 422 makes the bug visible.

A small simulation shows the effect. 10,000 users each request one export over a network that loses 5% of requests on the way in and 5% of responses on the way back. Clients retry up to four times. The only difference between the two runs is whether the client sends a key:

no key, blind retries   jobs=10536  users with duplicate exports=511  clients that gave up=0
with Idempotency-Key    jobs=10000  users with duplicate exports=0  clients that gave up=0
Text

Without a key, 511 users (about 5.1%) got duplicate exports, close to the response-loss rate: every lost response that the client retried created a second job. With a key, every user got exactly one. No client exhausted its four attempts in this run, but one that did could still recover later by retrying with the same key. A quick check of the mismatch rule printed 202 exp_1 422 idempotency_key_reused: the replay returned the same id, and a different body with the same key was refused.

A few details turn this from a sketch into a contract:

  • Scope the key to the authenticated caller, so one customer’s key can never match another’s.
  • Keep keys long enough to cover realistic retries. 24 hours is common for payments; an export might need less.
  • Handle the in-flight duplicate. If the second request arrives while the first is still being processed, return 409 Conflict with a “request in progress” code, or wait briefly for the first to finish. Don’t run both.
  • Generate the key before the first attempt and keep it through the retries. A key generated per attempt is just a random header that protects nothing.

Polling cost and Retry-After

Polling sounds wasteful, and it can be. Suppose 50,000 exports are in flight during a month-end rush. If every client polls once a second, that’s 50,000 GET requests per second against the status endpoint. With Retry-After: 5, it drops to 10,000, and the status endpoint is a cheap single-row read that caches well.

Webhooks or push notifications can tell the client when the export finishes, and they’re a good addition. They don’t replace the status resource, though: a webhook can fail to deliver, and a client that was offline needs to catch up. Polling with backoff is the recovery path that always works.

Errors a client can act on

Two habits make errors usable. First, use status codes for their meaning: 4xx means “your request is the problem”, 5xx means “we are”. A validation error returned as 500 makes well-behaved clients retry something that will never succeed. A failure returned as 200 {"error": ...} hides from every monitoring tool that counts status codes.

Second, give each error a stable code in the body, such as the Problem Details format from RFC 9457 (application/problem+json). Messages change with every copy edit; codes like range_too_large or export_quota_exceeded don’t, and clients can branch on them.

Interview practice: retryable order creation

“Mobile clients on poor networks sometimes see a timeout when placing an order, then tap the button again. Support reports duplicate orders. Design the API contract that fixes this.”

Answer

The root cause is that the client can’t tell a timed-out request that succeeded from one that never arrived, and the server treats every POST as new. I’d make three changes.

First, the client generates an idempotency key when the user taps “Place order” and sends it as a header on that request and every retry of it. The server stores the key, scoped to the user, with a hash of the request body and the response it returned, in the same transaction that creates the order (a unique constraint on the key catches concurrent duplicates). A repeat with the same key and body returns the original response; a different body gets a 422.

Second, the response includes the order id and a Location header for GET /v1/orders/{id}, so even a client that crashed can look up recent orders and reconcile.

Third, the error contract separates “fix your request” (4xx with codes like item_out_of_stock) from “try again” (503 with Retry-After), and the client only retries the second kind, with backoff and the same key. I’d test it by dropping responses after the commit and checking that the order count stays at one per tap.

Further reading

More in System Design

read ✓System Design · hard

System Design: Idempotent APIs

Design a payments endpoint that survives client retries: idempotency keys, request fingerprints, in-progress states and key expiry.

~6 min readread →
esc