Idempotency and retries
Networks fail. A request can time out on your side after the server has already run the model. The Idempotency-Key header lets you retry such a request without running the model twice and without paying twice.
The Idempotency-Key header
Send a unique key with each billable POST (/v1/deepfake/detect, /v1/speakers/enroll, /v1/speakers/verify). It is optional, and recommended.
Idempotency-Key: 3f1c9a8e-4b2d-4c7a-9e51-0d6f2b8a7c13- 1 to 128 printable ASCII characters. A random UUID per logical request works well. Other values are rejected with
400 invalid_idempotency_key. - Generate the key once, before the first attempt, and reuse it for every retry of that same request.
- Use a new key for each new request, even if the audio is the same.
A key is scoped to your account and the operation type, so the same key on /v1/deepfake/detect and /v1/speakers/verify refers to two separate records.
What the server does with a repeated key
The server stores a fingerprint of each request: the operation type, the normalised parameters (such as speaker_id and detect_deepfake) and a SHA-256 hash of the uploaded file.
| Situation | Response | Charged |
|---|---|---|
| First request with this key | Runs normally. | 50 (or 100) on success |
| Same key, same request, previous attempt succeeded | The stored result with "idempotent_replay": true, a new request_id, header Idempotent-Replayed: true and "credits": {"charged": 0, "balance": <current balance>, "original_charge": 50}. The model does not run again. |
0 |
| Same key, same request, previous attempt still running | 409 operation_in_progress, with the operation_id to poll. |
0 |
| Same key, same request, previous attempt failed (and was not charged) | Runs again under the same record. | 50 (or 100) on success |
| Same key, different request (other file or parameters) | 422 idempotency_key_reuse. |
0 |
Keys are kept for 24 hours for replay. Stored results stay available with GET /v1/operations/{operation_id} for 30 days.
In a replay, credits.charged is 0 because nothing was charged for this request, credits.balance is your balance now, and credits.original_charge is what the original request was charged (50, or 100 for a verification with detect_deepfake=true). Example:
{
"request_id": "req_5e0b7c2a91d4f3e8a6b1c0d9",
"operation_id": "op_9a0f3b6c1e72d8e6725b71fcb5f62c89",
"object": "deepfake.detection",
"result": {
"label": "genuine",
"is_synthetic": false,
"score": 0.08,
"threshold": 0.62,
"score_semantics": "model output probability of synthetic speech; not calibrated to your traffic",
"duration_seconds": 3.0,
"segments": [{ "start": 0.0, "end": 3.0, "score": 0.08 }]
},
"model": { "name": "aletheia-wavlm", "version": "wavlm-finetune-n-m43-moredata-ep063" },
"credits": { "charged": 0, "balance": 99850, "original_charge": 50 },
"latency_ms": 412.6,
"idempotent_replay": true
}Which errors to retry
| Response | Retry? |
|---|---|
| Network error or client timeout, no response | Yes, with the same Idempotency-Key. |
429 rate_limited, 429 concurrency_limited |
Yes, after Retry-After seconds. |
503 inference_overloaded |
Yes, after Retry-After seconds. |
502 inference_failed, 504 inference_timeout, 500 internal |
Yes, with backoff. Stop after a few attempts. |
409 operation_in_progress |
Do not resend. Poll GET /v1/operations/{operation_id} or wait and retry with the same key. |
400, 401, 403, 404, 413, 415, 422 |
No. Fix the request first. |
402 insufficient_credits |
No. Retrying will not succeed until credits are available. |
Use exponential backoff with jitter, honour Retry-After when present, and cap the number of attempts.
Retry helpers
cURL retries 429, 500, 502, 503 and 504 with --retry, and honours Retry-After. Fix the key in a variable so every attempt sends the same one:
KEY=$(uuidgen)
curl https://platform.detectif.ai/v1/deepfake/detect \
--retry 4 --retry-max-time 120 \
-H "Authorization: Bearer $DETECTIF_API_KEY" \
-H "Idempotency-Key: $KEY" \
-F "file=@call-recording.wav"import os
import random
import time
import uuid
import requests
API = "https://platform.detectif.ai/v1"
RETRYABLE = {429, 500, 502, 503, 504}
def post_with_retries(endpoint, path, fields=None, max_attempts=5):
key = str(uuid.uuid4()) # one key for every attempt of this request
for attempt in range(1, max_attempts + 1):
try:
with open(path, "rb") as f:
resp = requests.post(
f"{API}/{endpoint}",
headers={
"Authorization": f"Bearer {os.environ['DETECTIF_API_KEY']}",
"Idempotency-Key": key,
},
data=fields or {},
files={"file": (os.path.basename(path), f)},
timeout=120,
)
except requests.RequestException:
resp = None # network error: retry with the same key
if resp is not None:
body = resp.json()
if resp.ok:
return body
if resp.status_code not in RETRYABLE or attempt == max_attempts:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['request_id']})")
retry_after = resp.headers.get("Retry-After")
if retry_after:
time.sleep(float(retry_after))
continue
if attempt == max_attempts:
raise RuntimeError("request failed after retries")
time.sleep(min(30, 2 ** attempt) * random.uniform(0.5, 1.0))
body = post_with_retries("deepfake/detect", "call-recording.wav")
print(body["result"]["label"], body["idempotent_replay"])import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { randomUUID } from "node:crypto";
const API = "https://platform.detectif.ai/v1";
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function postWithRetries(endpoint, path, fields = {}, maxAttempts = 5) {
const key = randomUUID(); // one key for every attempt of this request
const audio = await readFile(path);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.append(k, v);
form.append("file", new Blob([audio]), basename(path));
let res;
try {
res = await fetch(`${API}/${endpoint}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.DETECTIF_API_KEY}`, "Idempotency-Key": key },
body: form,
signal: AbortSignal.timeout(120_000),
});
} catch {
res = undefined; // network error: retry with the same key
}
if (res) {
const body = await res.json();
if (res.ok) return body;
if (!RETRYABLE.has(res.status) || attempt === maxAttempts) {
throw new Error(`${body.error.code}: ${body.error.message} (${body.request_id})`);
}
const retryAfter = Number(res.headers.get("retry-after"));
if (retryAfter > 0) {
await sleep(retryAfter * 1000);
continue;
}
}
if (attempt === maxAttempts) throw new Error("request failed after retries");
await sleep(Math.min(30, 2 ** attempt) * 1000 * (0.5 + Math.random() / 2));
}
}
const body = await postWithRetries("deepfake/detect", "call-recording.wav");
console.log(body.result.label, body.idempotent_replay);Polling a running operation
409 operation_in_progress means an earlier attempt with the same key is still running. The error body carries its operation_id next to error. Poll the operation instead of sending the audio again. Polling is free.
GET /v1/operations/{operation_id} returns object: "operation" with type, status (pending, reserved, running, succeeded or failed), source (api, dashboard or mcp), credits_charged, created_at, completed_at, the stored result (the same JSON as the original response, kept 30 days) and, for a failed operation, an error object.
curl https://platform.detectif.ai/v1/operations/op_9a0f3b6c1e72d8e6725b71fcb5f62c89 \
-H "Authorization: Bearer $DETECTIF_API_KEY"import os
import time
import requests
def wait_for_operation(operation_id, timeout_s=120):
url = f"https://platform.detectif.ai/v1/operations/{operation_id}"
headers = {"Authorization": f"Bearer {os.environ['DETECTIF_API_KEY']}"}
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
op = requests.get(url, headers=headers, timeout=30).json()
if op.get("status") in ("succeeded", "failed"):
return op
time.sleep(2)
raise TimeoutError(operation_id)async function waitForOperation(operationId, timeoutMs = 120_000) {
const url = `https://platform.detectif.ai/v1/operations/${operationId}`;
const headers = { Authorization: `Bearer ${process.env.DETECTIF_API_KEY}` };
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const op = await (await fetch(url, { headers })).json();
if (op.status === "succeeded" || op.status === "failed") return op;
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`timed out waiting for ${operationId}`);
}