# Idempotency (/docs/api/operational/idempotency)



Retrying is a normal part of network code. We make it safe. Send `Idempotency-Key: <uuid>` on every mutating POST, and a retry returns the cached response instead of doing the work twice.

Use one on every POST. Not most POSTs — every POST.

## The header [#the-header]

```http
Idempotency-Key: 8d2f1a3e-0b4c-4a11-9f7e-33c0a2c1bd55
```

Format: any opaque string up to 255 chars. We strongly recommend a UUIDv4. Generate it client-side; don't ask us to.

Scope: `(api_key_id, key)`. Keys from different API keys don't collide. Keys from the same API key across different endpoints don't collide either — the endpoint path is part of the match.

TTL: 24 hours. After that the entry is gone and the key can be reused. Before that, rules below.

## The three outcomes [#the-three-outcomes]

### 1. First time — do the work [#1-first-time--do-the-work]

```http
POST /v1/projects/:projectId/content
Idempotency-Key: 8d2f...
```

Standard flow. We run the endpoint, store the response body + status code, and return it.

### 2. Same key, same body — replay the cache [#2-same-key-same-body--replay-the-cache]

A second request with the same key and an identical body (by SHA-256 of the canonicalized payload) replays the cached response. Status code, headers, and body are byte-identical to the first one. Safe to retry a network flake; safe to retry after your process crashed.

The response carries a header:

```http
Idempotency-Replayed: true
```

Use it for metrics. Don't branch your client on it — the cached response is the same shape.

### 3. Same key, different body — 409 CONFLICT [#3-same-key-different-body--409-conflict]

Reuse a key for a materially different request and you get:

```json
{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "message": "Idempotency-Key 8d2f... was used earlier with a different request body.",
    "requestId": "req_01HXZ9G7...",
    "details": {
      "originalRequestHash": "sha256:3f9a...",
      "currentRequestHash": "sha256:d21b..."
    }
  }
}
```

Create a new key and retry. Don't rotate the body; don't rotate the key alone — treat these as a pair.

## Which endpoints honor it [#which-endpoints-honor-it]

Every endpoint that mutates state. The short list:

* `POST /v1/projects` (idempotent on `id` in body, or on the key)
* `POST /v1/projects/:id/ingest/github`
* `POST /v1/projects/:id/ingest/website`
* `POST /v1/projects/:id/ingest/appstore`
* `POST /v1/projects/:id/sdk-apps`
* `POST /v1/projects/:id/influencers`
* `POST /v1/projects/:id/influencers/clone`
* `POST /v1/projects/:id/media`, `POST /v1/projects/:id/media/presign`, `POST /v1/projects/:id/media/finalize`
* `POST /v1/projects/:id/content`, `POST /v1/content/:id/regenerate`, `POST /v1/projects/:id/content/clone-from-post`
* `POST /v1/content/:id/approve`, `POST /v1/content/:id/reject`
* `POST /v1/content/:id/schedule`, `POST /v1/content/:id/publish`
* `POST /v1/projects/:id/social/oauth-url`, `POST /v1/projects/:id/social-accounts/:id/reauth-url`
* `POST /v1/leased-accounts/request`
* `POST /v1/jobs/:jobId/cancel`
* Every `PATCH` (project, content container, influencer, scheduled post, ads-content override, approval policy, SDK app)

`DELETE` is idempotent by nature — deleting an already-deleted resource returns the same shape the first delete did. You don't need the header there, but sending it doesn't hurt.

`GET` ignores the header.

## Pattern: generate once, reuse on retry [#pattern-generate-once-reuse-on-retry]

The whole point is that the client generates the key once per logical operation and reuses it for every retry of that operation. Not per attempt — per operation.

<Tabs items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts title="lib/idempotent-post.ts"
    export async function idempotentPost<T>(
      url: string,
      body: unknown,
      apiKey: string,
    ): Promise<T> {
      const idempotencyKey = crypto.randomUUID();
      let delayMs = 500;
      for (let attempt = 0; attempt < 4; attempt++) {
        const res = await fetch(url, {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${apiKey}`,
            'Content-Type': 'application/json',
            'Idempotency-Key': idempotencyKey,
          },
          body: JSON.stringify(body),
        });
        if (res.ok) return res.json() as Promise<T>;
        if (res.status === 429 || res.status >= 500) {
          await new Promise((r) => setTimeout(r, delayMs));
          delayMs = Math.min(delayMs * 2, 8_000);
          continue;
        }
        throw new Error(`${res.status} ${await res.text()}`);
      }
      throw new Error('retries exhausted');
    }
    ```
  </Tab>

  <Tab value="Python">
    ```py title="idempotent_post.py"
    import os, time, uuid, httpx

    def idempotent_post(url: str, body: dict, api_key: str) -> dict:
        key = str(uuid.uuid4())
        delay = 0.5
        for _ in range(4):
            r = httpx.post(
                url,
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                    "Idempotency-Key": key,
                },
                json=body,
            )
            if r.is_success:
                return r.json()
            if r.status_code == 429 or r.status_code >= 500:
                time.sleep(delay)
                delay = min(delay * 2, 8.0)
                continue
            r.raise_for_status()
        raise RuntimeError("retries exhausted")
    ```
  </Tab>
</Tabs>

The key lives with the operation, not the attempt. If the process crashes and restarts, persist the key alongside whatever work item triggered the POST — otherwise a restart-mid-retry becomes a double-create.

<Callout type="warn">
  Don't generate a fresh key on every retry. That defeats the entire mechanism
  and can create duplicate resources on the second call.
</Callout>

## Partner-created IDs as an alternative [#partner-created-ids-as-an-alternative]

For resources where you already have a stable id in your system — an influencer, a content container, a scheduled post, a project — you can pass the id in the body and we'll UPSERT on conflict. This is a stronger form of idempotency: it doesn't expire in 24 hours, and it's natural to persist on your side.

```json title="POST /v1/projects/:projectId/influencers — idempotent on body id"
{
  "influencerId": "inf_01HXZ9G7KMV2QX8Y1S5RJW3B7T",
  "name": "Juno",
  "gender": "nonbinary",
  "ageRange": "24-32"
}
```

Call it twice with the same `influencerId` and the second call returns the existing resource. Call it with a different body and the same id and we return `409 CONFLICT` — don't silently mutate.

Endpoints that accept a caller-supplied id in the body:

* `POST /v1/projects` — `id`
* `POST /v1/projects/:id/sdk-apps` — `appId`
* `POST /v1/projects/:id/influencers` — `influencerId`
* `POST /v1/projects/:id/influencers/clone` — `newInfluencerId`
* `POST /v1/projects/:id/content` — `id` (container id)
* `POST /v1/projects/:id/content/clone-from-post` — `id`
* `POST /v1/content/:id/schedule` — `targets[].scheduledPostId`
* `POST /v1/leased-accounts/request` — `requestId`

Prefer partner-created ids when you can. They make debugging easier (your id shows up in our logs) and they make your retries free of the 24-hour window.

You can use both: partner-created id in the body and an `Idempotency-Key` header. They don't conflict.

## Replaying and the jobs envelope [#replaying-and-the-jobs-envelope]

When a replayed response includes a `jobId`, that's the original job, not a new one. Polling it returns the same state it's in — which may already be `completed`. That's fine. The `Idempotency-Replayed: true` header tells you whether to expect the job to already be terminal.

## See also [#see-also]

* [Errors](/docs/api/operational/errors)
* [Jobs](/docs/api/concepts/jobs)
* [Rate limits](/docs/api/operational/rate-limits)
