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

# Idempotency

> Making retries safe on calls that cost money.

`GET` and search requests are read-only and safe to retry as often as you like. Two calls are not:
`POST /enrich` and `POST /jobs` both do billable work, and retrying one without protection charges
you twice.

Send an `Idempotency-Key`:

```bash theme={null}
curl -X POST https://api.unif.dev/v1/jobs \
  -H "Authorization: Bearer $UNIF_API_KEY" \
  -H "Idempotency-Key: nightly-enrich-2026-09-15" \
  -H "Content-Type: application/json" \
  -d '{ "type": "enrich", "input": { … } }'
```

If a request with that key already succeeded, Unif returns the **original response** without
re-running anything.

## Choosing a key

A key is any string up to 255 characters. What matters is that it identifies the *intent*, so a
retry produces the same key and a genuinely new request does not.

<CardGroup cols={2}>
  <Card title="Derive it from the work" icon="check">
    `nightly-enrich-2026-09-15`, `export-shops-us-2026-09` — deterministic, so a scheduler retry
    naturally reuses it.
  </Card>

  <Card title="Not a fresh UUID per attempt" icon="xmark">
    A new UUID on each attempt makes every retry a new request, which is exactly what you were
    trying to prevent.
  </Card>
</CardGroup>

```python theme={null}
key = f"enrich-{tenant_id}-{batch_id}"          # stable across retries
```

Generating the key once and storing it with the task — before the first attempt — is what makes
this work when the retry comes from a process that crashed and restarted.

## Rules

<AccordionGroup>
  <Accordion title="Keys are retained for 24 hours" icon="clock">
    After that the key is forgotten and the same key starts fresh work. Retries should happen well
    inside that window.
  </Accordion>

  <Accordion title="A key is bound to its request body" icon="fingerprint">
    Reusing a key with a different body returns `400` with `idempotency_key_reused`. This is a
    guard: it catches the bug where a key gets reused for work it does not describe.
  </Accordion>

  <Accordion title="Keys are scoped to a workspace" icon="building">
    Two workspaces can use the same key string without colliding.
  </Accordion>

  <Accordion title="Only successes are replayed" icon="arrow-rotate-right">
    If the original request failed with a `4xx` or `5xx`, the key is not retained — a retry runs
    the work properly rather than replaying a failure.
  </Accordion>
</AccordionGroup>

## Concurrent retries

If a second request with the same key arrives while the first is still in flight, it returns `409`
with `idempotency_key_in_progress`. Wait and retry:

```python theme={null}
def create_job(body, key):
    for attempt in range(5):
        resp = requests.post(
            "https://api.unif.dev/v1/jobs",
            headers={**AUTH, "Idempotency-Key": key},
            json=body, timeout=30,
        )
        if resp.status_code != 409:
            return resp.json()
        time.sleep(2 ** attempt)

    raise RuntimeError(f"idempotency key {key} stuck in progress")
```

This is the case that matters when two workers pick up the same queue message. One does the work,
the other waits and then reads the same result — instead of both running an export.

## Where it is not needed

| Call                                              | Needs a key?                             |
| ------------------------------------------------- | ---------------------------------------- |
| `GET` anything                                    | No — read-only                           |
| `POST /*/search`                                  | No — read-only despite the verb          |
| `POST /resolve`                                   | No — deterministic and cheap             |
| `POST /enrich`                                    | **Yes**                                  |
| `POST /jobs`                                      | **Yes**                                  |
| `POST /lists`, `POST /trackers`, `POST /webhooks` | Recommended — prevents duplicate objects |
| `POST /lists/{id}/items`                          | No — adds are naturally idempotent       |

<Warning>
  The single most expensive mistake against this API is a job-creation retry without a key. An
  export that costs real credits, run twice because a scheduler timed out and tried again, is
  entirely avoidable with one header.
</Warning>
