Error codes
Every non-2xx response carries a machine-readable code, a human message, and next_steps — what to do about it, as data rather than prose. Branch on code and next_steps[].action; never parse the message.
{
"ok": false,
"error": {
"code": "insufficient_balance",
"message": "This send needs more wallet credit before it can be queued.",
"details": {
"code": "insufficient_balance",
"estimated_cost_zar_cents": 3400,
"balance_zar_cents": 1200,
"available_zar_cents": 1200
},
"next_steps": [
{
"action": "top_up_wallet",
"by": "human",
"description": "The prepaid ZAR wallet cannot cover this send. A person must top up. Sandbox sends are never billed.",
"url": "https://app.blueticked.com/billing"
}
]
},
"request_id": "req_abc123"
}details is present on the codes that have something specific to say (the table below names them). next_steps is always present and never empty. Its by field is the one worth branching on:
caller— your code can fix this itself, right now.human— a person must act in a browser. Surface it and stop retrying; no rewrite of the request will clear it.waiting— outside anyone's control. Back off and retry.
The SDK throws a typed BluetickedError exposing code, status, details, and requestId — include the request id when contacting support.
Running out of credit
Blueticked billing is a prepaid ZAR wallet, so the most likely failure a long-running integration will meet is running out of money mid-run. Two distinct codes say so, and they are not interchangeable:
402 insufficient_balance— the workspace wallet is short.detailstells you by how much. Someone tops up and the same request succeeds.402 spend_cap_reached— the wallet is fine; this key's own monthly cap is spent. It is a deliberate guardrail, not a transient error. Never retry it — an unattended agent that treats it as retryable will hammer the endpoint until someone notices. Raise the cap in Developers → API keys, or wait for the month to roll.
Sandbox keys (blu_test_) never bill, so neither code can arise in test mode.
Every code
All 33codes the v1 API can return. “Cleared by” is the by value on the code's first next_steps entry.
| Code | HTTP | Cleared by | What it means, and what to do |
|---|---|---|---|
invalid_field_map | 400 | Your code | The incoming connection's field mapping is invalid. Correct the field mapping named by the error details, then retry. |
unauthorized | 401 | Your code | Missing key, revoked key, or malformed Authorization header. Send a live or sandbox key as Authorization: Bearer. If the key was revoked or rotated, mint a new one. |
forbidden | 403 | A person | The API key is valid but not permitted here — a missing scope, a blocked IP, a publishable key used off the agent chat endpoint, or an action the API refuses outright. For a missing scope, error.details carries granted and required, and someone with dashboard access must edit or recreate the key. Never retry unchanged. |
not_found | 404 | Your code | The requested contact, template, message or campaign does not exist. List the resource to find a valid id rather than retrying. An id belonging to another workspace also reports not found. |
validation_failed | 400 | Your code | The request shape is valid JSON but fails business validation. The message names the offending field. Fix it and retry; the same body will fail again. |
payload_too_large | 413 | Your code | The body is above the endpoint's size limit. Checked before the body is parsed, so nothing was applied. Split the request into smaller batches. |
rate_limited | 429 | Your code | The key or the workspace exceeded a per-minute limit. Wait until x-ratelimit-reset, then retry. error.details carries limit, reset_at and scope (key or organization). |
idempotency_conflict | 409 | Your code | The same Idempotency-Key was reused with a different body. Generate a fresh UUID per logical operation. Never reuse one across different payloads. |
idempotency_in_progress | 409 | Your code | A request with the same Idempotency-Key is still being processed. Wait briefly and retry with the SAME key to collect the original result. |
resource_in_use | 409 | Your code | The resource cannot be deleted because live resources still depend on it — a group targeted by a scheduled campaign, for instance. error.details lists the dependents; remove or repoint those first. |
conflict | 409 | Your code | The other kind of 409: the request collides with what already exists — a code or name that is taken, or a resource in a state the call does not accept (only a paid order can book a courier). Not resource_in_use, where the resource is fine and other things depend on it: here there are no dependents to clear. Send a different value, or bring the resource into the required state, then retry. The same body will keep failing, and nobody has to open the dashboard for you. |
method_not_allowed | 405 | Your code | The endpoint does not support that HTTP method. Next.js answers unsupported methods with a bodyless 405, so this arrives without the JSON envelope. Check the endpoint's method in GET /api/v1/openapi.json. |
enable_requires_dashboard | 400 | A person | The API can pause an AI agent but not enable one — switching on something that answers customers unattended needs a person looking at it. Surface this to your operator. Do not retry. |
activation_requires_dashboard | 400 | A person | The API can pause an automation but not activate one. Automations are created paused by design. Surface this to your operator. Do not retry. |
journey_not_active | 409 | A person | Only an active journey accepts enrolments; a draft, paused or archived one is refused. A person must activate the journey in the dashboard before this call can succeed. |
channel_unavailable | 503 | A person | The requested provider channel is disconnected or not ready. Do not spin on it — retrying cannot succeed until somebody connects a channel. Mint a connect link with POST /api/v1/channels/connect-sessions and send it to the account owner; check GET /api/v1/readiness first. A blu_test_ key sends with no channel connected and bills nothing. |
queue_unavailable | 503 | Retry | The send queue is unreachable, so live sends cannot be accepted. Nothing was queued. Retrying is safe. Back off exponentially. |
service_unavailable | 503 | Retry | Blueticked could not verify your workspace state. The request was not applied. Retry shortly with backoff. |
provider_error | 502 | A person | An upstream provider refused or failed — Paystack declining a payment link, for example. Unlike service_unavailable this usually will not fix itself. Check the request and the provider account connected in the workspace. A bare retry will fail the same way. |
insufficient_balance | 402 | A person | The prepaid ZAR wallet does not have enough available credit for this send. Billing is prepaid, so this is the failure a long-running integration is most likely to meet. A person must top up. error.details carries estimated_cost_zar_cents, balance_zar_cents and available_zar_cents, so you can report exactly how short the wallet is. Sandbox sends are never billed. |
spend_cap_reached | 402 | A person | This API key has reached the monthly spend cap set on it. The workspace wallet is untouched — the ceiling belongs to the credential. Do not retry: the stop is deliberate and only a person can lift it, by raising or removing the cap in Developers → API keys, or by waiting for the month to roll. |
plan_limit_reached | 403 | A person | A per-tier plan cap is exhausted — seats, published sites, contacts or WhatsApp numbers. error.details carries { key, tier, limit, current } naming which. A person must upgrade the plan or free capacity. |
subscription_inactive | 403 | A person | The workspace subscription is suspended or cancelled, so the gated action is refused. Nothing will succeed until a person restores the subscription. |
internal_error | 500 | Retry | Blueticked accepted the request but hit an internal failure. Retry with backoff. If it persists, quote request_id to support — it identifies this exact request in our logs. |
variables_mismatch | 400 | Your code | The supplied template variables do not match the template's {{n}} markers — a missing or an extra position. Read the template with GET /api/v1/templates/{id} to see the positions it declares, then send exactly those. |
button_params_mismatch | 400 | Your code | The supplied template_buttons do not match the template's dynamic URL buttons. A stored button URL ending in {{1}} needs a url_suffix on every send. Read the template with GET /api/v1/templates/{id} for its button definitions. |
document_invalid | 400 | Your code | The document parameter is malformed: url must be https and filename at most 240 characters. The URL has to be fetchable by WhatsApp once, at send time — a short-lived pre-signed URL is the right shape. |
document_required | 400 | Your code | The template has a DOCUMENT header, so a document is required on the send. Pass document: { url, filename }. |
document_not_applicable | 400 | Your code | A document was supplied but the template has no DOCUMENT header, or the send is not a template send. Send it without one, or use a template that declares the header. |
document_source_invalid | 400 | Your code | The campaign document_source is malformed. Check mode, field_key, filename_template and missing_policy against the campaigns reference. |
document_source_required | 400 | Your code | The campaign's template has a DOCUMENT header, so it needs a document_source to resolve one per recipient. Add a document_source to the campaign. |
document_source_not_applicable | 400 | Your code | A document_source was supplied but the campaign is neither an email campaign nor a WhatsApp template with a DOCUMENT header. Remove document_source. |
template_kind_unsupported | 422 | Your code | The WhatsApp template is a kind Blueticked can't send yet: details.kind is product (Product or Item list buttons, a product header or product cards), carousel, expiring_offer (a limited-time offer with an expiry) or coupon_code. No send path supplies those per-send values, so nothing was stored or sent. Send a template without those parts. Retrying the same template will not help. |
Rate limits
A 429 rate_limitedcan come from either of two buckets — the key's own, or the workspace ceiling every key shares — and error.details.scope says which. See Rate limits and retries for the ceilings and the headers to pace against.