On this page
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). 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).
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 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:
// 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?
# 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/jobscan 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-Keyper 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: truein our error reference; honourRetry-Afterand back off with jitter for the rest. - A finished request replays with
Idempotent-Replayed: trueand 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.
Sources
- RFC 9110: HTTP Semantics, Section 9.2.2 Idempotent Methods — IETF, 2022
- The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07) — 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.
Related services and guides
More from the blog
All articlesDevelopers
Phone number validation in JavaScript and Node.js
Validate phone numbers in JavaScript with libphonenumber-js: min vs max metadata, E.164, form input, and a live line-type check with runnable Node code.
9 min read
Developers
Phone number validation in Python: phonenumbers, Pydantic and a live check
Validate phone numbers in Python with the phonenumbers library: is_valid vs is_possible, E.164, a Pydantic validator, pytest and a live check with httpx.
7 min read
Developers
Phone and e-mail checks for AI agents: MCP, tool calling and guardrails
How AI agents check phone numbers and e-mail addresses safely: MCP server or function calling, which files an agent should read first, spend caps, and guardrails.
9 min read

