Every request to /v1 can fail, and some failures are worth trying again.
This guide shows how to tell them apart, how to retry safely, and which limits
your workspace has. The API reference has the rules in full: see its
Introduction.
1. Read the error
Section titled “1. Read the error”Every error has the same shape, whatever the endpoint:
{ "error": { "code": "template_params", "message": "The template variables don't match the template.", "details": { "missing": ["2"], "unexpected": [] }, "request_id": "req_8Yq2LhT4mWc9Rk1Zp6Ns" }}| Field | Always there | What it holds |
|---|---|---|
code |
Yes | What went wrong, from the table below. Act on this, not on message. |
message |
Yes | What went wrong, in plain English for you. It can change wording. |
field |
No | A JSON path to the field at fault, like template.name. Left out when no single field is at fault. |
details |
No | More about the error. Only the codes that list it in the table have it. |
request_id |
Yes | The request’s ID, also in the X-Request-ID header of every response. Quote it when you contact support. |
The codes never change meaning. New ones may be added: treat a code you don’t know by its HTTP status.
2. Decide whether to retry
Section titled “2. Decide whether to retry”| Status | Codes | What to do |
|---|---|---|
429 |
rate_limited |
Wait the Retry-After seconds, then send it again. |
503 |
unavailable |
Wait the Retry-After seconds, then send it again. |
500 |
internal_error |
Send it again after a few seconds, with the same Idempotency-Key. If it keeps failing, contact support with the request_id. |
409 |
idempotency_in_progress |
The first request with this Idempotency-Key is still running. Wait a second or two and send it again. |
Any other 4xx |
Don’t retry as it is. Fix the request, or the thing it names. |
Back off between tries, for example 1, 2, then 4 seconds, and give up after a
few. Retry only with an Idempotency-Key on a POST, so a retry never sends
a message twice.
3. Make retries safe with an Idempotency-Key
Section titled “3. Make retries safe with an Idempotency-Key”Send an Idempotency-Key header on any POST. Use a value that’s unique to
the thing you’re doing, like your own order ID: 1 to 255 printable ASCII
characters.
curl https://app.soosh.io/v1/messages \ -H "Authorization: Bearer $SOOSH_API_KEY" \ -H "Idempotency-Key: order-1042-shipped" \ -H "Content-Type: application/json" \ -d '{"to": "+919876543210", "type": "text", "text": {"body": "Your order has shipped."}}'The key belongs to the API key that sent it, and to the keys you rotate it
into: a retry with the rotated key gets the same answer. The same request
under the same Idempotency-Key from any other of your API keys gets 422 idempotency_mismatch. Soosh keeps it for 24 hours.
| You send the same key again… | You get |
|---|---|
| after the first request finished | The first response again, with Idempotent-Replayed: true and Original-Request-ID naming the first request. |
| while the first request is still running | 409 idempotency_in_progress |
| after the first request crashed (60 seconds without finishing) | The request runs again. |
| with a different method, path or body, or from an API key that isn’t this one or rotated from it | 422 idempotency_mismatch |
Errors are kept too: a 422 window_closed
stays window_closed for 24 hours under that key. A 5xx isn’t kept, so the same key can be retried. Send a
new key once you’ve fixed a request.
Use one on POST /v1/messages, POST /v1/broadcasts and
POST /v1/contacts at least.
4. Page through lists
Section titled “4. Page through lists”A list returns at most limit items (1 to 100, 25 if you leave it out) and a
next_cursor (see Pagination):
{ "data": [ … ], "next_cursor": "eyJzIjoiY3JlYXRlZF9hdCIs…" }To get the next page, send the same request with cursor set to
next_cursor. When next_cursor is null, you’ve reached the end. Lists
don’t give a total count.
A cursor only works with the sort it was made for. A changed cursor, or one
used with another sort, is 400 invalid_request. Lists that sync data
(contacts, messages and templates) take sort=updated_at, which runs oldest
change first, so you can keep the last cursor and carry on from it later.
A list by sort=updated_at shows a change about 30 seconds after it’s saved,
so that changes still being saved can land first. That catches almost all of
them, not every one: when you carry on later, go back a minute before your
last cursor’s time and skip items whose updated_at you already have. See
Keep contacts in sync.
5. Stay inside your rate limits
Section titled “5. Stay inside your rate limits”| Limit | Counts | Usually |
|---|---|---|
api_requests_per_minute |
Every request from all of your workspace’s keys together | 600 a minute |
api_messages_per_second |
POST /v1/messages |
20 a second |
| Heavy requests | POST /v1/broadcasts, POST /v1/templates, POST /v1/media and GET /v1/usage together |
10 a minute |
Your plan sets the first two. A plan with no limit on them is held at the
most Soosh allows: 60,000 requests a minute and 500 messages a second.
GET /v1/usage shows the values your workspace has. Broadcasts don’t count
against api_messages_per_second; they count against
broadcast_messages_per_month.
Each limit runs in fixed windows of a minute or a second. Every response to a valid key carries the workspace limit (see Rate limits):
| Header | What it holds |
|---|---|
X-RateLimit-Limit |
Requests your workspace can make each minute. |
X-RateLimit-Remaining |
Requests left in this minute. |
X-RateLimit-Reset |
Seconds until the minute starts again. |
Over a limit you get 429 rate_limited with Retry-After. To send many
messages at once, spread them out, or use a broadcast.
6. Keep requests under the size limits
Section titled “6. Keep requests under the size limits”| Request | Largest body | Over it |
|---|---|---|
| Any JSON request | 1 MB | 400 invalid_request |
POST /v1/broadcasts |
4 MB | 400 invalid_request |
POST /v1/media |
5 MB for an image, 14 MB for other files | 413 too_large |
Send bodies as they are: a compressed body (Content-Encoding) is
415 unsupported_media_type.
7. Watch messages after they’re accepted
Section titled “7. Watch messages after they’re accepted”POST /v1/messages answers 201
once the message is checked and saved, before WhatsApp has it. A 201 means
Soosh will send it, even if it stays pending for a minute: don’t send it
again. What happens next shows in the status of the
Message, in
GET /v1/messages/{id}, and in the
message.sent and
message.status webhooks:
pendingwhile it’s on its way to WhatsApp.sent,deliveredandreadas it moves forward.failedwith anerror: WhatsApp’s errorcode(0 if unknown) and amessage. For example, code 131047 means more than 24 hours have passed since the customer last wrote, and you need a template.
If Soosh sent a message but WhatsApp didn’t confirm it, the message stays
pending rather than being sent again. It settles when WhatsApp reports its
status, or fails after 15 minutes with “WhatsApp didn’t confirm this message
in time”. A message that couldn’t be sent at all in 15 minutes fails with
“Not sent in time”. See
When a message fails later.
Every error code
Section titled “Every error code”The same codes, with the endpoints that return them, are in Errors in the API reference.
| Status | Code | When | details |
|---|---|---|---|
400 |
invalid_request |
The JSON or form can’t be read, has a field the endpoint doesn’t take or a wrong type, is over the size limit, or has a bad cursor or query value. | |
401 |
unauthorized |
The key is missing, malformed, unknown, revoked or expired. The response doesn’t say which. | |
403 |
ip_not_allowed |
The key has allowed IP addresses, and the request came from another one. | |
403 |
scope_missing |
The key doesn’t have the scope the endpoint needs. | scope |
403 |
feature_not_in_plan |
Your plan doesn’t include the feature, such as api.messages. |
feature |
403 |
workspace_closed |
The workspace is closed. | |
404 |
not_found |
No such path, or no such object. Objects of another workspace are not_found too. |
|
405 |
method_not_allowed |
The path exists, but not with this method. The Allow header lists the methods it takes. |
|
409 |
conflict |
It already exists, such as a contact with this number. | id of the one that exists |
409 |
invalid_state |
The object’s state doesn’t allow it, such as cancelling a completed broadcast, or deleting a template a scheduled or running broadcast uses. | |
409 |
idempotency_in_progress |
A request with this Idempotency-Key is still running. |
|
413 |
too_large |
The file is over the size for its type. | |
415 |
unsupported_media_type |
The file isn’t a type the API takes, or the body is compressed. | |
422 |
invalid_field |
A value failed a check. field says which. |
id of the draft, only when WhatsApp refused a new template |
422 |
invalid_phone |
The phone number isn’t valid. | |
422 |
idempotency_mismatch |
This Idempotency-Key was used with a different request. |
|
422 |
window_closed |
A message other than a template, and the contact hasn’t written to that number in the last 24 hours. | |
422 |
template_not_approved |
The template isn’t approved. | |
422 |
template_params |
The template’s variables don’t match the ones you sent. | missing, unexpected, and for broadcasts rows |
422 |
template_ambiguous |
More than one of your numbers has a template with this name and language. Send number_id, or name the template by template.id. |
|
422 |
contact_opted_out |
A marketing template to a contact who opted out of marketing messages. | |
422 |
number_unavailable |
The sending number isn’t active. | |
422 |
limit_reached |
A limit of your plan is reached, such as contacts or broadcast messages this month. | limit, limit_value, used |
429 |
rate_limited |
Too many requests. Comes with Retry-After. |
|
500 |
internal_error |
Something went wrong on our side. | |
503 |
unavailable |
Part of Soosh isn’t available just now. Comes with Retry-After. |
When a request has several problems, you get the first in this order: body
size, the key, the IP address, rate limits, scope, plan, closed workspace,
reading the body, the Idempotency-Key, then the checks on the values.