Idempotency keys
POST requests that create resources (sessions, agent sessions,
crypto-invoice orders) accept an optional Idempotency-Key header.
When set, the server stores the first response keyed by (account_id, idempotency_key) and replays the same response on subsequent
requests with the same key — even if the operation is otherwise
non-idempotent. This is the standard
Stripe-pattern
that exists to make network retries safe.
Why this exists
Network requests fail. Sometimes a 502 from the edge means the
request never reached the server; sometimes it means the server
processed the request but the response was lost. Without an idempotency
key, retrying the request after the latter case would mint a duplicate
resource (a second session, a second checkout). With one, the retry
returns the original response and no duplicate is created.
Which endpoints honour it
The header is accepted on create-style endpoints — endpoints where the customer is asking the server to mint a new resource. The current list:
POST /v1/sessions— driver session creationPOST /v1/agent-sessions— agent (chat-style) session creationPOST /v1/billing/checkout-session— Stripe checkout sessionPOST /v1/billing/crypto-orders— NOWPayments crypto invoice
Other endpoints (PATCH, DELETE, the GET surface, and idempotent-by-design
POSTs like /v1/auth/login) accept the header but ignore it. Requests
that mutate-in-place don’t need replay protection — a retry simply
re-applies the same update.
Format
The header value is an arbitrary string. The server stores it as-is and matches it character-for-character. Recommended format:
Idempotency-Key: <UUID-v4 or other globally-unique identifier>
Stripe-pattern best practice: generate a new key per logical
operation (not per retry of the same operation). A client retrying
the same POST /v1/sessions after a timeout should send the same
key on the retry; the next time the customer creates a session, a
fresh key.
Constraints:
- Empty string is treated as absent (so a stray
Idempotency-Key:header from an overeager proxy doesn’t collapse every request to the same phantom-keyed row). - Scope is per-account, not global. Two different customers using the same idempotency-key string see independent results.
Semantics
When the server sees a POST with an Idempotency-Key:
- Look up
(account_id, idempotency_key)in the cache. - Hit → replay the original response status + body, byte-for-byte.
- Miss → execute the operation as usual, then record the
response keyed by
(account_id, idempotency_key)before returning.
A replay returns the same status code (typically 201 Created) and
the same body as the original — including any server-generated IDs.
The client can treat the replay as if the original response had been
received successfully.
What happens if I send the same key with a different body?
The server ignores the new body and replays the cached response. This matches Stripe’s behaviour: the idempotency key is the contract; if the customer wants a different result they need a different key.
(This deliberately favours “duplicate-prevention is more important than parameter-correctness.” If you’re worried about silently returning an old response when your body changed, generate a new key.)
What happens during a concurrent retry?
Two requests with the same (account_id, idempotency_key) race the
underlying INSERT on the resource row. The unique constraint on
idempotency_key ensures exactly one wins; the loser sees the
constraint violation and replays the winner’s cached response.
The window between “cache hit check” and “row insert” is the only
unsafe interval; the constraint check closes it. The customer never
sees a 409 Conflict from this race — the loser falls through to a
clean replay.
Lifetime
Idempotency-key records are retained 24 hours by default. Retries within the window return the cached response; retries after it are treated as a fresh request (and may mint a duplicate resource if you expected the key to still gate it).
The 24-hour window matches Stripe’s policy and is documented per endpoint in the OpenAPI spec.
Examples
TypeScript
import { randomUUID } from 'node:crypto';
async function createAgentSessionWithRetry(
apiKey: string,
body: { token_budget: number },
): Promise<unknown> {
const idempotencyKey = randomUUID();
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch('https://api.driftstack.dev/v1/agent-sessions', {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status >= 500) throw new Error(`5xx, retrying: ${res.status.toString()}`);
throw new Error(`non-retryable: ${res.status.toString()}`);
} catch (err) {
if (attempt === 2) throw err;
await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
}
}
throw new Error('unreachable');
}
Note: the same idempotencyKey is reused across all three attempts.
The first successful response (whether on attempt 1, 2, or 3) is the
only one the server records; subsequent successes are replays.
curl
curl -X POST https://api.driftstack.dev/v1/agent-sessions \
-H "authorization: Bearer ds_live_…" \
-H "content-type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "mode": "ai", "token_budget": 100000 }'
Common mistakes
-
Reusing one key across logically-distinct operations. If your client uses the same key for two different POSTs (e.g. creating two separate sessions for the same customer), the second one returns the first’s response. Generate a fresh key per logical operation.
-
Reusing one key across accounts. Scope is per-account, so this is technically safe — but it confuses your debugging if two customers’ requests end up with the same key in your logs.
-
Treating a replay as a no-op. A replay returns the same body as the original, including the resource ID. If your client logic assumes “I just minted this resource, so the post-conditions hold,” a replay still satisfies that — the resource exists. If your client logic assumes “I just charged the customer,” a replay does NOT re-charge them (it returns the original charge response).
-
Not retrying on idempotency-key 5xx. A 5xx response on an idempotent POST is safe to retry — that’s the whole point of the header. Don’t skip retrying because “the server might have processed it”; if it did, the retry replays; if it didn’t, the retry creates.
Implementation notes
- Storage. Idempotency-key records live alongside the resource
they protected (e.g.
agent_sessions.idempotency_keyis a partial-unique-indexed column on the table itself). There’s no separate idempotency-key table — the resource row IS the cache. - TTL enforcement. There is no scheduled key-expiry job. Behaviour
differs by subsystem: crypto-order keys are held in an in-memory cache
with a 24-hour TTL and pruned lazily — an entry is dropped the next
time the cache is consulted after its cutoff, so a key stops replaying
~24h after first use. Resource-backed keys (e.g.
agent_sessions.idempotency_key) have no TTL: the value lives in the partial-unique index for the lifetime of the row, so those keys replay for as long as the resource exists. - Replay observability. Audit-log entries are written for the first request but NOT the replays. This intentionally mirrors Stripe — the original is the operationally-significant action; the replays are transport noise.