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:

  1. 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.
  2. 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.
  3. 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

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

Next steps