You send a billed request. The connection drops. Did the server get it? Did it run? Did it charge you? From the client there is no way to tell, and the two obvious choices are both bad: do not retry and you lose the answer, retry and you may pay twice.
An idempotency key removes the dilemma. This post walks through what Onekey does with the Idempotency-Key header, using real calls made on 2026-09-30, and then gives you a retry loop that uses it.
The contract#
Send a UUID in the Idempotency-Key header on any billed data call. The rules:
- The key is claimed before anything is billed, and is scoped to your account.
- If the request completes with
200, the response is stored under that key for 24 hours. - The same request with the same key returns the stored response with
credits_charged: 0. - A request that fails (any status other than
200) releases the key, so the retry runs for real.
Here is the first call. The balance was 503.658 credits before it:
KEY=$(python -c "import uuid; print(uuid.uuid4())")
curl -si "https://api.onekey.dev/v1/github/repo?owner=torvalds&repo=linux" \
-H "x-api-key: $ONEKEY_KEY" -H "Idempotency-Key: $KEY" | grep -iE "^HTTP|x-cache|x-credits-remaining|x-idempotent"
# HTTP/1.1 200 OK
# x-cache: MISS
# x-credits-remaining: 502.658It cost 1 credit. (If someone had fetched that repository a moment earlier, the first call would itself be a cache hit and free; the replay behaves the same either way.) Now the identical request, same key:
curl -si "https://api.onekey.dev/v1/github/repo?owner=torvalds&repo=linux" \
-H "x-api-key: $ONEKEY_KEY" -H "Idempotency-Key: $KEY" | grep -iE "^HTTP|x-cache|x-credits-remaining|x-idempotent"
# HTTP/1.1 200 OK
# x-cache: HIT
# x-idempotent-replay: trueNo x-credits-remaining, because nothing was charged so the balance was not re-read. In the body, the replay is marked:
"metadata": {
"platform": "github", "endpoint": "repo",
"cached": false, "age_s": 0.008, "fetched_at": "2026-09-30T06:37:50.556181+00:00",
"credits_charged": 0, "request_id": "8221d10754114f4c9516a47768209226",
"idempotent_replay": true
}Two details matter. credits_charged is 0 and idempotent_replay is true. And fetched_at and age_s are the original call's, so they describe the answer you first got, not the moment of the replay. The request_id is new, so a replay can be traced on its own.
Every outcome#
| Situation | Answer | Charged |
|---|---|---|
| First call succeeds | 200, stored for 24 hours | The endpoint price (or free if it was a cache hit) |
| Same request, same key | 200 replay, credits_charged: 0 | Nothing |
| First call still running | 409 IDEMPOTENCY_IN_PROGRESS with Retry-After: 1 | Nothing |
| Different request, same key | 422 IDEMPOTENCY_KEY_REUSED | Nothing |
| Key is not a UUID | 400 IDEMPOTENCY_KEY_INVALID | Nothing |
| First call failed | The failure, and the key is released | Nothing |
The two rows people ask about most:
Reuse is refused, not guessed at. I sent the same key with repo=other instead of repo=linux. The answer was 422, with error.type IDEMPOTENCY_KEY_REUSED. The server compares the request, so a key can never make a different call return the wrong stored body.
A malformed key is refused too. Idempotency-Key: not-a-uuid returns 400 IDEMPOTENCY_KEY_INVALID. Neither costs anything.
A few edge cases worth designing around:
- Responses over 64 KB are returned normally the first time but not stored, so a replay answers
409 IDEMPOTENCY_REPLAY_UNAVAILABLErather than silently billing again. Keep large list results on your side. - The key is claimed atomically. Two identical requests sent at the same instant cannot both be billed: one runs and the other gets
409 IDEMPOTENCY_IN_PROGRESS. - If the store cannot be reached, you get
503 IDEMPOTENCY_STORE_UNAVAILABLE, refused before billing. Refusing is safer than risking a double charge. - A request that dies midway holds its key for at most 5 minutes.
- A replay still counts against your rate limit.
A retry loop that uses it#
The one rule for clients: generate the key once per logical request, and reuse it across every retry of that request. A new key per attempt defeats the whole thing.
import os, time, uuid, requests
API = "https://api.onekey.dev"
def get_with_retry(path, params, tries=4):
key = str(uuid.uuid4()) # once, outside the loop
for attempt in range(tries):
try:
r = requests.get(API + path, params=params, timeout=30,
headers={"x-api-key": os.environ["ONEKEY_KEY"], "Idempotency-Key": key})
except requests.RequestException:
time.sleep(2 ** attempt) # dropped connection: same key, try again
continue
if r.status_code in (409, 429, 503):
time.sleep(int(r.headers.get("Retry-After", 1)))
continue
if r.status_code in (500, 502):
time.sleep(2 ** attempt)
continue
return r.json() # 200, or an error that retrying will not fix
raise RuntimeError("gave up")
body = get_with_retry("/v1/github/repo", {"owner": "torvalds", "repo": "linux"})
print(body["metadata"]["credits_charged"], body["metadata"].get("idempotent_replay"))Read it as three cases. A dropped connection or a 5xx means "I do not know", so retry with the same key. A 409, 429 or 503 carries a Retry-After, so wait exactly that long. Everything else, including 404 and 422, will fail the same way again, so return it. If the first attempt did succeed, the retry is a free replay. If it did not, the key was released and the retry runs for real.
What it costs#
Nothing extra. The first success costs the endpoint price as usual; every replay within 24 hours is free; the 409, 422, 400 and 503 answers are never charged. The same header works on batch requests, where the key is bound to the exact body bytes, so reformatting the JSON makes it a different request.
The reference is the idempotency page. If you retry on rate limits, read rate limits and headers too. The terms are in the glossary: idempotency key and idempotent replay.