Idempotency keys
The non-idempotent POST requests that wire idempotency accept an optional
Idempotency-Key header. When set, the server or payment provider binds the
first operation to the account-scoped key and prevents a retry from performing
that operation twice. Depending on the endpoint, a completed request replays
the same response and a changed or still-running request fails closed. 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 or repeat browser work (a second session, a second checkout, a second
form submission). With one, the retry returns the original terminal outcome or
an explicit non-dispatching conflict and no duplicate is created.
Which endpoints honour it
The header is honoured on these explicitly wired endpoints:
POST /v1/agent-sessions— agent (chat-style) session creationPOST /v1/agent-sessions/{id}/message— one decompose→execute browser turnPOST /v1/billing/checkout-session— Stripe subscription checkoutPOST /v1/billing/crypto-checkout— crypto checkout (NOWPayments invoice)
Every other endpoint — including POST /v1/sessions, the PATCH/DELETE
surface, the GET surface, and idempotent-by-design POSTs like
/v1/auth/login — ignores the header. Sending it is harmless but has no
dedupe effect; guard those calls separately if they need at-most-once behavior.
Format
The header value is a printable-ASCII string, 1–255 characters, with no
whitespace. The server trims surrounding whitespace, then stores and
matches the trimmed value exactly; a key that is empty, longer than 255
characters, or contains whitespace or non-printable characters is
rejected with a 400. 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/agent-sessions after a timeout should send the same
key on the retry; the next create gets a fresh key. For an agent message,
the key must stay attached to the exact same session, message, ordered
approval list, and explicit BYOK 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
For create-style requests, the server/provider records the operation and a duplicate key replays the original response. Agent message turns use a stronger durable receipt because browser work deliberately continues after an SSE viewer disconnects:
- Validate session ownership and the request, then atomically reserve
(account_id, idempotency_key)before decomposition or dispatch. - Completed exact match → replay the stored terminal status and JSON body.
- Different session/body/BYOK fingerprint → return
409without dispatch. - Still running or terminal outcome unknown → return
409withidempotency_status: "in_progress"; inspect the durable transcript rather than minting a new key and repeating the task. - New key → run once and application-encrypt the terminal response before marking the receipt completed.
A completed replay returns the same status code and body as the original — including generated IDs or a terminal RFC 7807 problem. 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?
Do not do this. Agent-message and crypto-checkout receipts reject a changed
request with 409; Stripe also validates parameters on a reused checkout key.
The legacy agent-session create path replays the existing session. In every
case, mint a new key for a new logical operation.
What happens during a concurrent retry?
Database uniqueness/provider idempotency chooses one create operation. For an
agent turn, the first request owns the durable reservation; an overlapping
retry receives 409 in_progress and never enters the browser runtime. Retry the
same key after the original completes to retrieve its terminal result.
Lifetime
Lifetime is endpoint-specific:
- Crypto checkout keys are enforced by a permanent unique
index on the orders table (
INSERT … ON CONFLICT DO NOTHING, then select-and-replay), so a same-key retry replays the original order no matter how much later it arrives. A 24-hour in-memory cache exists purely as a same-process fast-path; the database is the cross-instance source of truth. - Agent-session keys live in a partial unique index on the session row and replay for as long as the row exists.
- Agent-message receipts live in their own durable table and are deleted only if the owning account/session row is deleted.
- Stripe checkout-session keys are forwarded to Stripe and follow Stripe’s provider-side retention rather than Driftstack’s resource-row lifetime.
Practical upshot: never reuse an idempotency key for a NEW logical
request — mint a fresh UUID per logical operation. An exact retry with a reused
key returns the original cached response instead of creating a new
resource. For agent turns, keep the key until a terminal response is received;
after an in_progress conflict, inspect the transcript before deciding whether
a different task and fresh key are appropriate.
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).
-
Minting a new key after an agent-message timeout. The server may still be finishing the original browser work. Reuse the original key. A completed receipt replays; an
in_progressreceipt refuses to dispatch again.
Implementation notes
- Storage. Create receipts generally live alongside the protected resource. Agent-message terminal bodies instead use a dedicated receipt table and are application-encrypted because they can contain customer/model transcript data.
- TTL enforcement. There is no scheduled key-expiry job and no
effective TTL. Crypto-order keys are backed by a permanent unique
index on the order row — the 24-hour in-memory cache is only a
same-process fast-path, and after a restart (or on another
instance) the database still replays the key. Resource-backed
keys (e.g.
agent_sessions.idempotency_key) live in the partial-unique index for the lifetime of the row. Agent-message receipts follow their owning session row. Stripe checkout is provider-managed. - Replay observability. Where an operation writes an audit-log entry, it is 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.