# Idempotency

Source: https://kirora.io/docs/api/reference/idempotency

Make retries safe with Idempotency-Key on every POST, and learn which operations are safe to retry without one.

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 `failed` and 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](https://kirora.io/docs/api/webhooks#retries).
- **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.

```http
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 is `unsafe` (it
  creates or sends something, listed below): that first request may have taken effect, so
  the retry is refused with 409 `IDEMPOTENCY_KEY_OUTCOME_UNKNOWN` instead 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:

```http
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
(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:

```ts
// 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.

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

- `unsafe` creates make a second resource on a retry without a key. Send an
  `Idempotency-Key`; without one, list before you retry and look for the resource the
  first attempt may have made. `createTemplate` is refused with
  `ALREADY_EXISTS` only when the request names an alias that exists.
- `sendTemplateTest` and `testWebhook` send again. `redeliverWebhookDelivery` delivers
  again.
- `rotateWebhookSecret` issues 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](https://kirora.io/docs/api/webhooks#verifying-the-signature).
- A `state` operation can return 409 where the first attempt succeeded, for example
  `chooseAbTestWinner` retried after the winner's send has begun. Read the resource to
  see where it is.
- `identifyContact` converges, 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_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

- [Transactional messages](https://kirora.io/docs/api/transactional) — sends that take an `Idempotency-Key`
- [Errors](https://kirora.io/docs/api/reference/errors) — every code, including `IDEMPOTENCY_KEY_REUSED`,
  `IDEMPOTENCY_KEY_IN_USE` and `IDEMPOTENCY_KEY_OUTCOME_UNKNOWN`
- [Rate limits](https://kirora.io/docs/api/reference/rate-limits) — back off correctly before you retry