Why this exists
Every random-generating endpoint mutates state: it advances the seed cursor/nonce, persists an outcome, fires webhooks, and counts against quota. A naive retry after a dropped connection would draw again — different numbers, different shortId, the original draw still recorded. That's almost never what you want when a player's life depends on the next roll.
The Idempotency-Key header tells the server "if you've seen this exact key from me before, replay the original response instead of running the draw a second time." It's accepted on every endpoint that mutates state: /api/floats, /api/ints, /api/shuffle, /api/pick, /api/bytes, /api/dice, /api/gaussian, /api/reveal, and /api/batch.
How the single-flight lock works
For each (account, key) pair the server tracks one of three states:
- In-flight. The first request takes a lock. A second request with the same key while the first is still running waits briefly and then returns the same response the first request produces — you never get two outcomes for one key.
- Cached. After the original request finishes, its full HTTP response (status, body, headers like
shortId) is cached. Any retry with the same key replays it byte-for-byte. No draw happens. - Conflict. If you reuse the same key with a different request body or different query parameters, the server rejects the retry with
409 Conflict. Keys are scoped to the exact request that created them.
Anonymous calls (no API key) don't have an account to scope against — the header is ignored for them. Always send an x-api-key alongside the idempotency key.
Try it — run this twice and notice the response is byte-identical the second time (same shortId, same outcome):
curl "https://api.provable.io/api/ints?clientSeed=idem-demo&count=3&min=1&max=100" \
-H "Idempotency-Key: demo-key-1234"
Choosing a key
- One key per logical draw — a player's round id, an order id, a webhook delivery id. Not per HTTP request.
- Use a high-entropy string (UUID v4 is fine). Don't reuse keys across different draws even months apart.
- Keys are stored for 24 hours by default and then expire. A retry beyond that window will be treated as a fresh request — make sure your application-level retry budget is shorter than 24 hours.
Node.js retry loop
Generate the key once per draw, then reuse it across every attempt. Exponential backoff with jitter keeps you a polite neighbor:
import { randomUUID } from "node:crypto";
async function drawWithRetry({ clientSeed, count, min, max }) {
const key = randomUUID(); // one key per logical draw
const url = new URL("https://api.provable.io/api/ints");
url.searchParams.set("clientSeed", clientSeed);
url.searchParams.set("count", count);
url.searchParams.set("min", min);
url.searchParams.set("max", max);
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url, {
headers: {
"x-api-key": process.env.PROVABLE_API_KEY,
"Idempotency-Key": key,
},
});
if (res.status === 409) throw new Error("idempotency conflict — request body changed");
if (res.status >= 500 || res.status === 429) {
await sleep(jitter(2 ** attempt * 250));
continue;
}
if (!res.ok) throw new Error(`http ${res.status}`);
return await res.json();
} catch (err) {
if (attempt === 4) throw err;
await sleep(jitter(2 ** attempt * 250));
}
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = (ms) => ms / 2 + Math.random() * (ms / 2);
Python retry loop
import os, time, uuid, random
import requests
def draw_with_retry(client_seed, count, min_, max_):
key = str(uuid.uuid4())
params = {"clientSeed": client_seed, "count": count, "min": min_, "max": max_}
headers = {
"x-api-key": os.environ["PROVABLE_API_KEY"],
"Idempotency-Key": key,
}
for attempt in range(5):
try:
res = requests.get(
"https://api.provable.io/api/ints",
params=params, headers=headers, timeout=10,
)
if res.status_code == 409:
raise RuntimeError("idempotency conflict — request body changed")
if res.status_code >= 500 or res.status_code == 429:
time.sleep(_backoff(attempt))
continue
res.raise_for_status()
return res.json()
except requests.RequestException:
if attempt == 4: raise
time.sleep(_backoff(attempt))
def _backoff(attempt):
base = 0.25 * (2 ** attempt)
return base / 2 + random.random() * (base / 2)
Using it with batch
One Idempotency-Key covers a whole POST /api/batch request. A retry with the same key replays the identical results array and does not re-run any draw — even if some draws inside the batch failed the first time, their failures are part of the cached response.
What can go wrong
- 409 Conflict. You reused a key with different parameters. Either send a fresh key for the new request, or recover the original response from your own logs.
- Concurrent retries. The single-flight lock is per
(account, key)— two parallel retries with the same key will both eventually see the same cached response, but only one will actually run the draw. - Forgotten key. If you regenerate the key on every retry attempt, you've defeated the feature. Generate it once, store it on the unit of work that owns the draw, and pass that exact string to every attempt.