# Idempotency keys: how to retry API requests safely

> How to use the Idempotency-Key header to retry POST requests without double-processing: MobileValidate's rules, tested-pattern retry code for Node.js and Python, and which errors are safe to retry.

Canonical: https://mobilevalidate.com/blog/idempotency-keys-for-safe-api-retries · Last updated: 2026-09-30

![A tagged request passes through a server into a 24-hour store; a retry with the same tag comes back marked replayed, while a retry with a changed body is refused with a 409.](https://mobilevalidate.com/images/blog/idempotency-keys-for-safe-api-retries.svg)

*Reuse one key per call so a retry replays the stored answer instead of running it twice.*


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-30 · Category: Developers · Tags: Idempotency, API, Retries, Developer tools, SDK, Error handling

Send an `Idempotency-Key` header — any unique string, 1 to 255 printable ASCII characters — on a `POST` request, and a retry with the same key and the same body within 24 hours returns the original result instead of running again. Retry with the same key and a *different* body, and the request is refused. This guide covers MobileValidate's exact rules, when retrying is safe, and tested-pattern retry code for Node.js and Python.

## Why does POST need this at all?

`GET`, `PUT` and `DELETE` are defined as idempotent in the HTTP specification: sending the same request twice has the same effect as sending it once. `POST` is explicitly not ([RFC 9110 §9.2.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2)). That's a problem for anything that creates something — a lookup, a bulk job, a webhook endpoint — because a network blip after the server processed your request but before you got the response leaves you unable to tell "it failed" from "it worked and the response got lost." Retry blindly and you risk running it twice; don't retry and you risk losing real requests to routine network noise.

The `Idempotency-Key` header pattern used across many APIs solves this by asking the client to name each logical operation once. An IETF working group has been standardizing it as `draft-ietf-httpapi-idempotency-key-header`; it recommends a UUID or similar random identifier as the value and describes the same core behavior MobileValidate implements: a duplicate key with the same payload replays the stored result, a duplicate key with a different payload is an error, and a request retried while the original is still running gets a conflict response rather than running twice ([IETF, 2025](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header)).

## What exactly happens with each key?

Every `POST` endpoint — `/v1/lookup`, `/v1/jobs`, `/v1/webhook_endpoints` and the rest — accepts the header. Four outcomes, depending on what you send and when:

| You send | Server state | Result |
|---|---|---|
| A new key | No prior request with this key | Runs normally, response stored for 24 h |
| Same key, same body | Prior request finished | Stored response replayed, header `Idempotent-Replayed: true`, no re-run |
| Same key, same body | Prior request still running | `409 idempotency_request_in_progress` (retryable) |
| Same key, **different** body | Any state, key not expired | `409 idempotency_key_reused` (not retryable) |

"Same body" means byte-for-byte identical, so don't reformat, re-order keys or change whitespace in the JSON between attempts — send the exact bytes again. If your request genuinely failed with a server error (`5xx`) or the connection dropped before the server finished, the key's claim is dropped: your next attempt with the same key runs as a fresh request, not a replay, because nothing was actually committed. A request stuck "in progress" for more than about two minutes — the sign of a crashed worker rather than a slow one — can also be retried and taken over rather than blocked indefinitely.

None of this is billing-specific plumbing bolted on top; it applies to every `POST` the same way, whether the underlying operation is free (creating a webhook endpoint) or metered (a lookup or bulk job).

## Which errors should you actually retry?

Not every error is worth retrying, and retrying the wrong ones wastes calls or masks bugs. Our [error reference](/docs/errors) marks each error code `retryable: true` or `false`; as of this writing, the retryable ones are `rate_limited`, `daily_cap_reached` (only after the reset), `idempotency_request_in_progress`, `internal_error` and `temporarily_unavailable`. `idempotency_key_reused` is deliberately not retryable — it means your client reused a key incorrectly, and retrying with the same inputs will fail the same way every time. The fix is to generate a new key for that request, not to retry.

Honour the `Retry-After` header when it's present, and back off with jitter for the rest so you don't create a thundering herd against your own rate limit.

## How do you implement this in Node.js?

Generate one key per logical call and reuse it across every attempt for that call — never a fresh key per retry, which would defeat the whole point:

```js
// retry.mjs
import { randomUUID } from "node:crypto";

const RETRYABLE = new Set(["rate_limited", "daily_cap_reached", "idempotency_request_in_progress", "internal_error", "temporarily_unavailable"]);

async function postWithRetry(path, body, { apiKey, maxRetries = 4 }) {
  const idempotencyKey = randomUUID(); // one key for this logical call, reused on every attempt below
  let attempt = 0;
  for (;;) {
    const res = await fetch(`https://api.mobilevalidate.com/v1${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body), // must be byte-identical on every attempt
    });
    if (res.ok) return res.json();

    const { error } = await res.json();
    if (!RETRYABLE.has(error.code) || attempt >= maxRetries) {
      const err = new Error(error.message);
      err.code = error.code;
      throw err;
    }
    const retryAfterMs = Number(res.headers.get("retry-after") ?? 0) * 1000;
    const backoffMs = retryAfterMs || Math.min(30_000, 500 * 2 ** attempt) + Math.random() * 250;
    await new Promise((r) => setTimeout(r, backoffMs));
    attempt++;
  }
}

