Menu

Idempotency

Which operations are safe to retry, and the two different mechanisms

An idempotency key is a value you send with a write so that a retry returns the first result instead of repeating the action.

A network timeout does not tell you whether the request arrived. Retrying blindly can send a message twice. Two different mechanisms protect against that here — know which applies before you build retries.

1 Idempotency keys — transactional and push sends

POST /v1/transactional/messages and POST /v1/push/messages require an Idempotency-Key header; a request without one fails validation:

POST /v1/transactional/messages
Authorization: Bearer kirora_…
Idempotency-Key: order-18f3a-receipt
Content-Type: application/json

The key must be 8–255 characters. Generate one per logical operation — a UUID, or a deterministic id derived from whatever you are acting on.

Behaviour:

Situation Result
First request with this key Accepted. The key is bound to the message the moment it is queued, before anything is sent.
Retry with the same key and same payload That message is returned, as it stands now. Nothing is sent a second time.
Same key, different payload 409 IDEMPOTENCY_KEY_REUSED. Nothing is sent.

A transactional retry must repeat the whole request exactly: the key is bound to a hash of the body, so it is recognised even after the content itself has been discarded (at once for a sensitive send or retainContent: false, otherwise after the workspace retention period).

Keys do not expire on a timer: a key is remembered as long as the message or event it created, so a retry is recognised however late it comes.

A replayed response carries Idempotency-Replay: true, so a client can tell a retry that was answered from the first attempt from a fresh send. The replay reads the message as it is now, not a stored copy of the first response: its status may have moved on from queued to delivered, or to failed if the provider refused it. A retry never re-sends a message that failed to deliver; send again with a new key for that.

Keys belong to the workspace, not to the API key that sent them, and are compared exactly (case matters; surrounding whitespace is ignored). A key used on POST /v1/push/messages does not collide with the same key on POST /v1/transactional/messages. A single transactional send and an entry of POST /v1/transactional/messages/batch share keys, so a batch retry recognises sends that first went out one at a time. In the batch, each entry carries its own idempotencyKey and its result says replayed.

When a request fails

A key is bound only when the message is accepted. A request that is refused — failed validation, a missing scope, TEMPLATE_NOT_PUBLISHED, a suppressed or unreachable contact, a suspended workspace — records nothing, so you can fix the request and retry with the same key. A 500 or a dropped connection may come before or after the message was accepted; retrying with the same key is safe either way, and tells you which.

Concurrent requests

Two requests with the same key that arrive together still create one message. Whichever is stored first wins; the other answers with that same message and Idempotency-Replay: true, or with 409 IDEMPOTENCY_KEY_REUSED if its payload differs.

That last row of the table is the important one. The key is bound to the request it first accompanied, so reusing a key for a different message is refused rather than silently ignored. Derive keys from the thing being sent, never from a counter or a timestamp:

// Good — stable across retries, unique per operation
const idempotencyKey = `receipt-${orderId}`;

// Bad — a retry generates a new key and sends a second message
const idempotencyKey = randomUUID();          // regenerated on each attempt

Generate the key once, before the first attempt, and reuse it for every retry of that operation. An SDK that generates a key inside its own retry loop has defeated the mechanism entirely.

2 State-based idempotency — campaign sends

POST /v1/campaigns/{id}/send and /schedule take no idempotency key and need none. A campaign is sent at most once: sending one that has already gone returns it again, and scheduling one already scheduled for the same time returns it unchanged. Neither sends a second time. The campaign’s own status is the record of whether the send happened.

So a retry after a timeout is safe, and its answer is the same as the first attempt’s. Only a genuinely different request — scheduling for another time, or sending a campaign someone archived meanwhile — answers 409 CONFLICT. POST /v1/journeys/{id}/activate and /pause are simpler still: they move a journey to a state, and moving it to the state it is already in is not an error.

POST /v1/campaigns/{id}/trigger is different: it requires an Idempotency-Key, because its recipients are in the request, not saved on the campaign. A retry with the same key and request returns the first answer, notFound included, with Idempotency-Replay: true; the same key with another request is 409 IDEMPOTENCY_KEY_REUSED. A campaign is triggered once: any other trigger of one already sent is 409 CONFLICT — duplicate it to send again.

What is safe to retry

Always safe Every GET. Every DELETE — deleting something already deleted returns 404, not a second deletion.
Safe POST /v1/campaigns/{id}/send, /schedule, /v1/journeys/{id}/activate, /pause — state-based
Safe with a key POST /v1/transactional/messages (and /messages/batch, a key per entry), POST /v1/push/messages, POST /v1/campaigns/{id}/trigger, POST /v1/events/track (and /v1/events/batch, a key per entry)
Safe by nature POST /v1/contacts/identify — an upsert keyed on identity, so repeating it converges rather than duplicating
Safe POST /v1/groups/{id}/contacts and /contacts/remove — already-members are skipped, non-members ignored; the response reports what actually changed, and notFound lists identifiers that matched no contact
Safe by uniqueness POST /v1/groups, /v1/segments, /v1/webhooks — a retry answers 409 ALREADY_EXISTS (details.field names the name or URL), which tells you the first attempt took
Not safe POST /v1/messages/exports (takes no key; a retry creates a second export), POST /v1/campaigns, /v1/templates, /v1/forms, /v1/journeys, /v1/shared-blocks, /v1/assets and every …/duplicate — a retry creates a second resource

For that last row, list before you retry: look for the resource the first attempt may have made before creating another.

Events

POST /v1/events/track also requires an Idempotency-Key header (8–255 characters, as everywhere). Events drive journey triggers, so a duplicate event could enrol a contact twice — derive the key from your own event id. A repeat of a key returns the event first recorded under it; the payload is not compared, so there is no IDEMPOTENCY_KEY_REUSED here.

POST /v1/events/batch takes up to 100 events, each with its own idempotencyKey in the body in place of the header. Results come back in order: an entry already stored answers with replayed: true, and one refused carries the error a single track would have answered without stopping the rest. Retry a failed batch whole; the keys keep every event to one copy.

In an SDK

  • Never generate an idempotency key inside the retry loop.
  • Let callers pass their own key, and document that it must be stable across retries.
  • Treat IDEMPOTENCY_KEY_REUSED as a caller error worth surfacing loudly. It almost always means a key is being reused for genuinely different payloads, which is a bug in the calling code and will quietly stop protecting anything.

Next steps