Idempotency
POST /v1/tasks accepts an optional idempotency_key in the request body. Reusing the same key returns the existing task instead of creating a duplicate. Several other writes accept the same body field, with stricter semantics — see Beyond POST /v1/tasks.
When to use
- Scheduled jobs. Cron fires at 9am Monday. Use
idempotency_key: "weekly-deck:2026-W17". If your worker crashes after the POST succeeds but before writing the response, the next retry returns the same task. - Network-timeout retries. Your HTTP client times out mid-POST. Retry with the same key — safe.
- Upstream-triggered flows. A Slack command fires two webhooks on the same action. Use the slack message ID as the key:
idempotency_key: "slack:T01234:123456.789". - Bulk fan-out. 50 prospects × one task each.
idempotency_key: "prospect:{id}:2026-04-weekly"keeps re-runs safe.
How it works
- First call: creates the task, returns the envelope.
- Second call with the same key: returns the original task’s envelope. The task lane matches on the key alone — it does not compare request bodies, so a reused key with a different prompt silently replays the first task rather than starting the new one.
id matches the original, that’s how you can tell.
The key is the intent. Because there is no body comparison and no conflict error on this endpoint, a stale key is a silent no-op, not a loud failure: derive the key from everything that makes the request distinct (week, prospect, revision), and mint a fresh one whenever you want fresh output.
Key design
- Stable and unique per logical operation. “weekly-deck” alone is bad (reused every week);
"weekly-deck:2026-W17"is good. - Hash complex inputs if the key would otherwise be long:
sha256(prompt + brand_kit_id + format). Deterministic + short. - Namespace per integration — keys are not isolated per API key. The task lane looks a key up globally, and the hashed lanes scope records to
(team, user, operation, key). Two of your integrations sharing a team can collide on a bare"weekly-deck"and silently replay each other’s result. Prefix them:"myapp:weekly-deck:2026-W17". - TTL. Idempotency records are retained long enough to cover sensible retry windows (24h on the hashed lanes) — don’t build around a specific duration. If you’re retrying a week later, use a different key.
Beyond POST /v1/tasks
idempotency_key is not task-only, and the other endpoints that take it are stricter than the task lane: they ride a shared pipeline that hashes the request payload alongside the key. Same key + same payload replays the stored result; same key + a different payload is a real 409 idempotency_conflict; a same-key duplicate that lands while the first call is still running gets a retryable 409 instead of a second side effect. Records expire after 24h.
POST /v1/brand-kits is the one to know — a retried create replays the stored kit instead of minting a second one (if the kit was deleted in the meantime, the stale record is invalidated and the create re-runs fresh). The metered verbs take it too. When in doubt, check the endpoint’s request schema for an idempotency_key field rather than assuming it has none.
Records are scoped to (team, user, operation, key) — not to the API key — so namespace your keys per integration.
Replay protection is best-effort, not a transaction. The store fails open: if Redis is unavailable or the record was evicted, the call executes without replay protection rather than 5xx-ing. A retry in that window can produce a second side effect. Treat idempotency_key as strong protection against the common failure (a timed-out retry), not as a guarantee of exactly-once.
POST /v1/uploads has no idempotency_key, and doesn’t need one: it dedupes by content hash and reports was_duplicate: true.
Conflict handling (hashed lanes)
- Use a different
idempotency_keyfor the new body. - Change your code so the body is byte-identical (e.g. canonicalize array order, don’t include a timestamp in the payload).
Not wired (yet)
Idempotency-KeyHTTP header (Stripe-style). Today,idempotency_keyis a body field. A header-based variant is on the roadmap; when it lands, existing body-field usage continues to work.
Webhook idempotency (separate concern)
Webhook receivers should use the event envelope’sid (evt_…) as the dedupe key. That’s about guarding your handler against duplicate deliveries — a different concern from idempotency_key on task creation. See webhooks.md.
Worked example — weekly cron
Common wrong guesses
- Using an
Idempotency-KeyHTTP header. Not supported today. Use the body fieldidempotency_key. - Reusing the same key across different operations. “default” as the key for every task. You’ll just keep getting back the first task you ever created with that key.
- Changing the body and reusing the key on
POST /v1/tasks. You get the old task back, not an error and not a new task — the task lane never compares bodies. Use a fresh key when the intent changed. (On the hashed lanes below, the same mistake is a loud409 idempotency_conflict.) - Assuming
idempotency_keyis task-only.POST /v1/brand-kitsand the metered verbs take it too, with the same replay /409semantics. Check the endpoint’s request schema.
Upstream
docs.moda.app/api/tasks/startTask— parameter referenceerrors.md— the full error envelope includingidempotency_conflict