const result = await postWithRetry("/lookup", { numbers: ["+447700900001"], checks: ["carrier"], wait: 5 }, {
  apiKey: process.env.MOBILEVALIDATE_API_KEY,
});
console.log(result);
```

This is the same shape our official TypeScript SDK uses internally: it creates one key per call with `crypto.randomUUID()`, attaches it to the original attempt and every retry of that attempt, and only retries when the server marked the error retryable. If you use the SDK, you get this for free — `client.lookup(...)` retries safely without any of the above code.

## How do you implement this in Python?

```python
# retry.py
import time
import uuid
import random
import requests

RETRYABLE = {"rate_limited", "daily_cap_reached", "idempotency_request_in_progress", "internal_error", "temporarily_unavailable"}
API = "https://api.mobilevalidate.com/v1"


def post_with_retry(path: str, body: dict, api_key: str, max_retries: int = 4) -> dict:
    idempotency_key = str(uuid.uuid4())  # one key for this logical call, reused on every attempt below
    attempt = 0
    while True:
        r = requests.post(
            f"{API}{path}",
            headers={"Authorization": f"Bearer {api_key}", "Idempotency-Key": idempotency_key},
            json=body,  # requests serializes this the same way on every attempt
            timeout=15,
        )
        if r.ok:
            return r.json()

        error = r.json()["error"]
        if error["code"] not in RETRYABLE or attempt >= max_retries:
            raise RuntimeError(f"{error['code']}: {error['message']}")

        retry_after = float(r.headers.get("Retry-After", 0))
        backoff = retry_after or min(30, 0.5 * 2 ** attempt) + random.uniform(0, 0.25)
        time.sleep(backoff)
        attempt += 1


result = post_with_retry(
    "/lookup",
    {"numbers": ["+447700900001"], "checks": ["carrier"], "wait": 5},
    api_key="mv_test_publicSandboxn9ZgneuhR1B9CRfKG3fulym",
)
print(result)
```

Both examples generate the key once, outside the retry loop, and pass the identical serialized body on every attempt — the two things that make an `Idempotency-Key` actually do its job.

## Where does this matter most?

Two places, in practice:

- **Bulk jobs.** `POST /v1/jobs` can enqueue up to 50,000 identifiers in one request. If your upload code times out waiting for the response but the job was actually created, retrying without an idempotency key risks creating a second job over the same list — and reserving cost against it twice. Send the same key on the retry and you get the original job back instead.
- **Lookups triggered by user actions.** A sign-up form that calls a carrier check on submit, where the user's browser retries on a flaky connection, is exactly the double-submit problem idempotency keys exist for.

It matters less for pure `GET` reads — fetching job status or results is already safe to retry, since nothing is created twice by reading. Reserve the header for the `POST` requests that create or trigger work.

## What are the key takeaways?

- Generate one `Idempotency-Key` per logical operation and reuse it, unchanged, across every retry of that operation — never a new key per attempt.
- Send the exact same request body every time; "same key, different body" is a client error (`idempotency_key_reused`, not retryable), not something to retry around.
- Only automatically retry errors marked `retryable: true` in our [error reference](/docs/errors); honour `Retry-After` and back off with jitter for the rest.
- A finished request replays with `Idempotent-Replayed: true` and no extra charge; a request that actually failed server-side (`5xx`) drops its claim so your retry runs cleanly.
- Keys are remembered for 24 hours per account. Use the official SDK if you'd rather not implement retry logic yourself — see the [SDK docs](/docs/sdk).

## Sources

1. [RFC 9110: HTTP Semantics, Section 9.2.2 Idempotent Methods](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2) — IETF, 2022
2. [The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07)](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header) — IETF HTTPAPI Working Group, 2025

## Frequently asked questions

### What is an Idempotency-Key and when should I send one?

It's a header on a POST request — any unique string, 1 to 255 printable ASCII characters — that lets you retry the exact same request without it running twice. Send one on any POST you might need to retry: a lookup, a bulk job, or a webhook endpoint you're creating.

### What happens if I reuse a key with a different request body?

The request is refused with a 409 idempotency_key_reused error, which is not retryable. An Idempotency-Key must stay paired with one exact request body. Use a new key for each distinct operation, and only reuse a key when you're retrying the identical request.

### Is it safe to retry immediately after a timeout?

Yes, if you send the same Idempotency-Key and body. If the first attempt is still being processed, you'll get a 409 idempotency_request_in_progress, which is retryable — wait briefly and try again. If the first attempt already finished, you'll get the stored response back with an Idempotent-Replayed: true header, not a second charge.

### Do idempotency keys prevent double billing?

For the same logical request, yes: a replayed request returns the original result instead of running (and billing) again. They don't affect our billing model itself — you're only ever charged for conclusive results, and reserve → settle → release still applies to the one request the key represents.

### Does the MobileValidate SDK handle this for me?

Yes. The official TypeScript SDK generates one key per logical call with crypto.randomUUID() and reuses it across its own automatic retries, so calling the SDK's retry logic never double-processes a request. If you retry manually or use another language, generate and reuse the key yourself.

### How long does the server remember an Idempotency-Key?

24 hours, scoped to your account. After that, the same key can be reused for an unrelated request. A request stuck in progress for more than about two minutes (for example after a server crash) can also be retried sooner, and the server will process it as new.
