Drıftstack docs
Docs

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 creation
  • POST /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 turn
  • POST /v1/agent-sessions/{id}/steps — a list of steps (the same receipt as a message)
  • POST /v1/support/conversations — a new Help conversation
  • POST /v1/support/assistant/conversations/{id}/messages — a question to the Help assistant (the header is required there)
  • POST /v1/profiles — profile creation
  • POST /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:

  1. Session ownership and the request are validated, then the key is reserved before any work starts.
  2. Completed exact match → the stored status and body are replayed.
  3. Different session, message, or approval list → 409 with idempotency_status: "mismatch"; nothing runs.
  4. Still running or outcome unknown → 409 with idempotency_status: "in_progress"; check the transcript rather than sending a new key and repeating the task.
  5. 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 409 and idempotency_status: "mismatch", without doing any browser work.
  • Crypto checkout does not reject. It replays the original order verbatim with Idempotent-Replayed: 1 and 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 returned order_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_seconds is outside its range: that is a 400, and the first session is not affected.
  • Profile create rejects a changed body with 409 and code: "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_progress turn 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.