Idempotency keys
The POST requests listed below 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 network 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 outcome or an explicit
conflict that runs nothing, and no duplicate is created.
Which endpoints honour it
The header is honoured on these endpoints:
POST /v1/agent-sessions— agent (chat-style) session creationPOST /v1/agent-sessions/{id}/reopen— a new agent session with an ended one’s configuration (keys are shared with the create)POST /v1/agent-sessions/{id}/message— one browser turnPOST /v1/agent-sessions/{id}/steps— a list of steps (the same receipt as a message)POST /v1/support/conversations— a new Help conversationPOST /v1/support/assistant/conversations/{id}/messages— a question to the Help assistant (the header is required there)POST /v1/profiles— profile creationPOST /v1/account/me/proxies— saving a proxy
So do the dashboard’s two checkout requests and the desktop app’s live
input. Every other endpoint — including POST /v1/sessions, the
PATCH/DELETE surface and the GET surface — ignores the header. Sending it is harmless but has no
dedupe effect; guard those calls separately if they need at-most-once behavior.
One case where sending a key can fail a request that omitting it would not
POST /v1/agent-sessions/{id}/message stores its receipt encrypted, which
is not available on every deployment. Where it is not, that endpoint
answers a valid Idempotency-Key with 503 feature-unavailable — “We could
not safely record this request, so nothing ran. This deployment cannot record an
Idempotency-Key at all, so sending the same key again fails the same way; the
same message without the header runs the turn, at the cost of its replay
protection.” — while the same request WITHOUT the header runs the turn
normally.
That is deliberate: a browser turn is expensive and side-effecting, and
the server would rather refuse than accept a key it cannot honour and let
you believe a retry is safe. It is worth knowing because it inverts the
usual advice — on that one endpoint, in that one deployment state, the
header is the reason the call fails. A 503 here means “your retry
protection is not available”, not “the turn failed”; no turn ran.
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 longer than 255 characters, or
one containing whitespace or non-printable characters, is rejected with a
400.
An empty or whitespace-only header is treated as absent, not
rejected: the request is processed normally, without idempotency
protection and without an error. Send a real key or omit the header —
an empty one silently gives you neither deduplication nor a 400 to
tell you so. 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, and ordered
approval list. Your BYOK key and the session’s AI/manual mode are not part
of the key’s identity — changing them does not change how a replay is
matched.
Constraints:
- Empty string is treated as absent (so a stray empty header from a proxy doesn’t make every request look like the same operation).
- 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:
- Session ownership and the request are validated, then the key is reserved before any work starts.
- Completed exact match → the stored status and body are replayed.
- Different session, message, or approval list →
409withidempotency_status: "mismatch"; nothing runs. - Still running or outcome unknown →
409withidempotency_status: "in_progress"; check the transcript rather than sending a new key and repeating the task. - New key → the turn runs once and its result is stored encrypted.
A completed replay returns the same status code and body as the original — including generated IDs or a terminal RFC 9457 problem. The client can treat the replay as if the original response had been received successfully.
In rare cases the server could not keep an agent message’s or a list of
steps’ response: it could not be checked for secrets in time, and Driftstack
never stores what it has not checked. A retry with the same key then answers
409 conflict with code: "response_not_kept". That answer is final for the
key, and the original request is not run again; check the session’s
transcript for its result before sending anything new under a new key.
A session create refused for having no proxy_id (422 proxy-required)
records nothing under its key: no session exists to replay. Resending it
unchanged is refused the same way; add the proxy_id of one of your saved
proxies and the same key creates the session. A create that succeeded under
a key before proxy_id became required still replays its
original response like any other.
Profile create
A POST /v1/profiles sent again with the same key and the same body returns
the profile the first request created (200), so a double click or a retry
after a lost reply never meets the 409 for a name already in use. Only a
created profile is recorded under the key: a create refused for its name or the
plan’s profile limit records nothing, and the same key creates the profile once
the cause is fixed.
If the profile the key created has since been moved to the Trash or deleted,
the same key answers 409 conflict with code: "profile_from_key_gone",
profile_id and profile_state, and nothing is created again. trashed means
it is in your Trash, which is also where a transfer to another account leaves
it; deleted means it has left the Trash. If you deleted it, restore the
trashed profile from the Trash; otherwise send a new key to create a new one.
Two creates with the same key sent at once create one profile: the second waits
for the first and returns its profile. If it cannot yet, it answers 409 with
idempotency_status: "in_progress"; send it again with the same key.
An agent-session create refused because no machine could take a new session
(503 feature-unavailable, code: "dispatch_no_live_node", with Retry-After)
also records nothing under its key: wait the Retry-After seconds and send the
same request with the same key, which creates the session once a device is
free.
For an agent message turn, a refusal raised before the turn did any work gives the key back instead of storing it: nothing ran, so there is nothing a retry could repeat, and the same key runs the turn once the cause is gone. These are the refusals that free their key:
| Status | type |
The refusal |
|---|---|---|
| 409 | conflict |
turn_in_progress: true — another message is still running on this session |
| 429 | rate-limited |
your account already has the most AI messages it may run at once, across your sessions or on Driftstack’s included AI |
| 402 | bundled-llm-consent-required |
the account has not opted in to Driftstack’s included AI |
| 402 | bundled-llm-budget-exhausted |
this month’s included-AI budget is used up |
| 403 | forbidden |
the plan does not include the included AI, or the session’s model runs only on your own Anthropic key (requires_own_key) |
| 502 | byok-anthropic-required |
no Anthropic key was available for the turn — the case without key_rejected: true |
Fix the cause or wait, then send the same request again with the same key.
While the first request is still being resolved you may get 409 with
idempotency_status: "in_progress"; that is safe to retry with the same key
too.
Every other answer is final for its key, and sending the same key again
replays it. That includes every completed turn, every failure after the turn
started, 502 byok-anthropic-required with key_rejected: true (Anthropic
refused your key on the first planning call), 500 internal, 503
feature-unavailable with code: "ai_provider_unavailable" (the AI that
comes with your plan was temporarily unavailable), 200 with
kind: "refuse" (including “the AI is briefly unavailable”), and the 409
for a session that is closed or paused (session_status) or whose control
changed (ai_control_unavailable). To send one of those again, fix the cause
and use a new key.
That completed result remains authoritative if the session later closes,
its mode changes between AI and manual, or your BYOK key rotates. Reusing
the same key after any of those changes replays the original result and never
starts another provider request or browser operation. A manual-mode turn
never reads the BYOK header. Use a new Idempotency-Key only for an
intentionally new AI turn with new browser work.
What happens if I send the same key with a different body?
Do not do this. What happens depends on the surface:
- Agent message turns reject a changed request with
409andidempotency_status: "mismatch", without doing any browser work. - Crypto checkout does not reject. It replays the original order
verbatim with
Idempotent-Replayed: 1and records the key reuse for support. So a changed body returns you the first order — not the one you just asked for. Check that header, or the returnedorder_id, before treating a checkout response as the order you requested. - Agent-session create likewise replays the existing session, unless the
retry’s
idle_timeout_secondsis outside its range: that is a400, and the first session is not affected. - Profile create rejects a changed body with
409andcode: "idempotency_key_reused"; nothing is created.
Stripe also validates parameters on a reused checkout key. In every case, mint a new key for a new logical operation.
What happens during a concurrent retry?
Only one of the concurrent requests performs the operation. For an agent
turn, the first request wins; an overlapping retry receives 409 in_progress
and does no browser work. Retry the same key after the original completes to
retrieve its result.
A
plain (non-streamed) POST /v1/agent-sessions/{id}/steps whose steps run past
about 50 seconds is answered 202 with kind: "still-running" while the
steps go on (see Run a list of steps). If that call
carried a key, a same-key retry sent while the steps still run is answered
the same 202 (without results_so_far) instead of 409 in_progress, and
once they have ended it replays the stored result. A 409 in_progress after
such a 202 means the result is not available (for example the server
restarted): check the transcript, and do not send the steps again under a new
key.
Lifetime
Lifetime is endpoint-specific:
- Crypto checkout keys never expire: a same-key retry replays the original order no matter how much later it arrives.
- Agent-session keys replay for as long as the session exists.
- Agent-message and steps keys replay for 72 hours after the turn finished; then the stored answer is deleted and the same key runs as a new turn. A turn that is still running is never forgotten.
- Saved-proxy keys replay for as long as the proxy they created exists.
- Profile-create keys are kept for 30 days. Inside that time, once their
profile is in the Trash or deleted, they answer
409 profile_from_key_gone. After it the key is forgotten and counts as a new one. - Help conversation keys replay for as long as the conversation exists.
- Help assistant message keys replay for as long as the conversation is kept; a conversation idle for 90 days is deleted with its keys.
- Stripe checkout-session keys are forwarded to Stripe and follow Stripe’s own retention rules.
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 final response is received;
after an in_progress conflict, check 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,
// proxy_id: the saved proxy the session goes out through (required).
body: { proxy_id: string; 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", "proxy_id": "<proxy id>", "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 turn replays; an
in_progressturn refuses to run again.
Notes
- Some keys are forgotten on a timer: profile-create keys after 30 days and the desktop app’s live-input keys after 60 seconds; agent-message and steps keys 72 hours after the turn finished, and Help assistant message keys with a conversation idle for 90 days. How long each endpoint keeps a key is described under Lifetime above.
- Where an operation writes an audit-log entry, it is written for the first request but NOT the replays.