Retries, idempotency and rate 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
Section titled “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
2xxanswer, and a5xxanswer (whose outcome may be unknown), are kept and replayed for 24 hours. - A
4xxanswer 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
429is 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_progresscode meanwhile. - When we cannot check the key, the request is refused with
503 idempotency_unavailableand 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 is400 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-Keyget 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
failedwitherror.categorynot_sent; send it again under a new key.
Rate limits
Section titled “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.