Idempotency
Idempotency-Key semantics, replay behavior, and why body `id` is not the retry mechanism.
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
Idempotency-Key: 8d2f1a3e-0b4c-4a11-9f7e-33c0a2c1bd55Format: 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
1. First time — do the work
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
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:
Idempotency-Replayed: trueUse it for metrics. Don't branch your client on it — the cached response is the same shape.
3. Same key, different body — 409 CONFLICT
Reuse a key for a materially different request and you get:
{
"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
Every endpoint that mutates state. The short list:
POST /v1/projects(and everyPOSTunder/projects/:id/...)POST /v1/projects/:id/sdk-appsPOST /v1/projects/:id/influencers,POST /v1/projects/:id/influencers/clonePOST /v1/projects/:id/media-library/{presign,finalize}POST /v1/projects/:id/contentPOST /v1/content/:id/{approve,reject,schedule,publish}POST /v1/projects/:id/social/oauth-url,POST /v1/projects/:id/social-accounts/:id/reauth-urlPOST /v1/leased-accounts/requestPOST /v1/jobs/:jobId/cancelPOST /v1/events(server-side event forwarding)- Sub-org 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, 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
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.
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');
}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")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.
Don't generate a fresh key on every retry. That defeats the entire mechanism and can create duplicate resources on the second call.
Resource ids are server-generated
You cannot pass your own id when creating a resource. The Partner API mints every resource id (UUIDv4 wrapped with the relevant prefix — prj_, inf_, cnt_, sp_, app_<24 hex>) and returns it in the response.
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/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
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.