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

# Idempotency

> Use the Idempotency-Key header to safely retry mutating requests without duplicating side effects.

The Causeloop API supports idempotent mutations via the `Idempotency-Key` header. Sending the same key on a repeated request replays the original response instead of executing the operation again — making it safe to retry network errors and timeouts without worrying about duplicate creates or double-charges.

<Warning>
  The header is **mandatory**, not just recommended, on three path prefixes — `/v1/ingest/`, `/v1/configs/`, and `/v1/engine/runs`. See [Mandatory idempotency keys](#mandatory-idempotency-keys) below.
</Warning>

## Which requests support idempotency

Idempotency keys are accepted on all **mutating** methods:

* `POST`
* `PATCH`
* `PUT`

`GET` and `DELETE` requests are inherently idempotent and ignore the header.

On most endpoints the header is optional — recommended for safe retries, but the request is processed normally without one. A specific set of paths (below) makes it required.

## Mandatory idempotency keys

Three path prefixes have real duplicate-side-effect risk on a client retry — a second ingest batch, a second config version, a second full engine run — so the middleware rejects mutating requests to them outright when the header is missing:

| Path prefix       | Methods | What a missing key would risk                                                                                                                             |
| ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/v1/ingest/`     | `POST`  | A retried batch or event push re-ingests the same issues as new rows instead of replaying the original ack.                                               |
| `/v1/configs/`    | `POST`  | A retried version create mints a duplicate immutable config version; a retried activation should replay, not re-run the (privileged, audited) activation. |
| `/v1/engine/runs` | `POST`  | A retried run trigger starts a second full pipeline run over the same scope.                                                                              |

A request to one of these prefixes without an `Idempotency-Key` header is rejected before it reaches the handler:

```http theme={null}
POST /v1/ingest/batch
Content-Type: application/json
(no Idempotency-Key header)
```

```json theme={null}
400 Bad Request
{
  "error": {
    "code": "idempotency_key_required",
    "message": "Idempotency-Key header is required for this request",
    "trace_id": "7f3a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c"
  }
}
```

Every example on this page — and every mutating call under those three prefixes in your integration — should always send a key.

## How it works

1. You attach an `Idempotency-Key` header with a unique value to a mutating request.
2. The server processes the request normally and caches the response (for 2xx responses only) keyed by `(Idempotency-Key, request path)`.
3. If you send a second request with the **same key and path**, the server returns the cached response immediately — the handler is never called again.
4. The replay response includes an `Idempotency-Replayed: true` header so you can distinguish a fresh response from a cached one.

<Note>
  Only **successful** (2xx) responses are cached. If the original request returned a 4xx or 5xx, the next request with the same key will be executed again.
</Note>

## The Idempotency-Key header

```http theme={null}
Idempotency-Key: <your_unique_key>
```

The key value is a string of your choice. Use a UUID or a similarly unique value. The key is scoped to the combination of key value **and** request path — the same key sent to a different endpoint is treated as a separate cache entry.

**Recommended format:** UUIDs (v4)

```
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
```

## Cache lifetime

Idempotency responses are cached for **24 hours**. After that window, a request with the same key will be processed as a new request.

When Redis is configured (`REDIS_URL`), the cache is shared across all API replicas. Without Redis, the cache is in-process — a request hitting a different replica will be treated as new. For production deployments with multiple API instances, configure Redis to ensure consistent idempotency guarantees.

## Example: safely creating an issue

Generate a unique key before the request, then reuse it on retries:

<CodeGroup>
  ```bash curl theme={null}
  IDEM_KEY=$(uuidgen)  # or python3 -c "import uuid; print(uuid.uuid4())"

  curl -X POST https://api.causeloop.ai/v1/issues \
    -H "Authorization: Bearer $CAUSELOOP_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $IDEM_KEY" \
    -d '{"title": "Payment gateway timeout", "severity": "high"}'
  ```

  ```javascript JavaScript theme={null}
  import { v4 as uuidv4 } from 'uuid';

  const idempotencyKey = uuidv4();

  async function createIssue(data, retries = 3) {
    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const resp = await fetch('https://api.causeloop.ai/v1/issues', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey, // same key on every retry
          },
          body: JSON.stringify(data),
        });

        if (resp.ok) {
          const replayed = resp.headers.get('Idempotency-Replayed') === 'true';
          const issue = await resp.json();
          console.log(replayed ? 'Replayed response' : 'Fresh response', issue.id);
          return issue;
        }

        if (resp.status >= 500) continue; // retry on server errors
        throw new Error(`HTTP ${resp.status}`);
      } catch (err) {
        if (attempt === retries - 1) throw err;
        await new Promise(r => setTimeout(r, 1000 * 2 ** attempt));
      }
    }
  }
  ```

  ```python Python theme={null}
  import uuid
  import time
  import requests

  idempotency_key = str(uuid.uuid4())

  def create_issue(data: dict, retries: int = 3):
      for attempt in range(retries):
          try:
              resp = requests.post(
                  'https://api.causeloop.ai/v1/issues',
                  json=data,
                  headers={
                      'Authorization': f'Bearer {access_token}',
                      'Idempotency-Key': idempotency_key,  # same key on every retry
                  },
              )
              if resp.ok:
                  replayed = resp.headers.get('Idempotency-Replayed') == 'true'
                  issue = resp.json()
                  print('Replayed' if replayed else 'Fresh', issue['id'])
                  return issue
              if resp.status_code >= 500:
                  continue
              resp.raise_for_status()
          except requests.RequestException:
              if attempt == retries - 1:
                  raise
              time.sleep(2 ** attempt)
  ```
</CodeGroup>

## Example: ingesting a batch (mandatory key)

`POST /v1/ingest/batch` is one of the mandatory-key paths — omit the header and you get `400 idempotency_key_required` before any record is processed:

```bash curl theme={null}
IDEM_KEY=$(uuidgen)

curl -X POST https://api.causeloop.ai/v1/ingest/batch \
  -H "Authorization: Bearer $CAUSELOOP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM_KEY" \
  -d '{
    "records": [
      {
        "external_id": "INC-4471",
        "title": "Payment gateway timeout on checkout",
        "narrative": "Health check timed out after 30s on 3/5 replicas.",
        "occurred_at": "2026-07-01T14:32:00Z",
        "severity": "p2"
      }
    ]
  }'
```

If the network drops before you see the response, resend the exact same request with the same `IDEM_KEY` — you get back the original `{accepted, rejected, job_id, issue_ids, engine_status_summary}` ack, not a second copy of the issue.

## Response headers

| Header                 | When present               | Description                                          |
| ---------------------- | -------------------------- | ---------------------------------------------------- |
| `Idempotency-Replayed` | On replayed responses only | Always `true`. Signals the response came from cache. |

## Key scoping

The cache key is `(caller, Idempotency-Key value, request path)` — the caller's `Authorization` header is part of the key, not just the header value and path. This means:

* `POST /v1/issues` with key `abc-123` and `POST /v1/patterns` with key `abc-123` are **separate** cache entries.
* The same key reused across **different paths** does not cause cross-resource collisions.
* Two different callers (different workspaces, different users, or a service account vs. a human) can never collide on the same cache entry, even if they happen to send the identical key and path.
* A request with no `Authorization` header never reads from or writes to the replay cache — it always executes fresh, and normal auth enforcement (`401`) still applies downstream.

<Warning>
  Do not reuse the same key value for different logical operations on the same endpoint. If you create issue A with key `abc-123`, then later try to create issue B with the same key on `POST /v1/issues`, you will receive the cached response for issue A instead.
</Warning>
