Idempotency
How `Idempotency-Key` works, when to set it, and how GenieOS keeps retries safe for 24 hours.
Every mutating endpoint accepts an Idempotency-Key header. The same key
returns the same response for 24 hours, so retries from anywhere — your
queue, your Cloud Functions trigger, your CLI loop — are safe by default.
POST /v1/transactional/send HTTP/1.1
Authorization: Bearer mg_live_...
Idempotency-Key: ord_8a72c0e1-checkout-confirmation
Content-Type: application/json
{ "to": "ada@example.com", "template": "checkout_confirm", "variables": { ... } }If the request had already succeeded, you\u2019ll receive the original response — same id, same status, same headers — with one extra header:
Idempotent-Replay: trueIf the prior request failed mid-flight (5xx, network reset), GenieOS forgets the partial outcome and retries afresh. You never get stuck with "this key is poisoned, throw it away."
The rules
- Keys are scoped to (workspace, endpoint). The same key sent to
/transactional/sendand/sequences/enrollare independent. - Keys are case-sensitive opaque strings, max 200 chars. Use whatever uniqueness you have lying around — order id, payment id, message id, or a UUID. Reusing keys across distinct logical operations is a bug — you\u2019ll get the wrong receipt back.
- Keys live for 24 hours. After that, the deduplication record is garbage-collected and the key becomes available for reuse.
- Body must match. Replaying a key with a different body is treated as
a programming error and rejected with
409 idempotency_key_mismatch. This catches bugs where two distinct operations accidentally share a key.
Which endpoints honour it
Every mutating endpoint accepts the header; reads ignore it.
| Endpoint | Idempotent | Notes |
|---|---|---|
POST /v1/transactional/send | yes | Most common use |
POST /v1/sequences/{id}/enroll | yes | Key off contact id + sequence id |
POST /v1/sequences/{id}/transition | yes | |
POST /v1/events | yes | Key off your event\u2019s primary key |
POST /v1/templates | no* | Authoring is interactive |
POST /v1/webhooks | yes | Key off (url, scope) |
DELETE /v1/webhooks/{id} | n/a | DELETEs are naturally idempotent |
* You can still pass the header on template authoring; it\u2019ll be recorded in the audit log but not used to dedupe.
Picking a key
The right key is stable, unique, and meaningful in your domain. The wrong key is a UUID generated at the call site (because the next retry will generate a different one).
Good:
// Stable: derived from the order id, the action, and the recipient.
const key = `order:${order.id}:checkout_confirm:${customer.id}`;Bad:
// Wrong: a fresh UUID per call defeats the whole point.
const key = crypto.randomUUID();For background jobs where the runner already owns a job id, use that:
key = f"job:{job_id}:notify"What the SDKs do for you
Both SDKs auto-generate a key for any mutating call, so you get idempotency for free even if you never think about it.
The recipe:
- Node SDK: if you don\u2019t pass
idempotencyKey, the SDK derives one from a hash of the JSON body. Pass your own to use a stable, domain-shaped key (recommended). - Python SDK: identical.
// Auto-generated: safe but body-derived (a payload change rotates the key).
await mg.templates.send({ to, template: 'welcome', variables: { first_name: 'Ada' } });
// Recommended: stable, domain-shaped.
await mg.templates.send(
{ to, template: 'welcome', variables: { first_name: 'Ada' } },
{ idempotencyKey: `welcome:${userId}` },
);Why both?
Auto-keys mean you can\u2019t accidentally send a duplicate from a retry loop. Domain-keys make it explicit which operation you\u2019re deduping. In production code, prefer domain-keys; in test code, the auto-keys are fine.
Failure modes
| Status | Code | What it means |
|---|---|---|
| 409 | idempotency_key_mismatch | Same key, different body. Either the bodies should match, or pick a new key. |
| 409 | idempotency_key_in_progress | Two writers are racing the same key. The second loser should just retry. |
| 422 | idempotency_key_invalid | Key is missing, empty, or longer than 200 chars. |
The _in_progress case is rare and self-healing — wait 250ms and retry.
How long is "in flight"?
A request holds the lock until it returns to the caller, with a hard
ceiling of 120 seconds. If your client has a tighter timeout and you
retry, the second call will see _in_progress until the first finishes (or
hits the ceiling). The SDKs handle this for you with bounded backoff.
Writing your own retry loop
If you\u2019re calling the API directly, the canonical retry policy is:
attempt 1 (immediate)
attempt 2 after 1 s
attempt 3 after 2 s
attempt 4 after 4 s
attempt 5 after 8 s \u2190 give up after thisRetry on:
- Network errors (DNS, ECONNREFUSED, ECONNRESET, timeout).
- HTTP 408, 429, 500, 502, 503, 504. (Honour
Retry-Afterif present.) - HTTP 409 with code
idempotency_key_in_progress.
Do not retry on:
- 4xx other than the above (they\u2019re your bug, not a transient failure).
- 401 / 403 (rotate the key first).
The SDKs implement this policy for you. If you\u2019re calling raw HTTP, copy it.