Idempotency keys explained: safe retries for async tasks
An idempotency key is a string you attach to an async task so that the task is created at most once, no matter how many times the request is sent. On this API the field is idempotencyKey, it is unique across your account, and a second task carrying a key already in use is refused without creating or charging anything. Every timeout, crash and overlapping cron run then becomes a safe resend instead of a possible duplicate.
Why you need it
When POST /v1/async/task times out or the connection drops, you cannot tell whether the server created the task before the failure. Without a key you have two bad options: don’t retry and risk losing the task, or retry and risk a second task that runs and is charged a second time. With a key, the retry is always correct: if the first attempt was created, the retry is refused; if it was not, the retry creates it.
For this reason the retry policy allows retrying task creation after a 5xx, timeout or connection error only when every task in the request carries an idempotencyKey.
How the API enforces it
- Scope: a key is unique across your account, not per engine, per batch or per day.
- Single create:
POST /v1/async/taskwith a key already in use answers409with codeRESOURCE_CONFLICTand creates nothing. - Batch create: in
POST /v1/async/task/batch, each task is admitted on its own. A task whose key is in use fails as a per-item result withRESOURCE_ALREADY_EXISTS, inside a200response; the other items still go through. - Refused requests claim nothing: a request whose every task was refused, for example with
INSUFFICIENT_CREDITS, creates nothing and claims no key. You can send the same keys again once you have topped up. - Visibility: the key is echoed as
task.idempotencyKeyon the create response, onGET /v1/async/task/{taskId}, and therefore in webhook bodies, which carry the same JSON as that GET.
curl -X POST https://api.answerline.dev/v1/async/task \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"taskType": "CHATGPT",
"payload": { "prompt": "Best CRM for a 10-person agency", "country": "US" },
"idempotencyKey": "chatgpt-3f9a1c0e7b2d4a58-2026-09-18"
}'
Send the same command twice and the second answers 409 RESOURCE_CONFLICT. The 409 body carries only code, message and timestamp, not the existing task’s id, so store task ids from successful creates, or match later results by task.idempotencyKey.
Designing keys
A good key names one logical unit of work. Two requests that should produce one task must derive the same key; two requests that should produce two tasks must derive different ones.
The rules
- Deterministic. Compute the key from the task’s content and schedule, never from
uuid4()or the current time at submission. A random key per attempt gives the same protection as no key. - Include the engine. Keys are unique across the account, so
best-crm-US-2026-09-18used for ChatGPT blocks the same prompt on Gemini. - Include every field that changes the meaning: prompt, market (
country,location), and anyincludeflag that changes the output or cost. - Include the run period. A daily run uses the date; a weekly run the ISO week; a report uses the report id. Tomorrow’s run is different work and needs a different key.
- Hash long inputs. Prompts can be up to 10,000 characters on ChatGPT. Hash the canonical payload and keep the key short and log-friendly.
Choosing the period by workload
| Workload | Key components | Effect of a rerun |
|---|---|---|
| Daily monitoring cron | engine, payload hash, date | A crashed or overlapping run the same day is refused; tomorrow runs fresh |
| Weekly report | engine, payload hash, ISO week | Any rerun inside the week is a no-op |
| On-demand report | report id, prompt index, engine | Regenerating report 4821 cannot double-charge |
| Ad-hoc exploration | none (omit the key) or an SDK-generated key | Every request is new work |
Key derivation code
Hashing the canonical JSON of the task type and payload covers rules 2, 3 and 5 at once: any change to the prompt, market or include flags produces a new key, and nothing else does.
import hashlib
import json
def task_key(task_type: str, payload: dict, period: str) -> str:
canonical = json.dumps({"taskType": task_type, "payload": payload}, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
return f"{task_type.lower()}-{digest}-{period}"
key = task_key("CHATGPT", {"prompt": "Best CRM for a 10-person agency", "country": "US"}, "2026-09-18")
import { createHash } from "node:crypto";
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") {
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical((value as Record<string, unknown>)[k])]));
}
return value;
}
export function taskKey(taskType: string, payload: object, period: string): string {
const digest = createHash("sha256").update(JSON.stringify(canonical({ taskType, payload }))).digest("hex").slice(0, 32);
return `${taskType.toLowerCase()}-${digest}-${period}`;
}
Sorting keys matters: {"country": "US", "prompt": "…"} and {"prompt": "…", "country": "US"} are the same task and must hash the same. The period should come from the schedule (the date the run is for), not the wall clock at submission, or a run that starts at 23:59 and retries at 00:01 derives two keys. The Python and TypeScript versions produce the same key for the same input, so services in either language can share one keyspace.
Handling a 409 on a single create
On a retry, a 409 means your earlier attempt worked. Treat it as success.
import { ApiError, Client } from "@answerline/sdk";
const client = new Client({ apiKey: process.env.API_KEY! });
const payload = { prompt: "Best CRM for a 10-person agency", country: "US" };
const idempotencyKey = taskKey("CHATGPT", payload, "2026-09-18");
try {
const { task } = await client.createTask({ taskType: "CHATGPT", payload, idempotencyKey });
await saveTaskId(idempotencyKey, task.id);
} catch (err) {
if (err instanceof ApiError && err.code === "RESOURCE_CONFLICT") {
// Created by an earlier attempt; its result arrives by webhook or under the id saved then.
} else {
throw err;
}
}
saveTaskId stands for your own storage. The SDK retries creation itself and, when you omit idempotencyKey, sends a generated one, so its internal retries are already safe. A generated key lives only inside that call: if your process dies and restarts, the retry generates a new key and can create a duplicate. Supply your own derived key whenever a restart could resend the work.
Crash-safe batch resubmission
With derived keys you don’t need to track which tasks were submitted before a crash: resubmit everything and let the API refuse what already exists.
- Build the task list deterministically from your prompt set, markets and the run period, with a derived key on every task.
- Chunk it into batches of at most 500 tasks.
- Submit each chunk to
POST /v1/async/task/batch. - Classify each result by
index: success means created now;RESOURCE_ALREADY_EXISTSmeans created by an earlier run;INSUFFICIENT_CREDITSmeans not created;VALIDATION_ERRORmeans fix that payload. - On a crash anywhere in steps 3 and 4, restart from step 1. Chunks that went through come back as
RESOURCE_ALREADY_EXISTS; the rest are created.
In Python with requests:
import os
import requests
BASE_URL = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['API_KEY']}"}
def build_tasks(prompts, countries, period):
tasks = []
for prompt in prompts:
for country in countries:
payload = {"prompt": prompt, "country": country}
tasks.append({
"taskType": "CHATGPT",
"payload": payload,
"idempotencyKey": task_key("CHATGPT", payload, period),
"webhook": {"url": "https://example.com/hooks/answers"},
})
return tasks
def submit(tasks):
created, existing, no_credits, invalid = [], [], [], []
for start in range(0, len(tasks), 500):
chunk = tasks[start:start + 500]
res = requests.post(f"{BASE_URL}/v1/async/task/batch", json=chunk, headers=HEADERS, timeout=(10, 360))
res.raise_for_status()
for item in res.json().get("results", []):
task = chunk[item["index"]]
if item["success"]:
created.append((task["idempotencyKey"], item["task"]["id"]))
continue
code = item.get("error", {}).get("code")
{"RESOURCE_ALREADY_EXISTS": existing, "INSUFFICIENT_CREDITS": no_credits}.get(code, invalid).append(task)
return created, existing, no_credits, invalid
created, existing, no_credits, invalid = submit(build_tasks(PROMPTS, ["US", "GB"], "2026-09-18"))
The balance is checked in input order within a batch, so once the balance runs out every later item is refused with INSUFFICIENT_CREDITS; those tasks were not created and can be resubmitted with the same keys after a top-up. Only a queue overflow rejects a whole batch (429 QUEUE_LIMIT_EXCEEDED); a 429 was not admitted, so resending the chunk is safe. For timeouts and 5xx on the batch call itself, wrap it in the retry policy from the errors post: every task carries a key, so the request is replayable.
What idempotency keys do not cover
Webhook deliveries
A key guarantees one task, not one delivery. A delivery counts as successful when your endpoint answers 2xx within 15 seconds; failed deliveries are retried up to 10 attempts in total, and deliveries can arrive more than once, and can arrive out of order. Dedupe in the handler by task.id, after verifying the signature over the raw body:
def handle_delivery(body: dict) -> None:
task = body["task"]
if already_processed(task["id"]): # a unique constraint on task id in your store
return
if task["status"] == "COMPLETED":
store_result(task["id"], task.get("idempotencyKey"), body["response"]["result"])
mark_processed(task["id"])
Test deliveries carry "test": true; skip them. The Webhook-Id header is not signed, so don’t use it as the dedupe key. See webhook reliability design and verifying webhook signatures.
Synchronous monitor calls
POST /v1/monitor/* calls take no idempotency key. A synchronous call that loses its connection may still run and be charged, which is why it should be retried only when the request never reached the server. If a lost response is unacceptable, use an async task with a key instead; sync, async and webhooks covers the choice.
Failed tasks
A task that ends FAILED keeps its key for 24 hours after it failed, then releases it: the same key then creates a new task, which is what a daily run wants. Failures are not charged, but resubmitting inside those 24 hours is refused, so a retry that cannot wait needs a new key, such as the original with -r1 appended. That applies to tasks that failed with details.cause QUEUE_WAIT_EXCEEDED (not started within 72 hours of creation) as much as to any other failure.
Results
Keys dedupe creation, not answers. Two runs of the same prompt on different days are different work with different results, and should have different keys. If you want to skip a run because yesterday’s answer is still fresh, that is a caching decision in your own code; monitoring cadence covers how often answers are worth re-collecting.
Pitfalls
- Keys without the engine. The same prompt across ChatGPT and Gemini collides, and the second engine’s task is silently refused as existing.
- Hand-picked key fields that miss a change. If you add
include.shoppingbut the key only covers prompt and country, the rerun is refused and the new field never collected. Hashing the whole payload avoids this. - Wall-clock periods. Deriving the date at submission time splits one run into two around midnight.
- Counting 409 as an error. Alerting on it during a resubmission floods you with alerts for work that already succeeded.
- Relying on SDK-generated keys across restarts. They protect the SDK’s own retries only.
- Assuming a key frees up after a failure. It does not; use a suffixed key to resubmit.
The full rules are in the async tasks and batches docs; field definitions are in the API reference.
Questions
What is an idempotency key?
An idempotencyKey is a string you attach to an async task so the API creates that task at most once. The key is unique across your account: a second task with the same key is refused and nothing new is created or charged.
What happens when I reuse an idempotency key?
A single POST /v1/async/task answers 409 RESOURCE_CONFLICT, and a batch item fails with RESOURCE_ALREADY_EXISTS inside a 200 response. In both cases the existing task is untouched and no new task is created.
Does a request refused for insufficient balance use up its key?
No. A request whose every task was refused, for example with INSUFFICIENT_CREDITS, creates nothing and claims no key, so you can send the same key again once you have topped up.
How should I generate idempotency keys?
Derive them from what the task means, such as engine, prompt, market and run date, never at random per attempt. A deterministic key lets a restarted process resubmit the same work and have duplicates refused.
Do idempotency keys stop duplicate webhook deliveries?
No. Keys dedupe task creation only. Webhook deliveries can arrive more than once and out of order, so your handler should dedupe by task.id.
Can I resubmit a failed task with the same key?
Not right away. A failed task keeps its key for 24 hours after it failed, then the key is released and the same key creates a new task. To retry sooner, resubmit with a new key, for example the original key plus an attempt suffix.