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



Retrying is a normal part of network code. We make it safe with one mechanism: send `Idempotency-Key: <uuid>` on every mutating POST/PATCH. A retry returns the cached response instead of doing the work twice.

Use one on every mutating call. Not most calls — every call. There is no other retry mechanism, and we do **not** accept partner-supplied resource ids in request bodies.

## 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.

Replay window: while the entry is active, the rules below apply.

## 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` (and every `POST` under `/projects/:id/...`)
* `POST /v1/projects/:id/sdk-apps`
* `POST /v1/projects/:id/influencers`, `POST /v1/influencers/:id/clone`
* `POST /v1/projects/:id/app-media`
* `POST /v1/projects/:id/content`
* `POST /v1/content/:id/{approve,reject,schedule,publish}`
* `POST /v1/projects/:id/social/oauth-url`, `POST /v1/projects/:id/social/reauth-url`
* `POST /v1/projects/:id/leased-accounts/request`
* `POST /v1/jobs/:jobId/cancel`
* `POST /v1/events` (server-side event forwarding)
* [Sub-org](/docs/api/concepts/organizations) control plane: `POST /v1/organizations` (create child), `POST /v1/organizations/:orgId/{suspend,resume}`, `POST /v1/organizations/migrate`, `POST /v1/organizations/:orgId/credits/allocate`, `PATCH /v1/organizations/:orgId/credit-config`, `POST /v1/organizations/:orgId/api-keys` (mint), `POST /v1/organizations/:orgId/api-keys/:keyId/rotate`
* Every `PATCH` (project, content container, influencer, scheduled post, ads-content override, approval policy, SDK app, child organization)

`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.

**Ads writes** ([create-campaign](/docs/api/reference/ads/create-campaign), authority PATCH, lifecycle, etc.) do **not** require `Idempotency-Key`. The pattern there is read–decide–write: you list existing campaigns, decide whether to create a new one, then create. Idempotency-Key remains canonical on **content** and **resource-create** routes.

## 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();
      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 waitBeforeRetry(attempt);
          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, uuid, httpx

    def idempotent_post(url: str, body: dict, api_key: str) -> dict:
        key = str(uuid.uuid4())
        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:
                wait_before_retry()
                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>

## Resource ids are server-generated [#resource-ids-are-server-generated]

You cannot pass your own `id` when creating a resource. The API integration mints every resource id (UUIDv4 wrapped with the relevant prefix — `prj_`, `inf_`, `cnt_`, `sp_`, `app_<24 hex>`) and returns it in the response.

```json title="POST /v1/projects/:projectId/influencers — response carries the id"
HTTP/1.1 202 Accepted

{
  "kind": "influencer_create",
  "jobId": "job_01HX9Y6K7EJ4T2ABCDEF01234",
  "influencerId": "inf_4a8e1bc2-3d4f-46a8-9b0c-1d2e3f4a5b6c",
  "status": "queued"
}
```

Persist the returned id in your own system and map it back to whatever you call this entity. Sending an `id` field in the request body is rejected with `422 VALIDATION` — the schemas are strict and unknown keys are surfaced as errors, not silently ignored.

`POST /v1/projects/:id/leased-accounts/request` accepts a `requestId` in the body, but that's an idempotency key — it's stored on the row's `idempotency_key` column, not as the row's primary key. Same role as `Idempotency-Key`, scoped to the lease-request endpoint.

Use `Idempotency-Key` on every mutating POST. There's no second mechanism — no body-id upsert path.

## 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)
