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

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

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

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

- [Transactional messages](/docs/api/transactional) — sends that take an `Idempotency-Key`
- [Errors](/docs/api/errors) — every code, including `IDEMPOTENCY_KEY_REUSED`
- [Rate limits](/docs/api/rate-limits) — back off correctly before you retry