GenieOSdocs

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: true

If 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/send and /sequences/enroll are 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.

EndpointIdempotentNotes
POST /v1/transactional/sendyesMost common use
POST /v1/sequences/{id}/enrollyesKey off contact id + sequence id
POST /v1/sequences/{id}/transitionyes
POST /v1/eventsyesKey off your event\u2019s primary key
POST /v1/templatesno*Authoring is interactive
POST /v1/webhooksyesKey off (url, scope)
DELETE /v1/webhooks/{id}n/aDELETEs 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

StatusCodeWhat it means
409idempotency_key_mismatchSame key, different body. Either the bodies should match, or pick a new key.
409idempotency_key_in_progressTwo writers are racing the same key. The second loser should just retry.
422idempotency_key_invalidKey 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 this

Retry on:

  • Network errors (DNS, ECONNREFUSED, ECONNRESET, timeout).
  • HTTP 408, 429, 500, 502, 503, 504. (Honour Retry-After if 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.

On this page