Authentication, scopes and errors
Every request carries an API key. This page covers keys, scopes, limits, errors, idempotency and pagination: the rules every endpoint shares.
API keys
Create a key in the dashboard under Settings → API & Webhooks. It is shown in full exactly once: we store a hash of it and cannot show it to you again. If you lose it, revoke it and make another.
A key looks like this:
cbk_live_7mK2pQx9Lw4t.k3nR8vYc1pA6sD2fG9hJ0lZ4xW7bN5mQ8tE1rU3yI6oSend it in either header. Use whichever your HTTP client makes easy:
X-Api-Key: cbk_live_7mK2pQx9Lw4t.k3nR8vYc1pA6sD2fG9hJ0lZ4xW7bN5mQ8tE1rU3yI6oAuthorization: Bearer cbk_live_7mK2pQx9Lw4t.k3nR8vYc1pA6sD2fG9hJ0lZ4xW7bN5mQ8tE1rU3yI6oA key belongs to one workspace and acts only on that workspace's data. There is no workspace id anywhere in this API, because there is nothing for you to pass: the key decides.
Never put a key in a query string, in client-side JavaScript, or in a repository. The part before the dot is the key's public identifier: if you need to tell support which key you are using, quote cbk_live_7mK2pQx9Lw4t and nothing after it.
Revoking a key takes effect on the very next request. So does its expiry date, if you set one.
Scopes
A key carries the scopes you chose when you created it, and nothing else. Give a key the least it needs: a plugin that only sends order updates should not be able to read every contact in the workspace.
| Scope | What it allows |
|---|---|
messages:send | Send template and session messages. Spends the workspace's monthly message allowance. |
messages:read | Look up what happened to a message. |
contacts:read | Read contacts, tags and opt-in state. |
contacts:write | Create and update contacts, change tags, opt in and out. |
templates:read | List templates and their approval status. |
orders:read | Read orders. |
orders:write | Create orders and move them through their statuses. |
webhooks:manage | Add, change and remove this workspace's webhook subscriptions. |
A request without the scope its route needs answers 403 with insufficient_scope, and the error says which scope was missing.
A typical store integration needs messages:send, templates:read, contacts:write, orders:write and webhooks:manage. It does not need contacts:read.
Rate limits
120 requests per minute per API key. Over it, you get 429 with code rate_limited. Back off and retry: a short exponential backoff starting at a second is plenty.
A platform-wide per-address limit also applies, well above this one. If you are running something that needs more than 120 requests a minute sustained, talk to us rather than sharding across keys.
Sending is additionally limited by two things that are not request limits: the workspace's monthly message allowance, and WhatsApp's own throughput ceiling on the business's number.
Errors
Every failure, on every route, answers with the same body:
{
"error": {
"code": "session_window_closed",
"message": "WhatsApp only allows a plain text message within 24 hours of the customer writing to you. Send an approved template instead.",
"details": {
"contactId": "clx8h2k9p0001",
"sessionExpiresAt": "2026-10-16T14:22:00.000Z"
}
}
}Branch on code. It is stable. message is a sentence written for a person and may be reworded; details is optional and carries only values you sent us or an error number from WhatsApp.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No key, or something that is not shaped like one. |
invalid_api_key | 401 | No key matches. |
api_key_revoked | 401 | It was revoked. Make a new one. |
api_key_expired | 401 | Its expiry date has passed. |
workspace_inactive | 403 | The workspace is suspended or cancelled. |
insufficient_scope | 403 | Real key, wrong scopes. details.required lists them. |
validation_failed | 400 | The request body is wrong. details.fields says how. |
not_found | 404 | No such thing in this workspace. |
conflict | 409 | See the idempotency_* codes below. |
rate_limited | 429 | Too many requests for this key. |
idempotency_key_in_progress | 409 | The first request with this key is still running. Retry shortly. |
idempotency_key_reused | 409 | Same key, different request. Use a new key. |
contact_opted_out | 422 | The contact asked not to be messaged. Nothing overrides this. |
session_window_closed | 422 | More than 24 hours since they wrote. Send a template. |
message_quota_exceeded | 403 | The workspace's monthly allowance is spent. |
template_not_approved | 422 | The template exists but Meta has not approved it. |
whatsapp_not_connected | 422 | No WhatsApp number is connected to the workspace. |
whatsapp_send_failed | 502 | WhatsApp refused it. details.metaCode is their number. |
internal_error | 500 | Ours. Retrying later is reasonable. |
Which of these are worth retrying: 429, 500, 502, and idempotency_key_in_progress. Everything else will fail the same way again.
Idempotency
A shop's webhook retries. Ours must not send the same WhatsApp message twice because of it. Send an Idempotency-Key header on anything that sends a message or creates an order:
POST /api/v1/messages/template
Idempotency-Key: shopify-order-1234-shippedThe first request runs. Any later request with the same key, on the same endpoint, with the same body, does not run: it is answered with the first one's result, with the same status code.
Rules worth knowing:
- Pick a key from something in your own system that identifies the operation (
shopify-order-1234-shipped), not a random UUID per attempt. A random key per retry defeats the whole thing. - Keys are remembered for 7 days.
- Keys are per workspace. Yours cannot collide with another customer's.
- The same key with a different body is a
409 idempotency_key_reused. That is deliberate: replaying the first result would hide a message that never went out. - If the first request failed, the key is released and your retry will genuinely send. A failure does not burn the key.
- A request with no
Idempotency-Keysimply runs. Idempotency is offered, not imposed.
Endpoints that accept it: POST /v1/messages/template, POST /v1/messages/text, POST /v1/orders.
POST /v1/contacts does not need one: it is an upsert on the phone number, so running it twice leaves exactly the state running it once does.
An idempotency key is not the same thing as a message reference. See matching an event to your own records.
Pagination
Every list answers the same shape:
{ "items": [ ... ], "total": 1842, "page": 1, "pageSize": 25 }Query parameters: page (from 1) and pageSize (default 25, maximum 100). Asking for more than 100 gives you 100.
Getting help
Quote the key prefix (cbk_live_..., the part before the dot) and, for a delivery problem, the X-Chatbox-Delivery id or the waMessageId. Never send anyone a full API key or a webhook secret.