Idempotency
Idempotency-Key semantics, the 24-hour replay window, and partner-created resource IDs.
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
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.
TTL: 24 hours. After that the entry is gone and the key can be reused. Before that, rules below.
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(idempotent onidin body, or on the key)POST /v1/projects/:id/ingest/githubPOST /v1/projects/:id/ingest/websitePOST /v1/projects/:id/ingest/appstorePOST /v1/projects/:id/sdk-appsPOST /v1/projects/:id/influencersPOST /v1/projects/:id/influencers/clonePOST /v1/projects/:id/media,POST /v1/projects/:id/media/presign,POST /v1/projects/:id/media/finalizePOST /v1/projects/:id/content,POST /v1/content/:id/regenerate,POST /v1/projects/:id/content/clone-from-postPOST /v1/content/:id/approve,POST /v1/content/:id/rejectPOST /v1/content/:id/schedule,POST /v1/content/:id/publishPOST /v1/projects/:id/social/oauth-url,POST /v1/projects/:id/social-accounts/:id/reauth-urlPOST /v1/leased-accounts/requestPOST /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
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();
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');
}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")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.
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.
{
"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—idPOST /v1/projects/:id/sdk-apps—appIdPOST /v1/projects/:id/influencers—influencerIdPOST /v1/projects/:id/influencers/clone—newInfluencerIdPOST /v1/projects/:id/content—id(container id)POST /v1/projects/:id/content/clone-from-post—idPOST /v1/content/:id/schedule—targets[].scheduledPostIdPOST /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
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.