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_REUSEDas 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.