Skip to content

Retries, idempotency and rate limits

Download .md

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.

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.

requesta retry while the first is still runningthe same key, a different request
POST /v1/messages (SMS)409 send_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/messages (WhatsApp)409 send_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/calls409 call_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/campaigns409 create_in_progress; retry in a few seconds422 idempotency_key_reuse
POST /v1/campaigns/{id}/start409 start_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/campaigns/{id}/resume409 resume_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/campaigns/{id}/clone409 clone_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/contacts409 create_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/lists, POST /v1/lists/{id}/contacts (per list)409 import_in_progress + Retry-After422 idempotency_key_reuse
POST /v1/conversations/{id}/messages (per conversation)409 reply_in_progress + Retry-After422 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.

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.

limitapplies toover it
300 reads (GET, HEAD) and 100 writes (every other method) a second per API keyevery 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 brieflyPOST /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 workspacePOST /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 sends429 rate_limited
the WhatsApp number’s daily template cap (shared with campaigns)WhatsApp sends429 daily_limit_reached
10 calls a minute per workspace, and the workspace’s daily calling minutesPOST /v1/calls429 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/recordings429 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.