# Retries, idempotency and rate limits

> How to retry a /v1 request safely with an Idempotency-Key, what a replay answers, and the request and sending limits.

A network error or a `5xx` does not tell you whether a send, call or start
went through. Send an `Idempotency-Key` and retry with the same key: the
retry never acts twice. When you go over a limit, wait the `Retry-After` the
answer gives you and retry the same way.

## Idempotency

Send an `Idempotency-Key` header on every request that sends, calls or starts
something. A retry with the same key never acts twice.

**Keys are per workspace.** Every API key and every script of the workspace
shares them, so two different requests of the same kind (the same route)
that happen to use one key (a fixed `order-1` in two scripts, or a key copied
from an example) collide. Use a new
unique value for each action, such as a UUID (the examples use
`$(uuidgen)`), and keep it to send the retry.

**A replay answers the resource as it is now.** The retry gets the first
answer's HTTP status code and the header `Idempotency-Replayed: true`, with
the message, call or campaign read again: a text's replay shows its current
`status` together with its current `sent_at`, `delivered_at`, `segments` and
`billed_credits`, never a mix of the first answer and the present. A
campaign create's replay keeps `audience.import` from the first answer (only
the import that ran can say what it did). When the resource can't be read,
the first answer is returned as it was.

| request | a retry while the first is still running | the same key, a different request |
|---|---|---|
| `POST /v1/messages` (SMS) | `409 send_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/messages` (WhatsApp) | `409 send_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/calls` | `409 call_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/campaigns` | `409 create_in_progress`; retry in a few seconds | `422 idempotency_key_reuse` |
| `POST /v1/campaigns/{id}/start` | `409 start_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/campaigns/{id}/resume` | `409 resume_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/campaigns/{id}/clone` | `409 clone_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/contacts` | `409 create_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/lists`, `POST /v1/lists/{id}/contacts` (per list) | `409 import_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |
| `POST /v1/conversations/{id}/messages` (per conversation) | `409 reply_in_progress` + `Retry-After` | `422 idempotency_key_reuse` |

Retry an `*_in_progress` with the same key after `Retry-After`; it never acts
twice. Pause and cancel take no key: repeating them is harmless (a pause of a
paused campaign, or a cancel of a canceled one, answers `200` with the
campaign). A `metadata` of `{}` is the same as none, for the replay check too.

`422 idempotency_key_reuse` and `400 invalid_idempotency_key` read the same on
every route: `param` is `"idempotency-key"`, with the same `message` and
`hint` and no other fields.

**SMS sends** go through the sending engine, which never sends a text twice
for one key, whatever happened to the request: a timeout, a `5xx` and a lost
connection are all safe to retry with the same key. If the first request
never finished (our server restarted mid-request), a retry keeps getting
`send_in_progress`, usually for up to about 25 minutes, then sends the text
itself.

**WhatsApp sends, calls, WhatsApp and voice campaign starts, contact and
list creates, list adds and inbox replies** keep their keys in one store,
with one rule:

- A `2xx` answer, and a `5xx` answer (whose outcome may be unknown), are kept
  and replayed for 24 hours.
- A `4xx` answer frees the key: nothing was done, so fix the cause and retry
  with the same key.
- Except a WhatsApp send refused after its message was stored (an opted-out
  number, a number pending verification or disconnected, a suspended
  workspace, a blocked destination): that refusal is kept and replayed for 24
  hours, like an answer that went through, so a retry never stores a second
  message. Once the cause is fixed, send again under a new key.
- Except an inbox reply: every answer but a `429` is kept and replayed for 24
  hours, since a refused reply may already be in the thread, marked not sent.
- A first request that never finished holds the key for up to 15 minutes;
  retries get its `*_in_progress` code meanwhile.
- When we cannot check the key, the request is refused with
  `503 idempotency_unavailable` and nothing is done. Retry with the same key.
- A retry of a WhatsApp or voice start that went through replays the start
  (the campaign as it is now), even though that start made its preview out
  of date.

And for every key:

- **The format.** A key is 1 to 255 characters, each printable ASCII from `!`
  to `~` (0x21 to 0x7E, the regex `^[!-~]{1,255}$`): letters, digits and
  punctuation. A space inside it, a character outside ASCII (`é`, an emoji) or
  an empty header is `400 invalid_idempotency_key`; spaces at either end are
  trimmed first.
- **Keys belong to the workspace**, not to the API key: two keys of the same
  workspace sending the same `Idempotency-Key` get the same answer.
- **Use a new key for a new attempt.** A send that was accepted and then
  failed is a finished answer: retrying its key returns that failure. A text
  that never went out ends `failed` with `error.category` `not_sent`; send it
  again under a new key.

## Rate limits

Over a limit, the answer is `429` with `code: "rate_limited"` (or a more
precise code such as WhatsApp's `daily_limit_reached`), a `Retry-After` header
in seconds and, where the limit says, `retry_after` inside `error`. Wait that
long and retry with the same `Idempotency-Key`.

| limit | applies to | over it |
|---|---|---|
| **300 reads** (`GET`, `HEAD`) and **100 writes** (every other method) a second **per API key** | every messaging route and `/v1/webhooks`, counted together (`/v1/events`, `/v1/media` and `/v1/recordings` have their own, below) | `429 rate_limited` |
| 10 texts a second, a burst of 50, **per workspace and per API key** (a send must fit both) | `POST /v1/messages` with `channel: "sms"` | `429 rate_limited` |
| 8 keyed SMS sends at once per server; a ninth waits briefly | `POST /v1/messages` with `channel: "sms"` | `429` with `Retry-After` |
| template sends through the API, by default 300 a minute with a burst of 10 a second, per workspace | `POST /v1/messages` with `channel: "whatsapp"` | `429 workspace_rate_limited` |
| the WhatsApp number's own budget per second, minute and hour (shared with the inbox) | WhatsApp sends | `429 rate_limited` |
| the WhatsApp number's daily template cap (shared with campaigns) | WhatsApp sends | `429 daily_limit_reached` |
| 10 calls a minute per workspace, and the workspace's daily calling minutes | `POST /v1/calls` | `429 rate_limited`; `400 daily_voice_minutes_exceeded` |
| 60 requests a minute **per API key** and 600 **per workspace**, each route counted on its own | `/v1/events`, `/v1/media`, `/v1/recordings` | `429 rate_limited` (with `retry_after`) |

**The per-key limit** counts only requests the key authenticates, a refused
one too. Each answer carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
`X-RateLimit-Reset` (Unix time in seconds, when the current second's window
ends, by the limiter's own clock) for the kind of request it was. If the
counter is ever unavailable the request goes through and the three headers
are left out. `/v1/events`, `/v1/media` and `/v1/recordings` carry the same
three headers for their per-minute limit (the one with less left).

**The sending limits are checked after it**, so a burst of texts meets the
send bucket first. When the send limiter itself is briefly unavailable the
answer is `503 rate_limiter_unavailable`, and any `503` with `Retry-After`
means sending is briefly unavailable: retry after that many seconds.

Every error code: [Errors](/platform-api/errors/).