An idempotency key is a value you send with a write so that a retry with the same key and request body doesn’t repeat the action. For most writes, the retry replays the stored first response, kept for 24 hours. A few operations, transactional sends among them, keep their key for longer and return what the first request created as it stands now. Each section below says which applies.
A network timeout does not tell you whether the request arrived. Retrying blindly can send
a message twice. Every write sent as POST takes an Idempotency-Key (a batch, one per
item), and a few operations also keep their key for longer or are safe to repeat by their
nature — know which applies before you build retries.
Four things can repeat a request, and they are separate:
- Your retry of an API call. This guide. You decide whether it is safe.
- Kirora’s retry to the email or push provider. A temporary refusal or an outage is
retried for about 10 minutes for a transactional message, and for hours for campaigns
and journeys, before Kirora gives up. A message the provider refuses outright is
marked
failedand is not handed over again. - Webhook redelivery. A webhook that fails is sent again on a schedule, and you can resend a delivery by hand. See Webhooks.
- A deliberate resend. Sending again on purpose, with a new key, is a new send.
1 Idempotency-Key on every POST that writes
Every POST under /v1 that writes takes an Idempotency-Key header (8–255
characters). It is optional on most; the operation reference says where it is required,
as it is for a campaign trigger. Send one with any write you might retry. The few reads sent as POST, such
as previews and reports (retry kind read, below), take none: each call answers from the
data as it is then.
POST /v1/campaigns
Authorization: Bearer kirora_…
Idempotency-Key: 5f0c1b9e-6a7d-4c38-9b0e-2d1f7a4e8c21
Content-Type: application/json| Situation | Result |
|---|---|
| First request with this key | Runs as usual. Its response is kept for 24 hours. |
| Retry with the same key and same request | The first response again, status and body, with Idempotency-Replay: true. Nothing runs a second time. |
| Same key, different request (another body, path or query) | 409 IDEMPOTENCY_KEY_REUSED. Nothing runs. |
| Same key while the first request is still running | 409 IDEMPOTENCY_KEY_IN_USE. Wait a moment and retry with the same key to get its response. |
Same key after an unsafe first request failed or never returned |
409 IDEMPOTENCY_KEY_OUTCOME_UNKNOWN. It may have taken effect: check, then retry with a new key. |
What is kept and replayed:
- A response the operation gave, success or refusal (a 404, a 409), is replayed as it was. To try again after a refusal you have fixed, use a new key.
- A request refused before it ran — a validation error, a missing scope, a rate limit — keeps nothing, so you can retry it with the same key.
- A
5xx, or a request that has not returned after two minutes, keeps nothing: the retry runs again. The exception is an operation whose retry kind isunsafe(it creates or sends something, listed below): that first request may have taken effect, so the retry is refused with 409IDEMPOTENCY_KEY_OUTCOME_UNKNOWNinstead of running it a second time. Check whether it happened, and use a new key if it did not. - A replay is the stored response, not a fresh read: read the resource for its state now.
Keys belong to the workspace, not to the API key that sent them, and are compared exactly
(case matters; surrounding whitespace is ignored). After 24 hours the key is free and a
request with it runs again. An upload (uploadAsset) is matched on its file and its
name: a different file under a key already used is 409 IDEMPOTENCY_KEY_REUSED.
The operations below keep their key for longer than 24 hours, on what they create.
Single call or batch item. A single event or transactional send takes its key in the
Idempotency-Key header. Each item of a batch takes its own key in the body, as
idempotencyKey beside the item. Both are optional, and both reach the same keys: an
event tracked singly is recognised when it arrives again in a batch, and the reverse.
Event keys and transactional keys are separate, so order-1001 can name one of each.
2 Idempotency keys — transactional sends
POST /v1/transactional/messages takes an optional Idempotency-Key header. Without
one, every request sends, so a retry after a timeout can deliver the message twice. With
one, a retry is safe:
POST /v1/transactional/messages
Authorization: Bearer kirora_…
Idempotency-Key: order-18f3a-receipt
Content-Type: application/jsonThe 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
(once sending finishes for a sensitive send, otherwise after the workspace retention
period).
A transactional key stays with the message it created. Messages are kept after the
workspace’s history period, with the recipient’s personal data removed, so a
transactional retry is recognised however late it comes. An event key is kept for a year
from the event’s occurredAt, even when the plan keeps the event itself for less. After
that the same key records a new event, so a backdated event’s key is kept for less time.
A send can create a contact: an email send to an address no contact has creates a transactional-only one. A retried send does not create a second one, but the first attempt may already have created it, even when that attempt was refused (see below).
A replayed response carries Idempotency-Replay: true, so a client can tell a retry
that was replayed 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 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 may carry its own
idempotencyKey (distinct within the call) and its result says replayed; an entry
without one returns idempotencyKey: null.
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 — binds no key and creates no message, so you can fix the
request and retry with the same key. A refusal for a suppressed or unreachable
recipient comes after the contact is resolved, so a contact the request created or
updated stays. 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 returns that same message with
Idempotency-Replay: true, or 409 IDEMPOTENCY_KEY_REUSED if its payload differs.
The last row of the table above 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 attemptGenerate 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.
3 State-based idempotency — campaign sends
POST /v1/campaigns/{id}/send and /schedule need no idempotency key. A
campaign is sent at most once: sending one that has already gone returns it again, and
scheduling one for the time it is already scheduled for returns it as it stands, even
once that send has started. 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. Its response is the campaign as it stands now, which
may no longer be what the first attempt returned. Scheduling a campaign for another time
moves it there while its send has not begun, so a repeat of that converges too. Only a
genuinely different request — another time once the send has begun, or sending a campaign
someone archived meanwhile — returns 409 CONFLICT.
POST /v1/journeys/{id}/activate and /pause move a journey to a state, and a repeat
returns its current state. A state can still refuse: activating a journey an agent wrote
or edited, before a person has confirmed it, returns 409 AGENT_CONFIRMATION_REQUIRED,
and activating an ended journey returns 409.
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 response, 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
The reference page of every write says which of these applies to it:
| Kind | What a retry does |
|---|---|
key |
Send an Idempotency-Key. A retry with the same key and body repeats nothing. Most writes replay the first response; a transactional send returns the message as it stands now. |
entries |
Each entry carries its own idempotencyKey. A retried entry replays. |
state |
Safe to retry. A repeat returns the current state, or 409 once it has moved on from it. |
converges |
Safe to retry. A repeat sets the same values again. |
unique |
A repeat after success is refused with 409 ALREADY_EXISTS. Read the one the first attempt created. |
done |
A repeat after success returns 404 or 409. Treat that as done. |
read |
Reads only. Safe to retry. |
unsafe |
A repeat without a key does it again. Send an Idempotency-Key and a retry within 24 hours replays the first response. |
Every other POST takes a key too (section 1), so any of them can be retried within 24
hours without thinking about its kind. Every GET is safe to retry. Examples of each kind for writes:
| Kind | Operations |
|---|---|
key |
sendTransactionalMessage, trackEvent, triggerCampaign |
entries |
sendTransactionalMessagesBatch, trackEventsBatch |
state |
sendCampaign, activateJourney, pauseJourney, publishTemplate, publishForm, unpublishForm, cancelScheduledTransactionalMessage, deleteWorkspace |
converges |
identifyContact, updateContact, scheduleCampaign, addListContacts, deleteListContacts, addSuppressions, deleteSuppressions, defineEventName, describeAttribute |
unique |
createList, createSegment, createWebhook, createTopic, createSendingDomain, createConversionGoal |
done |
cancelCampaign, unscheduleCampaign, endJourney, mergeContacts, deleteEventName, and every other delete |
read |
previewSegment, renderTemplate, renderTemplateContent, previewCampaign, simulateJourney, getFunnelReport |
unsafe |
createCampaign, createTemplate, createJourney, createForm, submitForm, uploadAsset, importAsset, every duplicate…, sendTemplateTest, testWebhook, redeliverWebhookDelivery, rotateWebhookSecret, createMessageExport, createEmbedSession |
Notes on the less obvious ones:
unsafecreates make a second resource on a retry without a key. Send anIdempotency-Key; without one, list before you retry and look for the resource the first attempt may have made.createTemplateis refused withALREADY_EXISTSonly when the request names an alias that exists.sendTemplateTestandtestWebhooksend again.redeliverWebhookDeliverydelivers again.rotateWebhookSecretissues a new secret on every call without a key. With one, a retry within 24 hours returns the same secret. Without one, the original secret keeps signing for 24 hours, so deploy the secret from the last response you receive. See Webhooks.- A
stateoperation can return 409 where the first attempt succeeded, for examplechooseAbTestWinnerretried after the winner’s send has begun. Read the resource to see where it is. identifyContactconverges, but a send that names a new address also creates a contact (see above).
Events
POST /v1/events/track takes an optional Idempotency-Key header (8–255 characters, as
everywhere). Send one: events drive journey triggers, so without a key a retry after a
timeout records the event twice and can enroll a contact twice. Derive the key from your
own id for the occurrence (a Segment messageId is one). A repeat of a key returns the receipt (id, occurredAt) of the event
first recorded under it; the same key with a different event returns 409 IDEMPOTENCY_KEY_REUSED, as for
sends.
POST /v1/events/batch takes up to 100 events, each with its own optional
idempotencyKey in the body in place of the header; an entry without one is recorded every
time it is sent. Results come back in order: an entry already stored returns
replayed: true, and one refused carries the error a single track would have
returned 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.