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

# Idempotency

> Making retries safe: the event id, decide's idempotency_key, exactly what a replay returns, recommended key formats, and a retry matrix by status.

Retries are how networks are survived, and a retry that is not idempotent double-counts a purchase or creates a second decision. Both write endpoints give you a key that makes a retry safe.

## Events: `id`

Give each event a stable `id`. A repeated `id` is deduplicated and answered as a success with `duplicate: true`; in a batch, the item is `{ "status": "ok", "duplicate": true }` and the envelope's `duplicates` counts them. Ids are unique per organization and environment. Events without an `id` are delivered at least once.

A good `id` is derived from something stable on your side, so the same fact always produces the same id: `<entity id>_<event name>`, such as `ord_8f3a_purchased` or `inv_2201_refunded`. The server SDK mints `sdk-js:<uuid>` once per `track()` call and keeps it across every retry.

## Decide: `idempotency_key`

A decision is a write: it records which arm the user saw, and a second one ends the first's observation window. So a retried `decide` must not create a second decision. Send an `idempotency_key` generated before the first attempt and reused on every retry:

```json theme={null}
{ "element": "welcome_email_subject", "user_id": "user_123", "idempotency_key": "9c1d2e3f-0b7a-4c2d-8e1f-3a4b5c6d7e8f" }
```

* An opaque string of 1 to 255 characters; a UUID generated per logical request is the recommended shape. It may not contain U+0000 or an unpaired surrogate.
* Keys are scoped to the organization and environment: a test retry never replays a live decision.
* In a batch, the key is per item, and the envelope's `replayed` counts the replayed items.

| Retry | Result |
| - | - |
| Same key, same request, original committed | `200`: the original response verbatim, `decision_id` included, plus `replayed: true`. Nothing new is written. |
| Same key, same request, original never committed | A normal decision; the key is claimed by it. |
| Same key, different request | `409` with `{ "error": "idempotency_error", "message": "idempotency_key was already used with a different request" }`. |

"Same request" is judged on the request's content, not its formatting. Failures are not stored: a `400` under a key leaves the key unclaimed.

## Retry matrix

| Response | `decide` | `events` | `identify`, `declare` |
| - | - | - | - |
| `400`, `401`, `403`, `404`, `405` | Do not retry; fix the request, key or origin. | Same. | Same. |
| `409` | Do not retry, except code `lock_timeout`: once, same request. | Not returned. | `identify`: resolve the link. `declare`: conclude an arm first. |
| `429` | Serve the fallback; the deadline has passed by the time `Retry-After` elapses. | Retry after `Retry-After`, same ids. | Retry after `Retry-After`. |
| `500` | Serve the fallback. Retry only with the same `idempotency_key`. | Retry, same ids. | Retry the same request. |
| `502`, other `5xx`, dropped connection, timeout | Serve the fallback. Retry only with the same `idempotency_key`; an unkeyed retry can create a second decision. | Retry, same ids. | Retry the same request. |

Cap retries at a handful with exponential backoff. The server SDK makes five attempts per event and never retries `decide`.

## Ambiguous failures

A `502` or a dropped connection after the request left you tells you nothing about whether the origin committed. The response's message says so and names the safe retry: the same `idempotency_key` for `decide`, the same `id` for events. Both make the retry safe either way: if the original committed, you get it back; if it did not, the retry is the first attempt.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.