Errors
Every machine-readable error code the API can return, and what to do about each.
The shape of a failure
Every error returns the same envelope, whatever went wrong.
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "\"loyalty_tier\" is not a defined custom field. Valid keys: none defined.",
"details": {
"field": "loyalty_tier",
"code": "unknown_custom_field"
},
"next_steps": [
{
"action": "correct_request",
"by": "caller",
"description": "The message names the offending field. Fix it and retry; the same body will fail again."
}
]
},
"request_id": "req_ca7d43c17cab7ea1c60aa97a8c2668ca"
}Branch on code, never on message: messages are prose and may be reworded. The request_id is also on the x-request-id header, and it identifies the exact call in your request log; quote it in support requests.
next_steps is always present and never empty. Each step carries a stable action to branch on, a sentence for your logs, and (the part worth writing code against) by, which says who can clear the failure: caller means you can fix it yourself and retry, human means a person has to act in a browser (surface it and stop retrying: no rewrite of the request will help), and waiting means something outside anyone's control, such as a Meta review, so poll. Steps that are fixed by a call carry endpoint; steps a person must clear carry url.
Two kinds of validation_failed
Both answer 400 validation_failed, and details tells them apart.
When the body does not fit the schema, details is the list of schema issues, each with the field's path and a message. When the body fits but a value breaks a rule, details names the field.
POST /api/v1/messages
{
"channel": "whatsapp",
"body": "Hello"
}
HTTP 400
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "Invalid message: `to` — Invalid input: expected object, received undefined",
"details": [
{
"expected": "object",
"code": "invalid_type",
"path": [
"to"
],
"message": "Invalid input: expected object, received undefined"
}
],
"next_steps": [
{
"action": "correct_request",
"by": "caller",
"description": "The message names the offending field. Fix it and retry; the same body will fail again."
}
]
},
"request_id": "req_3a0a501cdcc36329614c739fe760383f"
}POST /api/v1/contacts
{
"phone": "+27800000001",
"custom_fields": {
"loyalty_tier": "gold"
}
}
HTTP 400
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "\"loyalty_tier\" is not a defined custom field. Valid keys: none defined.",
"details": {
"field": "loyalty_tier",
"code": "unknown_custom_field"
},
"next_steps": [
{
"action": "correct_request",
"by": "caller",
"description": "The message names the offending field. Fix it and retry; the same body will fail again."
}
]
},
"request_id": "req_ca7d43c17cab7ea1c60aa97a8c2668ca"
}Rehearse a failure
Error handling is the one part of an integration you cannot reach by accident in the sandbox.
With a test key, ask for a failure by its code and get the real response: the real status, code and next_steps.
curl "https://app.blueticked.com/api/v1/channels" \
-H "Authorization: Bearer $BLUETICKED_API_KEY" \
-H "X-Simulate-Error: channel_unavailable"Test keys only: a live key that sends the header is refused, so a test suite cannot pass against production while proving nothing. The header is read after authentication and rate limiting, so it uses quota like any other call. The codes you can ask for:
channel_unavailable, insufficient_balance, spend_cap_reached, rate_limited, plan_limit_reached, subscription_inactive, queue_unavailable, service_unavailable, provider_error, validation_failed, idempotency_conflict, resource_in_use, not_found, forbidden
Details some codes carry
error.details adds what a code needs to be acted on.
Two you will meet early:
- rate_limited
- details.scope says which bucket refused the call: "key" (this key's own allowance) or "organization" (the plan allowance every key shares), so you know whether to slow one worker or the whole fleet. details.limit is that bucket's ceiling and details.reset_at (ISO 8601) when it refills.
- insufficient_balance
- details.estimated_cost_zar_cents is what the send would cost, details.balance_zar_cents the purchased top-up balance, and details.available_zar_cents what the send can draw on: the plan's included credit plus the top-up (plus a small grace for a transactional send). All in ZAR cents.
Every code
34 stable, machine-readable codes.
This list is the same one the OpenAPI Error enum is generated from, so it cannot fall behind the API.
| Code | Status | Meaning |
|---|---|---|
| invalid_field_map | HTTP 400 | The inbound field map has an unknown or ambiguous target or repeats a contact identity target.Your code clears it: Use an unambiguous custom field key and map each contact identity target from only one source. |
| unauthorized | HTTP 401 | The credential was refused: a missing or malformed Authorization header, an unknown key or a wrong secret, a revoked or expired key, an expired or revoked OAuth token, or an inactive workspace. Once a key's secret checks out, details.reason says which (revoked, expired, workspace_inactive).Your code clears it: Send a live or sandbox key as `Authorization: Bearer`. If the key was revoked or rotated, mint a new one. |
| forbidden | HTTP 403 | The credential is valid but not allowed here: a missing scope (details.required), an address outside the key's IP allowlist, a business-restricted key on an endpoint it cannot use, or a blu_pub_ key off the agent chat endpoint or its allowed origins.A person clears it: The key is missing a scope this endpoint requires. `error.details.required` lists them; an owner or admin can edit this key's scopes under Developers > Keys > Manage with two-step verification. Agent and widget keys keep their server-managed scopes. Do not retry unchanged. |
| not_found | HTTP 404 | The requested contact, template, message or campaign does not exist.Your code clears it: The id does not exist in this workspace. List the resource to find a valid id rather than retrying — an id from another workspace also reports not found. |
| validation_failed | HTTP 400 | The request was refused before anything happened. Either the body does not fit the schema (details is the list of schema issues, each with the field's path), or a value breaks a rule (details names the field).Your code clears it: The message names the offending field. Fix it and retry; the same body will fail again. |
| rate_limited | HTTP 429 | The workspace or key exceeded a rate limit, or the calling address made too many failed authentication attempts. Wait Retry-After seconds, then retry.Your code clears it: Wait the number of seconds in `retry-after`, then retry; `x-ratelimit-reset` is the same moment as an ISO-8601 timestamp. `x-ratelimit-limit` and `x-ratelimit-remaining` on every response let you pace before you hit this at all. |
| idempotency_conflict | HTTP 409 | The same Idempotency-Key was reused with a different body.Your code clears it: This key was already used with a different body. Generate a fresh UUID per logical operation — never reuse one across different payloads. |
| idempotency_in_progress | HTTP 409 | A request with the same Idempotency-Key is still being processed; retry once it finishes.Your code clears it: An identical request is still running. Wait briefly and retry with the SAME key to collect its result. |
| resource_in_use | HTTP 409 | The resource cannot be deleted because other live resources still depend on it (e.g. a group targeted by a scheduled campaign). `details` lists the dependents.Your code clears it: Something still depends on this resource. `error.details` lists what; remove or repoint those first. |
| conflict | HTTP 409 | 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, as when 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, so send a different value or reconcile the state and retry.Your code clears it: The request collides with what is already there: that code or name is taken, or the resource is not in a state this call accepts. `message` says which. Send a different value, or bring the resource into the required state first — the same body will keep failing, and nobody has to open the dashboard for you. |
| method_not_allowed | HTTP 405 | The endpoint does not support the attempted HTTP method. (Next.js answers unsupported methods with a bodyless 405, so this code is listed for completeness rather than returned in the JSON envelope.)Your code clears it: Check the endpoint's method in the OpenAPI document. |
| gone | HTTP 410 | The endpoint was retired. It keeps answering 410, for at least one SDK major, with `details.successor` naming what replaced it; the same request will never succeed again, so move to the successor rather than retrying.Your code clears it: This endpoint was retired and will not come back. `details.successor` names the endpoint that replaced it and `message` says how to move; the same request keeps answering 410. |
| channel_unavailable | HTTP 503 | The requested provider channel is disconnected or not ready.A person clears it: No channel is connected for this workspace. Mint a connect link and send it to the account owner — they complete Meta's flow themselves. Check GET /api/v1/readiness for the full picture first. |
| queue_unavailable | HTTP 503 | The send queue is not configured, so live sends cannot be accepted.It clears with time: The send queue is unreachable. Nothing was queued, so retrying is safe. Back off exponentially. |
| insufficient_balance | HTTP 402 | The prepaid wallet does not have enough available credit for the requested send.A person clears it: The prepaid ZAR wallet cannot cover this send. A person must top up. Sandbox sends are never billed. |
| spend_cap_reached | HTTP 402 | This API key has reached the monthly spend cap set on it. The workspace wallet is unaffected: raise or remove the cap in Developers → API keys. Do not retry: the stop is deliberate and only a human can lift it.A person clears it: This key's own monthly ceiling is spent — the workspace wallet is untouched. Deliberate stop, not a transient error: do not retry. A person raises the cap or waits for the month to roll. |
| plan_limit_reached | HTTP 403 | A per-tier plan cap (seats, sites, contacts or WhatsApp numbers) is exhausted. `details` carries { key, tier, limit, current }. Maps to 403.A person clears it: A per-tier limit is exhausted. `error.details` names which. A person must upgrade or free capacity. |
| subscription_inactive | HTTP 403 | The organization's subscription is suspended or cancelled, so the entitlement-gated action is refused. Maps to 403.A person clears it: The workspace subscription is suspended or cancelled. Nothing will succeed until a person restores it. |
| variables_mismatch | HTTP 400 | The supplied template variables do not match the template's {{n}} markers (missing or extra positions).Your code clears it: Supplied variables do not match the template's {{n}} markers. Read the template to see the positions it declares, then send exactly those. |
| button_params_mismatch | HTTP 400 | 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.Your code clears it: template_buttons does not match the template's dynamic URL buttons. Read the template for its button definitions. |
| document_invalid | HTTP 400 | The document parameter is malformed: url must be https and filename at most 240 characters.Your code clears it: The document object is malformed. `url` must be https and reachable by WhatsApp at send time; `filename` is capped at 240 characters. |
| document_required | HTTP 400 | The template has a DOCUMENT header; pass document: { url, filename }.Your code clears it: This template has a DOCUMENT header, so a `document` is required. |
| document_not_applicable | HTTP 400 | A document was supplied but the template has no DOCUMENT header (or the send is not a template send).Your code clears it: This template has no DOCUMENT header, so it rejects a `document`. Send it without one, or use a template that declares the header. |
| document_source_invalid | HTTP 400 | The campaign document_source is malformed; check mode, field_key, filename_template and missing_policy.Your code clears it: document_source is malformed. Check `mode`, `field_key` and `missing_policy` against the campaigns reference. |
| document_source_required | HTTP 400 | The campaign template has a DOCUMENT header; pass a document_source.Your code clears it: This campaign's template has a DOCUMENT header, so it needs a document_source to resolve one per recipient. |
| document_source_not_applicable | HTTP 400 | A document_source was supplied but the campaign is neither an email campaign nor a WhatsApp template with a DOCUMENT header.Your code clears it: This campaign's template has no DOCUMENT header, so document_source does not apply. |
| template_kind_unsupported | HTTP 422 | The template is a kind Blueticked can't send yet: Product or Item list buttons, a product header or cards (product), carousel cards (carousel), a limited-time offer with an expiry (expiring_offer) or a coupon-code button (coupon_code). details.kind names it. No send path supplies these per-send values, so choose a template without them. POST /api/v1/templates answers it too, with details.field, for a header field in the body (any field whose name starts with header, in any case: header, header_format, headerFormat): the API creates templates without a header, so make one with a header in the dashboard.Your code clears it: Blueticked can't send this kind of template yet (details.kind is product, carousel, expiring_offer or coupon_code). Send a template without it. |
| internal_error | HTTP 500 | Blueticked accepted the request but hit an internal failure.It clears with time: Unexpected failure on our side. Retry with backoff; if it persists, quote `request_id` to support — it identifies this exact request in our logs. |
| payload_too_large | HTTP 413 | The request body is above the endpoint's size limit; returned as 413 before the body is validated.Your code clears it: Split the request into smaller batches. This is checked before the body is parsed, so nothing was applied. |
| service_unavailable | HTTP 503 | Blueticked could not verify your workspace state; the request was not applied, so retry shortly.It clears with time: A dependency we own is unavailable and the request was not applied. Retry shortly. |
| provider_error | HTTP 502 | An upstream provider refused or failed the request (Paystack declining a payment link, for instance). Returned as 502. Unlike service_unavailable this usually will not fix itself: check the request and the provider account connected in your workspace before retrying.A person clears it: An upstream provider refused — Paystack declining a payment link, for example. Usually the request or the connected provider account needs changing, so a bare retry will fail the same way. |
| enable_requires_dashboard | HTTP 400 | The API can only pause an AI agent. Re-enabling one is a dashboard action, where its persona and escalation rules are visible.A person clears it: 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; do not retry. |
| activation_requires_dashboard | HTTP 400 | The API can only pause a flow. Activating one is a dashboard action, so nobody ships a live flow sight-unseen.A person clears it: Flows are created paused by design. A person activates them in the dashboard. Surface this; do not retry. |
| journey_not_active | HTTP 409 | Only an active journey accepts enrolments; a draft, paused or archived one is refused. Activation is a dashboard action.A person clears it: Only an active journey accepts enrolments. A person must activate it before this call can succeed. |
Reasons
Some errors carry details.reason, naming which failure of that kind it was, so a program can branch on it without reading the message.
96 reasons today; a value not listed here means the code's general case.
| Reason | Code | Meaning |
|---|---|---|
| rotation_grace_active | conflict | An earlier secret is still in its rotation grace window. Wait until details.expires_at, or explicitly choose end_old_secret=now to end every earlier secret. PATCH /api/api-keys/{id} (dashboard) |
| key_revoked | conflict | The key was revoked before or during this edit. Create a new key to continue. PATCH /api/api-keys/{id} (dashboard) |
| list_not_found | not_found | The audience list is missing, deleted, or outside this organisation and business. Choose an available list. POST /api/v1/campaigns |
| segment_not_found | not_found | The audience segment is missing, deleted, or outside this organisation and business. Choose an available segment. POST /api/v1/campaigns |
| setup_recheck_too_soon | rate_limited | Meta was asked about this number less than a minute ago. Wait retryAfter seconds, then check again. POST /api/setup/recheck (dashboard) |
| audience_unchecked | service_unavailable | The audience source could not be checked. Nothing was saved; retry when the service is available. POST /api/v1/campaigns |
| erasure_retrying | service_unavailable | Work on the contact is still in progress (an Ask Blu answer about them, or a large backlog). Nothing was stamped; send the same request again after the Retry-After seconds, and contact support if it keeps answering 503. POST /api/v1/contacts/{id}/erase |
| revoked | unauthorized | The key was revoked. It will not work again: create a new key in Developers → Keys. Every /api/v1 call |
| expired | unauthorized | The key's expiry date has passed. Rotate it to make it work again: a rotate clears an expiry that has passed. Every /api/v1 call |
| workspace_inactive | unauthorized | The key is valid but its workspace is no longer active, so no key in it can call the API. Every /api/v1 call |
| no_draft | validation_failed | The site has no draft to publish. Edit the site first. POST /api/v1/sites/{id}/publish |
| invalid_draft | validation_failed | The site's draft does not pass the site schema; error.message names the problem. Fix the draft, then publish. POST /api/v1/sites/{id}/publish |
| live_address_unavailable | service_unavailable | The site's live address could not be prepared (or is already in use elsewhere); nothing was published. Try again, or contact support if the message says the address is in use. POST /api/v1/sites/{id}/publish |
| publish_failed | internal_error | Blueticked could not publish the site. Try again. POST /api/v1/sites/{id}/publish |
| not_published | validation_failed | The site is not published, so there is nothing to take offline. POST /api/v1/sites/{id}/unpublish |
| unpublish_failed | internal_error | Blueticked could not take the site offline. Try again. POST /api/v1/sites/{id}/unpublish |
| business_not_found | not_found | No business this key can reach matches business_id. List the workspace's businesses with GET /api/v1/me. POST /api/v1/sites |
| business_limit_reached | plan_limit_reached | The workspace holds as many businesses as its plan includes; details.limit says how many and error.message names the next plan. Move to a larger plan in Billing, or remove a business first. POST /api/v1/businesses |
| business_charge_required | plan_limit_reached | Adding this business costs money on this plan, and nothing was created. details.billing gives unit_zar_cents (a month), part_zar_cents (the rest of this period) and charged_now. Send the request again with accept_business_charge: { monthly_zar_cents: <unit_zar_cents> } to add it. POST /api/v1/businesses |
| business_charge_changed | plan_limit_reached | The monthly price you accepted is no longer the price of another business (the plan or your number of businesses changed), and nothing was created. details.billing gives today's price; accept that one to add it. POST /api/v1/businesses |
| business_charge_live_key_required | plan_limit_reached | Adding this business costs money on this plan, and a blu_test_ key cannot start a real charge; nothing was created. details.billing gives the price. Use a live key to add it. POST /api/v1/businesses |
| operator_agreement_unsigned | forbidden | This workspace was made after the operator agreement came into force, and its owner has not signed it yet, so nothing was sent. The owner signs it in Settings, Legal (next_steps carries the address); then send again. A blu_test_ key is not asked. POST /api/v1/messages, POST /api/v1/campaigns, POST /api/v1/events/send, POST /api/v1/journeys/{id}/enroll |
| no_organization | forbidden | The signed-in user belongs to no workspace (dashboard session only). POST /api/v1/sites |
| unknown_template | validation_failed | No site template has that name; leave template out for the blank starter. POST /api/v1/sites |
| slug_unavailable | conflict | No free site address could be found from desired_slug; error.message says why. Choose another desired_slug. POST /api/v1/sites |
| insert_failed | internal_error | Blueticked could not create the site, or could not check whether its address is free. Nothing was created; try again. POST /api/v1/sites |
| recipient_not_found | not_found | No contact matches `to`. Pass `to.create: true` to create it in the send, or create it with POST /api/v1/contacts first. POST /api/v1/messages |
| consent_required | validation_failed | A marketing send needs the recipient's consent: `to.create` would create a contact, which has given none, or the contact has no email marketing consent recorded. Record the opt-in on the contact first, or send a transactional message. POST /api/v1/messages |
| custom_identity_required | validation_failed | A marketing email must come from your own verified sending domain, not the shared Blueticked sender. Verify a domain in Email → Settings, or send it as transactional if it is one. A blu_test_ key does not check this. POST /api/v1/messages |
| dmarc_required | validation_failed | A marketing email needs a confirmed DMARC record on the sending domain. Publish the record shown in Email → Settings, run Check now, then send again. POST /api/v1/messages |
| business_identity_required | validation_failed | A marketing email carries the business's registered legal name in its footer, and the business profile has none. Add it, then send again. POST /api/v1/messages |
| physical_address_required | validation_failed | A marketing email carries a complete postal address in its footer. Add the street, city, postal code and country to the business profile, then send again. POST /api/v1/messages |
| freemail_reply_to | validation_failed | The reply-to is a Gmail, Outlook, Yahoo, iCloud or other freemail address, on the request's reply_to or the sender's configured Reply-To. Use an address on the business's own domain. POST /api/v1/messages |
| reply_to_not_aligned | validation_failed | The reply-to is not on the sending domain: a request's reply_to must use the active sender's own domain, and a configured Reply-To must match it. Change the reply-to, or leave reply_to out. POST /api/v1/messages |
| contacts_write_required | forbidden | `to.create: true` creates a contact, which needs the contacts:write scope on the key as well as the send scope. POST /api/v1/messages |
| domain_in_use_elsewhere | conflict | Another Blueticked workspace sends from this domain. Use a domain you control that no other workspace uses. POST /api/v1/email/sending-domain |
| domain_in_use_by_business | conflict | Another business in this workspace sends from this domain. Use another domain, or remove it from that business first. POST /api/v1/email/sending-domain |
| domain_registered_unclaimed | conflict | The email provider already has this domain but no workspace holds it. Contact Blueticked support with request_id to release it; a retry gets the same answer. POST /api/v1/email/sending-domain |
| domain_holder_unchecked | service_unavailable | Blueticked could not read who holds the domain, so nothing was added. Retry the same request in a moment. POST /api/v1/email/sending-domain |
| receive_only | conflict | The integration receives events from an app, so it has nothing to send and no outbound mapping to set or test. PATCH /api/v1/integrations/{id}, POST /api/v1/integrations/{id}/test |
| website_form_channel | conflict | A website form sends WhatsApp and SMS only to a visitor who ticks its consent box, so its `inbound_target_channel` cannot be `whatsapp` or `sms`. Write a sentence for it instead; `email` can be bound. PATCH /api/v1/integrations/{id} |
| invalid_contact_ids | validation_failed | `contact_ids` must be a non-empty array of contact ids, within the per-call limit. POST /api/v1/journeys/{id}/enroll |
| business_id_required | validation_failed | The workspace has more than one business, so the flow must name one (see GET /api/v1/me). POST /api/v1/studio-flows |
| automations_retired | gone | The classic automations API was retired on 21 October 2026. Use Flow Studio flows (details.successor, /api/v1/studio-flows) and start them with events (POST /api/v1/events/send). GET, POST /api/v1/automations; GET, PATCH, DELETE /api/v1/automations/{id}; GET /api/v1/automations/recipes |
| definition_required | validation_failed | Pass `definition`: the whole Flow Studio flow, as GET /api/v1/studio-flows/{id} returns it. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| invalid_business_id | validation_failed | `business_id` must be the id of a business (see GET /api/v1/me). POST /api/v1/studio-flows |
| invalid_definition | validation_failed | The definition is not a Flow Studio flow; `details.detail` says what is missing. Nothing was saved. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| compile_failed | validation_failed | The flow would not work as written; `details.issues` lists each problem (`path`, `message`), the checklist Flow Studio shows. Nothing was saved. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| sms_type_required | validation_failed | An SMS message the flow sends itself (before its first wait) has no `smsMessageType`. Set `transactional` for an order or service update, or `marketing` for anything promotional (it needs SMS consent and keeps quiet hours); `details.issues` names each step. Nothing was saved. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| edit_version_required | validation_failed | Pass `expected_updated_at`: the `updated_at` of the flow you read. PATCH /api/v1/studio-flows/{id} |
| conflict | conflict | Someone saved the flow since it was read. Read it again, then send the change; nothing was overwritten. PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| studio_flow_live | resource_in_use | The flow is on, so the API does not change it. Switch it off first (POST /api/v1/studio-flows/{id}/switch-off), or edit it in Flow Studio. PATCH /api/v1/studio-flows/{id} |
| studio_flow_shared_entry | resource_in_use | The flow starts a journey it shares with other flows, a link that is set in Flow Studio, so it is edited there. PATCH /api/v1/studio-flows/{id} |
| journey_read_failed | service_unavailable | The journeys the flow starts could not be checked just now. Nothing was saved; try again. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| sequence_requires_send_step | validation_failed | A journey in the flow has no message to send. Add a message step. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| invalid_trigger | validation_failed | The flow's start is not one a journey can begin with. Choose another start in the definition. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| flow_name_taken | conflict | Another flow in this business already has that name. Change `name` in the definition and send it again. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| invalid_steps | validation_failed | A journey in the flow has a step that cannot be saved; `message` says which. Nothing was saved. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| invalid_segment | validation_failed | A step names a segment that does not exist in this workspace. Choose one from the workspace's segments. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| invalid_email_template | validation_failed | A step names an email template that does not exist in this workspace. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| invalid_whatsapp_template | validation_failed | A step names a WhatsApp template that does not exist in this workspace. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| unsupported_whatsapp_template | validation_failed | A step names a WhatsApp template a journey cannot send (its buttons or media need something the step cannot give). POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| whatsapp_template_not_approved | validation_failed | A step names a WhatsApp template Meta has not approved yet. Wait for approval, or choose another. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| whatsapp_template_not_attached | validation_failed | A step names a WhatsApp template that is not on this business's WhatsApp number. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| not_found | not_found | The flow does not exist for this key: it was deleted, or it is in a business the key cannot reach. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| forbidden | forbidden | The flow is in a business this caller cannot change. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| reference_outside_business | validation_failed | The flow starts a journey, or begins on a segment, that belongs to a business this key cannot reach; `details.issues` names each step. Nothing was saved. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id} |
| meta_refused | provider_error | Meta did not accept the template; the message carries Meta's own reason. POST /api/v1/templates/{id}/submit |
| journeys_list_failed | internal_error | Blueticked could not complete the call on its side. Nothing changed; retry with backoff. GET /api/v1/journeys |
| ai_agents_list_failed | internal_error | Blueticked could not complete the call on its side. Nothing changed; retry with backoff. GET /api/v1/ai-agents |
| form_submissions_list_failed | internal_error | Blueticked could not complete the call on its side. Nothing changed; retry with backoff. GET /api/v1/form-submissions |
| studio_flow_save_failed | internal_error | Blueticked could not complete the call on its side. Nothing changed; retry with backoff. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| automation_save_failed | internal_error | Blueticked could not complete the call on its side. Nothing changed; retry with backoff. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| save_failed | internal_error | Blueticked could not complete the call on its side. Nothing changed; retry with backoff. POST /api/v1/studio-flows, PATCH /api/v1/studio-flows/{id}, POST /api/v1/studio-flows/{id}/switch-off |
| table_not_found | not_found | `audience.table_id` is not a table of this business (or of the whole organisation). Nothing was saved. POST /api/v1/campaigns |
| table_has_no_people | validation_failed | `audience.table_id` is a reference table: its rows belong to no one, so it can't be a campaign's audience. POST /api/v1/campaigns |
| table_status_unknown | validation_failed | `audience.status` is not one of the table's status keys. Name one of them, or leave it out for every row. POST /api/v1/campaigns |
| table_column_unknown | validation_failed | A `row_field` template value names a column the table does not have. Map it to one of the table's column keys. POST /api/v1/campaigns |
| row_field_needs_table | validation_failed | A template value has `source: "row_field"`, which reads a table row, but the campaign's audience is not a table. POST /api/v1/campaigns |
| table_unchecked | service_unavailable | Blueticked could not read the table to check it. Nothing was saved; retry with backoff. POST /api/v1/campaigns |
| assignee_not_member | validation_failed | assigned_to is not a current member of this organisation. Nothing was changed; choose a workspace user. PATCH /api/v1/conversations/{id} |
| assignee_cannot_open | validation_failed | assigned_to cannot open this conversation: they need access to its business, or, when it has no business, to be an owner or admin. Nothing was changed. PATCH /api/v1/conversations/{id} |
| media_pending | conflict | The file is still being fetched from the channel (media_status is pending). Retry in a few seconds. GET /api/v1/conversations/{id}/messages/{messageId}/media |
| media_unavailable | not_found | The file cannot be served: its download from the channel failed for good (media_status is failed), or it is no longer stored. GET /api/v1/conversations/{id}/messages/{messageId}/media |
| invalid_form | validation_failed | The body is not multipart/form-data. Send the PDF as a `file` part with `contact_id`; JSON and base64 bodies are not accepted. POST /api/v1/documents |
| missing_file | validation_failed | The form has no `file` part. Attach the PDF as `file`. POST /api/v1/documents |
| invalid_contact_id | validation_failed | `contact_id` is missing or is not a UUID. Pass the id of the contact the document belongs to. POST /api/v1/documents |
| file_too_large | validation_failed | The PDF is larger than 4 MB, the most an API upload takes (the request body is capped at 4.5 MB). Nothing was stored. POST /api/v1/documents |
| not_a_pdf | validation_failed | The bytes are not a complete PDF: a PDF header and an end-of-file marker are both required, whatever the Content-Type says. Nothing was stored. POST /api/v1/documents |
| document_not_found | not_found | `document.id` names no document in this organisation, or the document was deleted. Upload it with POST /api/v1/documents first. POST /api/v1/messages |
| document_not_for_contact | validation_failed | The document belongs to another contact, or the send would create its recipient. A document is only sent to the contact it was uploaded for; nothing was written. POST /api/v1/messages |
| not_whatsapp_channel | validation_failed | The channel is not a WhatsApp channel; `details.channel` says what it is. Spend is Meta's WhatsApp billing, so only a WhatsApp channel has one. GET /api/v1/channels/{id}/whatsapp-spend |
| whatsapp_token_missing | channel_unavailable | The WhatsApp channel has no usable Meta connection to read spend with, or Meta refused its token (expired or revoked). Reconnect it in the dashboard; retrying will not help until then. GET /api/v1/channels/{id}/whatsapp-spend |
| meta_unavailable | provider_error | Meta answered with an error instead of the spend (`details.meta_status` is Meta's status). Retry shortly; it is never reported as an empty spend. GET /api/v1/channels/{id}/whatsapp-spend |
| meta_unavailable | service_unavailable | Meta could not be reached at all. Retry shortly; it is never reported as an empty spend. GET /api/v1/channels/{id}/whatsapp-spend |
| meta_pricing_permission_required | provider_error | Meta has not enabled pricing analytics for this WhatsApp Business Account yet. Retrying will not help; it opens on Meta's side. GET /api/v1/channels/{id}/whatsapp-spend |
Two answers forward a reason from elsewhere, so their values are open-ended:
- POST /api/v1/templates/{id}/submit (internal_error): The submit pipeline's own snake_case reason (for example meta_unreachable when Meta could not be reached).
- POST /api/v1/messages (queue_unavailable): The send queue's own error text, when a message could not be handed to the queue. Nothing was sent.