> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency

> Send Idempotency-Key on a designated POST and a retry returns the original response instead of applying the operation twice.

**A retry is only safe if the server can tell it apart from a second request. `Idempotency-Key` is how you tell it.**

Send the header on a designated POST and Exo records that request's response for 24 hours. Retry with the same key and you get the stored response back verbatim, without the route running again.

## The shape of it

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/ingest \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "Idempotency-Key: 4f7b1c2e-8a90-4d3f-b6e1-0c5a7d2f9b31" \
    -H "Content-Type: application/json" \
    -d '{"content": "We dropped the freemium tier. Too much support load."}'
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import uuid
  import httpx

  key = str(uuid.uuid4())  # one key per logical operation, reused on retries

  response = httpx.post(
      "https://api.get-exo.com/v1/ingest",
      headers={"X-Exo-API-Key": EXO_KEY, "Idempotency-Key": key},
      json={"content": "We dropped the freemium tier. Too much support load."},
  )
  print(response.status_code, response.json()["jobId"])
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const key = crypto.randomUUID(); // one key per logical operation

  const response = await fetch("https://api.get-exo.com/v1/ingest", {
    method: "POST",
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      "Idempotency-Key": key,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      content: "We dropped the freemium tier. Too much support load.",
    }),
  });
  const { jobId } = await response.json();
  ```
</CodeGroup>

Without the header there is no idempotency handling at all, and a retry after a client timeout produces a second job and a second copy of the content.

## Which routes replay

| Route                                                                                | Replays                                                |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| [`POST /v1/ingest`](/api-reference/ingest/post-ingest)                               | the original 202 and job id                            |
| [`POST /v1/ingest/batch`](/api-reference/ingest/ingest-batch)                        | the original 202 and job ids                           |
| [`POST /v1/exports`](/api-reference/exports/create-export)                           | the original 202 and export id                         |
| [`POST /v1/keys`](/api-reference/keys/create-key)                                    | the original 201, **with the plaintext redacted**      |
| [`POST /v1/webhooks`](/api-reference/events/create-webhook)                          | the original 201, **with the signing secret redacted** |
| [`POST /v1/graph/edges`](/api-reference/graph/assert-edge)                           | the original 201                                       |
| [`POST /v1/graph/nodes/{node_id}/supersede`](/api-reference/graph/supersede-node)    | the original 200                                       |
| [`POST /v1/graph/nodes/{node_id}/undelete`](/api-reference/graph/undelete-node)      | the original 200                                       |
| [`POST /v1/proposals/{proposal_id}/accept`](/api-reference/insights/accept-proposal) | the original 200 decision                              |
| [`POST /v1/proposals/{proposal_id}/reject`](/api-reference/insights/reject-proposal) | the original 200 decision                              |
| [`POST /v1/settings/reset`](/api-reference/settings/reset-settings)                  | the original 200                                       |
| [`POST /v1/sandbox/reset`](/api-reference/keys/reset-sandbox)                        | the original 200 counts                                |

Idempotency is a POST-only mechanism. Sending the header on a `GET`, `PUT`, `PATCH` or `DELETE` has no effect.

## Which routes deliberately do not

| Route                                                                                                                                                       | Why not                                                                                                                                               |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`POST /v1/sandbox/keys`](/api-reference/keys/create-sandbox-key)                                                                                           | Replay would mean persisting a live credential in the replay store for 24 hours. Minting a fresh key is the better trade, so a retry mints a new one. |
| [`POST /v1/purge`](/api-reference/admin/purge-data)                                                                                                         | Each confirm consumes its token. A second destructive act must be a second deliberate act, so the header cannot make one repeatable.                  |
| [`POST /v1/import`](/api-reference/ingest/post-import), [`POST /v1/import/transcript`](/api-reference/ingest/post-import-transcript)                        | These are multipart, and a multipart body does not fingerprint reliably across client attempts because the boundary differs.                          |
| [`POST /v1/ingest/session-event`](/api-reference/sessions/post-ingest-session-event), [`/session-batch`](/api-reference/sessions/post-ingest-session-batch) | Deduplication happens per event, which fits a transcript better than per request. A resent event is reported as `deduped`.                            |

## Show-once secrets are redacted on replay

Two routes return a credential exactly once. Persisting it in the replay store would undo the reason those credentials are hashed or encrypted at rest, so the stored copy carries a marker instead of the real value.

* [`POST /v1/keys`](/api-reference/keys/create-key) replays with `apiKey` replaced.
* [`POST /v1/webhooks`](/api-reference/events/create-webhook) replays with `secret` replaced.

A replay still proves the resource was created exactly once, which is what you retried to find out. The plaintext was only ever on the original response, and this is why you store it before closing the connection.

## What counts as the same request

The stored identity is derived from four things:

1. the `Idempotency-Key` you sent,
2. your API key,
3. the route,
4. the **effective subject**.

Two consequences follow. Two different callers can use the same key string without colliding. And the same key under two different `X-Exo-Subject` values is two distinct identities, so one subject's response can never replay to another.

Alongside that identity, Exo fingerprints the request body. **A key is bound to the body it was first used with.**

## The three outcomes

| Situation                                   | Result                                                                          |
| ------------------------------------------- | ------------------------------------------------------------------------------- |
| First use of the key                        | The route runs. Its response is stored for 24 hours.                            |
| Same key, same body, original finished      | The stored response is returned verbatim. The route does not run.               |
| Same key, same body, original still running | 409 [`idempotency_in_flight`](/errors/idempotency_in_flight). Wait, then retry. |
| Same key, **different** body                | 422 [`idempotency_key_reuse`](/errors/idempotency_key_reuse). Use a new key.    |

The mismatch check runs before the in-flight check, so reusing a key for different content fails the same way whether or not the first request has finished.

## Failures release the key

A claimed key is always released when the request did not produce a stored response. Any failure after the claim frees it: a 429, a validation failure, a problem response, or a crash. Responses that failed with 400 or 422 are never persisted for replay.

That means **a corrected retry can reuse the same key**. Fix the body, send it again with the same `Idempotency-Key`, and it will be treated as a fresh attempt rather than refused as a mismatch.

If the idempotency store itself is unreachable, the request fails **closed** with 503 [`service_degraded`](/errors/service_degraded) rather than proceeding without the claim. Proceeding could double-apply a side effect, which is exactly what the key exists to prevent. Retry with the same key.

## Choosing keys

* One key per logical operation. A UUID is the usual choice.
* Reuse it for every retry of that operation, including after a timeout where you never saw a response.
* Never reuse it for different content. That is what `idempotency_key_reuse` catches.
* Keys expire after 24 hours. A retry a day later is a new request.

## Next

<Columns cols={2}>
  <Card title="Rate limits" icon="gauge" href="/platform/rate-limits">
    Honouring Retry-After, and why the same key belongs on the retry.
  </Card>

  <Card title="Ingesting content" icon="upload" href="/guides/ingesting">
    Where idempotency matters most, and how to read the job result.
  </Card>
</Columns>
