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:

text
cbk_live_7mK2pQx9Lw4t.k3nR8vYc1pA6sD2fG9hJ0lZ4xW7bN5mQ8tE1rU3yI6o

Send it in either header. Use whichever your HTTP client makes easy:

http
X-Api-Key: cbk_live_7mK2pQx9Lw4t.k3nR8vYc1pA6sD2fG9hJ0lZ4xW7bN5mQ8tE1rU3yI6o
http
Authorization: Bearer cbk_live_7mK2pQx9Lw4t.k3nR8vYc1pA6sD2fG9hJ0lZ4xW7bN5mQ8tE1rU3yI6o

A 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.

ScopeWhat it allows
messages:sendSend template and session messages. Spends the workspace's monthly message allowance.
messages:readLook up what happened to a message.
contacts:readRead contacts, tags and opt-in state.
contacts:writeCreate and update contacts, change tags, opt in and out.
templates:readList templates and their approval status.
orders:readRead orders.
orders:writeCreate orders and move them through their statuses.
webhooks:manageAdd, 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:

json
{
  "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.

CodeStatusMeaning
unauthorized401No key, or something that is not shaped like one.
invalid_api_key401No key matches.
api_key_revoked401It was revoked. Make a new one.
api_key_expired401Its expiry date has passed.
workspace_inactive403The workspace is suspended or cancelled.
insufficient_scope403Real key, wrong scopes. details.required lists them.
validation_failed400The request body is wrong. details.fields says how.
not_found404No such thing in this workspace.
conflict409See the idempotency_* codes below.
rate_limited429Too many requests for this key.
idempotency_key_in_progress409The first request with this key is still running. Retry shortly.
idempotency_key_reused409Same key, different request. Use a new key.
contact_opted_out422The contact asked not to be messaged. Nothing overrides this.
session_window_closed422More than 24 hours since they wrote. Send a template.
message_quota_exceeded403The workspace's monthly allowance is spent.
template_not_approved422The template exists but Meta has not approved it.
whatsapp_not_connected422No WhatsApp number is connected to the workspace.
whatsapp_send_failed502WhatsApp refused it. details.metaCode is their number.
internal_error500Ours. 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:

http
POST /api/v1/messages/template
Idempotency-Key: shopify-order-1234-shipped

The 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-Key simply 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:

json
{ "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.