Retry safely with Idempotency-Key
Send an Idempotency-Key header and a retried call returns the first answer for 0 credits instead of running and billing again
A timeout leaves you not knowing whether a call ran. Put a unique value in an Idempotency-Key header, and send the retry with the same value: SocialCrawl returns the first call's answer for 0 credits instead of running it, and billing it, again.
KEY="$(uuidgen)"
# The first call runs and is billed.
curl "https://www.socialcrawl.dev/v1/tiktok/profile?handle=nasa" \
-H "x-api-key: YOUR_API_KEY" \
-H "Idempotency-Key: $KEY"
# The retry, with the same key, replays that answer and costs 0 credits.
curl -i "https://www.socialcrawl.dev/v1/tiktok/profile?handle=nasa" \
-H "x-api-key: YOUR_API_KEY" \
-H "Idempotency-Key: $KEY"The second call did not reach TikTok. It came back with the first call's body, credits_used: 0 and idempotent_replay: true, and it was not billed. Use one key per logical request, keep it across that request's retries, and make a new one for the next request.
What does a replay look like?
The status and the body are the first call's, with the billing fields refreshed:
{
"success": true,
"platform": "tiktok",
"endpoint": "/v1/tiktok/profile",
"data": {
"author": { "username": "nasa", "followers": 3900000 }
},
"credits_used": 0,
"credits_remaining": 99,
"request_id": "req-abc123",
"idempotent_replay": true
}| Where | On a replay |
|---|---|
| Headers | X-Idempotent-Replay: true and Idempotent-Replayed: true (same meaning, two spellings), and X-Credits-Used: 0 |
idempotent_replay | true. The field is absent on a call that ran |
credits_used | 0. The first call's charge is the only one |
credits_remaining | Your balance now. If the balance cannot be read at that moment it is null and X-Credits-Remaining is left out |
request_id | The first call's id. The X-Request-Id header is this delivery's id, so log both |
Which calls take a key?
| Calls | Key | What a retry with the same key returns |
|---|---|---|
Every GET under /v1/ | Optional | The stored answer, for 24 hours |
The batch calls: POST /v1/prism/profiles, POST /v1/prism/post-stats, POST /v1/prism/comment-lookup, POST /v1/prism/post-comments, POST /v1/youtube/videos, POST /v1/youtube/channels, POST /v1/youtube/transcripts | Optional | The stored answer, for 24 hours. A streamed batch replays as its JSON answer. See Batch |
The job submits: POST /v1/prism/jobs, POST /v1/web/crawl, POST /v1/web/batch-scrape, and a GET sent with async=true | Optional, per API key | The job it already created, for 7 days, never a second job. This answer carries no replay headers, and POST /v1/prism/jobs says 200 where the first submit said 202. See Async jobs |
POST /v1/prism/discover | Read only on a body with schedule | An identical request returns the run that already exists, with or without a key, unless you send new_run. With schedule, the key replays the first answer exactly. See Discover |
POST /v1/webhook-endpoints, POST /v1/webhook-endpoints/{id}/rotate-secret | Optional, 1 to 255 characters, per API key | The endpoint or the secret the first call made. See Event webhooks |
POST /v1/cohorts, PUT /v1/cohorts/{cohortId}/members, POST /v1/cohorts/{cohortId}/queries | Required, a UUID, per API key | The resource the first call made. Without a UUID key the call is a free 400 INVALID_REQUEST. See Cohorts |
A key covers the method, the path, the query and the JSON body. Key order inside the body does not matter, and array order does: the same members in a different order are a different request. Send the same key with a different payload and you get 422 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH, never a false replay. On the GET and batch calls a key belongs to your account, so every API key on the account shares it; on the job submits, the webhook endpoint calls and the cohort calls it belongs to the one API key that sent it.
Which calls ignore the key?
These calls run every time you send them, key or not. A retry after a timeout can start a second browser session, a second monitor or a second upload, and bill it.
POST /v1/web/sessionsPOST /v1/web/sessions/{session_id}/executePOST /v1/web/agentPOST /v1/web/monitorsPOST /v1/web/parsePOST /v1/trustpilot/reviews/historyPOST /v1/prism/discover/inputsPOST /v1/monitors
Before you resend one of these after a timeout, list what exists (GET /v1/web/sessions, GET /v1/web/monitors, GET /v1/monitors) and send it again only if the first call did not land. PATCH and DELETE calls are safe to repeat without a key, because sending them twice leaves the same state.
What happens while the first call is still running?
A GET retry with the same key waits up to 20 seconds for the first call to finish, then replays its answer. If the first call is still running after that, or the retry is a POST, you get 409 IDEMPOTENCY_IN_PROGRESS with a Retry-After of 1 to 30 seconds, which grows with how long the first call has run. Nothing was sent or billed: wait Retry-After, then send the same key and payload again.
When is a call not replayed?
- The first call failed with a retryable error. It is not stored, so the retry with the same key runs again, which is what you want. Only a finished answer is stored and replayed.
- The answer was too large to keep. An answer over 64 KB of JSON is stored compressed. One that is still over 160 KB compressed cannot be replayed: the retry gets
409 IDEMPOTENCY_REPLAY_UNAVAILABLEand is not charged. Send the request with a new key. While the endpoint's cache window is open it is served from the cache for 0 credits. - The key is older than its window. 24 hours for the stored answers, 7 days for the job submits. After that the same key is a new request.
- It was a dry run. A
GETwithdry_run=1stores nothing, so the same key still works for the real call. - It was a streamed
GET. AGETthat asks for a stream (Accept: text/event-stream, or the endpoint's own stream parameter) and carries anIdempotency-Keyis a free400 INVALID_REQUEST, because an interrupted stream has no whole answer to replay. Ask for JSON when you need a replay. See Streaming. - The key store was unavailable. You get
503 SERVICE_UNAVAILABLEwithRetry-After: 1before anything runs. Retry with the same key.
Errors you can get
error.type | HTTP | Retry? | What to do |
|---|---|---|---|
IDEMPOTENCY_IN_PROGRESS | 409 | Yes, after Retry-After | Send the same key and payload again |
IDEMPOTENCY_KEY_PAYLOAD_MISMATCH | 422 | No | The payload changed. Use a new key for the new request |
IDEMPOTENCY_REPLAY_UNAVAILABLE | 409 | No | The answer was too large to keep. Send it with a new key |
SERVICE_UNAVAILABLE (idempotency_store_unavailable) | 503 | Yes, after Retry-After | Send the same key again |
None of these is charged. The full catalogue is in Errors.
A retry loop that keys every call
const KEY = process.env.SOCIALCRAWL_API_KEY!;
const RETRYABLE = new Set([
"IDEMPOTENCY_IN_PROGRESS",
"RATE_LIMITED",
"CONCURRENCY_LIMIT",
"UPSTREAM_ERROR",
"SERVICE_UNAVAILABLE",
"INTERNAL_ERROR",
]);
async function getWithRetry(url: string, attempts = 4) {
// One key for this request, reused on every retry of it.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch(url, {
headers: { "x-api-key": KEY, "Idempotency-Key": idempotencyKey },
signal: AbortSignal.timeout(30_000),
});
const body = await res.json();
if (body.success || !RETRYABLE.has(body.error?.type) || attempt === attempts) {
return body;
}
const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
} catch (error) {
// A timeout or a dropped connection: the call may have run, so retry with the same key.
if (attempt === attempts) throw error;
}
}
}
const body = await getWithRetry("https://www.socialcrawl.dev/v1/tiktok/profile?handle=nasa");
console.log(body.idempotent_replay ?? false, body.credits_used);Every retry of one call reuses its key, so a timeout that hid a finished call costs nothing the second time. The Production checklist has the full wrapper with per-endpoint timeouts and logging.
Webhook deliveries you receive
Idempotency-Key is for calls you send. Deliveries SocialCrawl sends you can also arrive twice, because every channel retries until your endpoint answers 2xx. De-duplicate those on the id each channel names: see Webhooks and events.
Next steps
Errors
Every error.type, which ones are safe to retry, and what each costs.
Production checklist
Timeouts, retries, paging and spend controls before you go live.
Batch
Up to 1,000 items in one POST, keyed and billed per row.
Webhooks and events
Which channel tells you when something changes, and how to verify it.
