AnswerLine Start free

, Engineering · Tutorials

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

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

  1. 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.
  2. Include the engine. Keys are unique across the account, so best-crm-US-2026-09-18 used for ChatGPT blocks the same prompt on Gemini.
  3. Include every field that changes the meaning: prompt, market (country, location), and any include flag that changes the output or cost.
  4. 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.
  5. 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.

  1. Build the task list deterministically from your prompt set, markets and the run period, with a derived key on every task.
  2. Chunk it into batches of at most 500 tasks.
  3. Submit each chunk to POST /v1/async/task/batch.
  4. Classify each result by index: success means created now; RESOURCE_ALREADY_EXISTS means created by an earlier run; INSUFFICIENT_CREDITS means not created; VALIDATION_ERROR means fix that payload.
  5. 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

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.

Try it on your own prompts

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading