Start WhatsApp conversations with a template, send messages, read conversations and manage templates.
The WhatsApp API works on your workspace’s own WhatsApp Business account (connect it in the console → WhatsApp → Settings). It needs the whatsapp:read / whatsapp:write scopes. Starting a conversation with an agent also needs WhatsApp agents to be enabled for the workspace.
Every send goes through the same checks as the console: a contact who opted out of WhatsApp (or is on your do-not-call list, or was erased) is never messaged, a template that is not approved is never sent, and a marketing template needs a recorded WhatsApp opt-in for that person (pass optIn when you have one). An opt-in you record never overrides a customer’s own STOP.
A business-initiated message outside the customer’s 24-hour window must be an approved template. Text and media are only accepted inside the window.
Test keys (ohk_test_…) run every check and return a simulated result with livemode: false; nothing is sent to WhatsApp and nothing is stored or changed. That covers starting a conversation and sending a message (a wamid.TEST_… id), ending a conversation (it stays open), and creating, editing, deleting, syncing templates and uploading header samples (the template that would result, a TEST_… handle; your templates at Meta are untouched). The same errors as a live key come back.
The message object (also the data of every message.* webhook):
{
"messageId": "wamsg_…",
"wamid": "wamid.HBgM…",
"chatId": "chatsess_…",
"threadId": "wathr_…",
"agentId": "agt_…",
"contactId": "con_…",
"externalRef": null,
"direction": "outbound",
"source": "api",
"type": "template",
"text": "Hi Asha, we missed you.",
"template": { "id": "watpl_…", "name": "missed_call_follow_up", "language": "en", "category": "UTILITY" },
"media": null,
"from": "+918050000000",
"to": "+919811111111",
"customerUserId": null,
"customerUsername": null,
"phoneNumberId": "1234567890",
"status": "delivered",
"timestamp": "2026-10-07T06:00:03Z",
"statuses": { "sentAt": "2026-10-07T06:00:01Z", "deliveredAt": "2026-10-07T06:00:03Z", "readAt": null, "failedAt": null }
}
customerUserId and customerUsername are the customer’s WhatsApp user id (such as IN.13491208655302741918) and @username, or null. For a customer who hides their number, the customer side of the message (from on a received message, to on a sent one) is null until WhatsApp shares the number.
A failed message also carries error: { code, title, detail }: Meta’s error code, or one of ours for a message refused before it reached Meta (see Local error codes). Inbound media carries media: { type, mimeType, messageId, available }; download it with Download media.
status is one of:
status |
Meaning |
|---|---|
queued |
Waiting to be sent (paced by a campaign or the number’s throughput). |
sent |
WhatsApp accepted it. |
delivered |
It reached the customer’s phone. |
read |
The customer opened it (when they share read receipts). |
failed |
Not delivered; see error. |
delivery_unknown |
WhatsApp did not confirm the send in time. It may still arrive and is never sent again. |
received |
An inbound message from the customer. |
deleted |
The customer deleted it, or it was erased (DPDP). |
Local error codes
error.code |
error.title |
Meaning |
|---|---|---|
-1001 |
contact_opted_out |
The person opted out of WhatsApp, is on your do-not-call list or was erased. |
-1003 |
text_too_long |
Over WhatsApp’s 4,096 characters. |
-1004 |
window_closed |
Free text / media outside the 24-hour window; send a template. |
-1005 |
invalid_message |
Not a valid WhatsApp message (too many buttons, an empty list…). |
-1006 |
opt_in_required |
A marketing template and no recorded opt-in. |
-1007 |
template_unavailable |
The template is not approved. |
-1008 |
template_not_found |
No template with that name and language on your account. |
Meta’s own codes and what to do about each are listed in the WhatsApp troubleshooting guide.
Start a conversation
POST /v1/whatsapp/conversations · scope whatsapp:write · rate limited · optional Idempotency-Key
Sends an approved template to a customer and opens a conversation with your WhatsApp agent: when the customer replies, the agent answers, with your variables available to its flow.
curl -X POST https://api.oyehello.ai/v1/whatsapp/conversations \
-H "Authorization: Bearer ohk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-1042" \
-d '{
"agentId": "agt_…",
"to": "+919811111111",
"template": { "id": "watpl_…", "params": { "body": { "customer_name": "Asha" } } },
"variables": { "lead_id": "L-1042" },
"contact": { "name": "Asha" },
"onActiveSession": "reject"
}'
template-id, orname(+language).params.bodyis an object for named templates ({{customer_name}}) and an array for numbered ones ({{1}},{{2}});params.headeris{ "type": "text", "text" }or{ "type": "image" | "video" | "document", "url" }(https);params.buttonsis[{ "index", "text" | "payload" | "code" }]. Omitparamsto fill the template from the agent’s template-tool mapping. For a template those fields cannot fill (carousel, catalog, multi-product or flow buttons), passparams.componentsinstead: Meta’s own template components in its send format, used as they are. See Carousel and other raw templates.from- optional; must be the number bound to the agent.contact- optional{ "name", "externalRef" }. The customer’s contact is found by number (or created).externalRef(your own id for the person) is stored the same way asPOST /v1/contactssets it: only on a contact that has no reference of its own yet. A contact that already has a different reference keeps it, and a reference another contact already holds is ignored; the conversation is started either way.metadata- optional; your own key/value data (up to 20 keys of 1-40 characters; string values up to 500 characters, numbers, booleans or null; 4 KB in all). It is stored on the conversation and returned on it, on List conversations / Get a conversation and in itschat.*webhooks. The agent never reads it. Sending into an open conversation (send_in_session) keeps that conversation’s metadata.optIn-{ "source", "capturedAt" }: the customer’s WhatsApp opt-in you collected (required for marketing templates unless already recorded).onActiveSession-reject(default:409 active_session_existswith the openchatId) orsend_in_session(the template goes into the open conversation,200).priorMessages- optional; up to 200 earlier messages with this customer, for a conversation your helpdesk or another bot hands to the agent:[{ "role": "customer" | "agent" | "bot", "text", "agentName"?, "sentAt"?, "wamid"? }](text up to 4096 characters, 256 KB in all). The agent reads them as background, never as instructions: messages with the samewamidcount once, control characters and leading “System:”-style labels are removed, and when they are long only the newest are kept. They are not sent to the customer.handoverNote- optional; up to 1000 characters for the agent (“VIP customer, refund already approved”). Not sent to the customer.priorMessagesandhandoverNoteonly seed a new conversation, so they cannot be combined withonActiveSession: "send_in_session"(400).
Response 201 (200 when sent into an open conversation):
{ "data": { "livemode": true, "chatId": "chatsess_…", "threadId": "wathr_…", "contactId": "con_…", "sentInSession": false, "metadata": { "crmId": "L-1042" },
"message": { "id": "wamsg_…", "wamid": "wamid.…", "status": "sent" } } }
Errors (every error body is { error, code, meta? }):
| Status | code |
When |
|---|---|---|
| 400 | (none) | The body is not valid (an unknown field, a bad value); error names the field. |
| 400 | invalid_idempotency_key |
The Idempotency-Key header is longer than 255 characters. |
| 400 | template_not_found |
No template with that id (or name + language) on your WhatsApp account. |
| 402 | insufficient_balance |
The wallet cannot pay for a conversation. |
| 403 | whatsapp_disabled |
WhatsApp is turned off for the workspace. |
| 403 | whatsapp_agents_off |
WhatsApp agents are not enabled for the workspace. |
| 403 | contact_opted_out |
The person opted out of WhatsApp, is on your do-not-contact list, or was erased (meta.reason). |
| 403 | opt_in_required |
A marketing template and no recorded WhatsApp opt-in (pass optIn). |
| 404 | agent_not_found |
No such agent in this workspace. |
| 409 | active_session_exists |
The customer already has an open conversation (meta.chatId); use onActiveSession: "send_in_session" to send into it. |
| 409 | agent_not_active |
The agent is not published. |
| 409 | agent_number_missing |
No WhatsApp number is bound to the agent. |
| 409 | agent_number_mismatch |
from is not the agent’s number (meta.phoneNumberId). |
| 409 | whatsapp_not_connected |
The workspace’s WhatsApp connection is missing. |
| 409 | idempotency_in_progress |
A request with the same Idempotency-Key is still running, or it ended in an unexpected 500. After a 500 the key stays held for 24 hours, because the message may have gone out: check GET /v1/whatsapp/messages (filter by contactId or templateName) before you retry with a new key. |
| 422 | agent_not_whatsapp |
The agent is not a WhatsApp agent. |
| 422 | invalid_number |
to is neither an international number nor a WhatsApp user id. |
| 422 | invalid_opt_in |
optIn.capturedAt is in the future. |
| 422 | template_unavailable |
The template exists but is not approved (paused, rejected, disabled…). |
| 422 | template_params_invalid |
A value is missing, extra or too long (every issue in meta.issues: { field, code, message }). |
| 422 | idempotency_key_reused |
The Idempotency-Key was used with a different body. |
| 429 | tier_limit_reached |
Your messaging limit for the last 24 hours is used up (also the per-minute rate limit: rate_limited). |
| 502 | delivery_failed |
WhatsApp rejected the message (meta.error has Meta’s code; the conversation ends unbilled). |
| 502 | delivery_failed (recipient_unsupported) |
WhatsApp can’t deliver this message type to a user id (for example authentication templates). |
| 503 | provider_unavailable |
WhatsApp did not confirm in time (meta.chatId); it may still arrive and is never sent again. |
With an Idempotency-Key, a retry returns the first response - including a 502 or 503 - and never sends the template twice.
End a conversation
POST /v1/whatsapp/conversations/{id}/end · scope whatsapp:write
Ends an open conversation (endCause: completed); ending an ended one returns its final state.
Take over a conversation
POST /v1/whatsapp/conversations/{id}/takeover · scope whatsapp:write
Your helpdesk (Zendesk, Freshdesk, your own inbox) takes the conversation from the AI agent: the agent stops replying until you hand it back. Your helpdesk replies with Send a message (free text inside the 24-hour window). The console inbox shows the conversation as handled by your helpdesk.
curl -X POST https://api.oyehello.ai/v1/whatsapp/conversations/chatsess_…/takeover \
-H "Authorization: Bearer ohk_live_…" -H "Content-Type: application/json" \
-d '{ "agentName": "Priya", "note": "Refund dispute" }'
Body (optional): agentName (up to 80 characters) and note (up to 1000), both echoed in the chat.taken_over webhook.
{ "data": { "id": "chatsess_…", "status": "taken_over", "alreadyTakenOver": false, "livemode": true } }
Safe to retry: taking over a conversation your helpdesk already holds returns alreadyTakenOver: true. A test key (ohk_test_) validates the request and answers without changing the conversation.
Hand a conversation back
POST /v1/whatsapp/conversations/{id}/handback · scope whatsapp:write
Gives the conversation back to the AI agent. Pass what your helpdesk told the customer so the agent knows: the customer’s own messages are already in the conversation.
curl -X POST https://api.oyehello.ai/v1/whatsapp/conversations/chatsess_…/handback \
-H "Authorization: Bearer ohk_live_…" -H "Content-Type: application/json" \
-d '{ "messages": [{ "text": "Your refund is approved.", "agentName": "Priya" }], "note": "Refund approved, close politely" }'
Body (optional): messages (up to 200 of { "text", "agentName"?, "sentAt"? }, 256 KB in all) - added to the conversation history as your team’s replies; note (up to 1000 characters) - added for the agent only, never sent to the customer.
{ "data": { "id": "chatsess_…", "status": "with_agent", "alreadyWithAgent": false, "livemode": true } }
Safe to retry: a conversation already with the agent returns alreadyWithAgent: true.
| Status | code |
When (take over and hand back) |
|---|---|---|
| 400 | (none) | The body is not valid; error names the field. |
| 403 | api_scope_missing |
The key lacks whatsapp:write. |
| 404 | not_found |
No WhatsApp conversation with that id in this workspace. |
| 409 | conversation_ended |
The conversation has ended. Start a new one. |
| 409 | held_by_teammate |
A teammate is handling it in the console inbox; they hand it back there first. |
Assign a conversation
POST /v1/whatsapp/conversations/{id}/assign · scope whatsapp:write
Gives the conversation to one of your teammates in the console inbox, as if they had taken it over: the AI agent stops replying and the teammate replies from the inbox. Use it to route conversations from your own system (by skill, language or customer value). Send exactly one of userId or email:
curl -X POST https://api.oyehello.ai/v1/whatsapp/conversations/chatsess_…/assign \
-H "Authorization: Bearer ohk_live_…" -H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'
{ "data": { "id": "chatsess_…", "assignedTo": "usr_…" } }
The teammate must be able to reply in the inbox (edit access to agents). GET /v1/whatsapp/assignees (scope whatsapp:read) lists them: { "data": [{ "userId", "name", "email" }] }. The API may move a conversation a teammate holds; it never takes one your helpdesk holds through Take over. Each assignment sends a chat.assigned webhook event.
| Status | code |
When |
|---|---|---|
| 400 | assignee_not_allowed |
That person is not a teammate who can reply in the inbox. |
| 403 | test_mode_read_only |
A test key: assigning changes a live conversation, so use a live key. |
| 404 | not_found |
No WhatsApp conversation with that id in this workspace (web and API chats are not assigned here). |
| 409 | conversation_ended |
The conversation has ended. |
| 409 | held_by_helpdesk |
Your helpdesk holds it; hand it back first. |
List conversations
GET /v1/whatsapp/conversations · scope whatsapp:read
Newest first, limit (1-100, default 50) with nextCursor. Filters: agentId, phoneNumberId, contactId, customer (E.164 number or WhatsApp user id), status (active / ended), endCause, origin (inbound, template_start, call_followup, campaign, ai_unavailable), startedAfter, startedBefore, includeTest (test chats are left out by default).
{ "data": [{ "id": "chatsess_…", "agentId": "agt_…", "contactId": "con_…", "externalRef": null,
"customer": { "phone": "+919811111111", "name": "Asha", "userId": null, "username": null }, "business": { "phone": "+918050000000", "phoneNumberId": "1234567890" },
"status": "ended", "endCause": "idle_timeout", "origin": "template_start", "messageCount": 4, "isTest": false,
"startedAt": "2026-10-07T06:00:00Z", "endedAt": "2026-10-07T09:10:00Z", "outcome": { "disposition": "interested", "boardColumn": "Interested" } }],
"nextCursor": null }
Get a conversation
GET /v1/whatsapp/conversations/{id} · scope whatsapp:read
?include=messages,analysis adds the conversation’s messages (oldest first) and analysis: { summary, extracted, disposition }.
adReferral (list and detail) is the click-to-WhatsApp ad or post that started the conversation, or null: { sourceId, sourceType ("ad" or "post"), sourceUrl, headline, body, mediaType, imageUrl, videoUrl, thumbnailUrl, ctwaClid } as Meta reported it. Use sourceId to match Meta Ads Manager and ctwaClid for Meta’s Conversions API. It is also on every chat.* webhook payload.
Every conversation (list and get) also carries labels ([{ "id", "name", "color" }], the labels your team applied in the inbox) and assignedTo (the teammate’s user id, or null).
Send a message
POST /v1/whatsapp/messages · scope whatsapp:write · rate limited · optional Idempotency-Key
One message without an agent conversation. Send exactly one of:
template(same shape as above), to anyone.text(up to 4096 characters).media:{ "type": "image" | "video" | "document" | "audio", "url": "https://…", "caption", "filename" }. Audio (AAC, AMR, MP3, M4A or OGG/Opus, up to 16 MB) takes no caption.interactive: reply buttons, a list or a link button (below).location:{ "latitude", "longitude", "name"?, "address"? }, a map pin.
to is the customer’s international number (+919811111111) or, for a customer who hides their number, their WhatsApp user id from a received message (customerUserId, for example IN.13491208655302741918). Templates and in-window messages both accept it. A response for a user id has to: null and customerUserId set; a test key answers in the same shape.
Everything except a template needs the customer’s 24-hour window on that number to be open (409 window_closed otherwise). A session message to a user id that has never written to that number is 409 window_closed.
interactive follows WhatsApp’s limits, checked before anything is sent (a 400 names the field):
type |
Fields | Limits |
|---|---|---|
buttons |
body, buttons: [{ "id", "title" }] |
1-3 buttons, titles up to 20 characters and all different, ids up to 256 and all different, body up to 1024 |
list |
body, button, sections: [{ "title", "rows": [{ "id", "title", "description"? }] }] |
button text up to 20, 1-10 sections, at most 10 rows in all, row title up to 24, description up to 72, id up to 200 (row ids all different), section title up to 24, body up to 4096 |
cta_url |
body, displayText, url |
a link button: displayText up to 20, an https:// url, body up to 1024 |
Each can add a text header and a footer (up to 60 characters each). The customer’s tap on a reply button or a list row arrives as a message.received with its id.
{ "to": "+919811111111", "interactive": { "type": "cta_url", "body": "Your bill for October is ready.", "displayText": "Pay now", "url": "https://pay.example.com/inv/123" } }
from (optional) picks which of your live WhatsApp numbers sends it: the number’s id, its phoneNumberId, or its E.164 number (spaces allowed), as listed by List numbers. Without from, your default number (isDefault: true) sends. A customer’s 24-hour window is per number: reply from the number they wrote to.
Response 201 with the message object (plus livemode). Errors as for Start a conversation (without the agent and conversation codes), plus 409 window_closed (text or media outside the 24-hour window) and 409 from_number_mismatch (from is not one of your live WhatsApp numbers). A test key gets the same errors; only the send itself is simulated.
Carousel and other raw templates
Some templates can’t be filled with header, body and buttons: carousels, catalog and multi-product templates, and flow buttons. Your console marks them as read-only. Send them on POST /v1/whatsapp/messages and POST /v1/whatsapp/conversations with template.params.components. This is Meta’s template components array in its send format, and it replaces header / body / buttons (passing both is refused):
"template": { "id": "watpl_…", "params": { "components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "Asha" }] },
{ "type": "carousel", "cards": [
{ "card_index": 0, "components": [
{ "type": "header", "parameters": [{ "type": "image", "image": { "link": "https://cdn.example.com/1.png" } }] },
{ "type": "button", "sub_type": "quick_reply", "index": "0", "parameters": [{ "type": "payload", "payload": "more-1" }] } ] },
{ "card_index": 1, "components": [ … ] } ] } ] } }
We check the shape before anything is sent:
- Each component is a
header(exactly one parameter), abody, abutton(sub_typeandindex0-9) or acarousel. - A carousel has 1-10 cards, numbered by
card_indexfrom 0. Each card has its own header, body and button components. - Every parameter has a known
type. - There are at most 20 components and the array is at most 16 KB.
A bad shape answers 422 template_params_invalid, with the path of each problem in meta.issues (code invalid_component). Meta checks the values themselves against the template, so a wrong card count or media type comes back as a failed message. A carousel template sent without components is refused. A template that header / body / buttons can fill refuses components.
Campaigns and agent template tools fill templates from per-person values, so they don’t take raw components. Send a carousel to a list from your own code, one POST /v1/whatsapp/messages per person.
List messages
GET /v1/whatsapp/messages · scope whatsapp:read
Newest first with nextCursor. Filters: chatId, threadId, contactId, direction, status, source, templateName, after, before.
Get a message
GET /v1/whatsapp/messages/{id} · scope whatsapp:read
Download media
GET /v1/whatsapp/media/{messageId} · scope whatsapp:read
Redirects (302) to a short-lived link for media a customer sent you. Outbound media is the link you sent; 404 for it, and 503 media_unavailable when the deployment stores no media.
List numbers
GET /v1/whatsapp/numbers · scope whatsapp:read
Every live number of the workspace (several can be live at once, each answered by its own agent).
{ "data": [{ "id": "wanum_…", "phoneNumberId": "1234567890", "phone": "+918050000000", "verifiedName": "Acme", "status": "registered",
"qualityRating": "GREEN", "tier": "TIER_2K", "agentId": "agt_…", "isDefault": true }] }
isDefault marks the number Send a message uses without from. tier is your business’s messaging limit (new customers per 24 hours), shared by all your numbers: TIER_50, TIER_250, TIER_2K, TIER_10K, TIER_100K or TIER_UNLIMITED.
List templates
GET /v1/whatsapp/templates · scope whatsapp:read
Filters: status, category, language, quality, q (name contains), with limit and nextCursor. Each template carries its components, variables, status, qualityScore, any announced recategorisation (pendingCategory, categoryChangeAt), editable and Meta’s edit allowance.
Get a template
GET /v1/whatsapp/templates/{id} · scope whatsapp:read
Create a template
POST /v1/whatsapp/templates · scope whatsapp:write
Body { "spec": { name, language, category, parameterFormat, header, body, footer, buttons }, "submit": true } - the same template builder as the console. submit: false saves a local draft. Returns 201 with the template (pending once submitted to Meta).
Errors: 409 name_taken (the name and language already exist - Meta also blocks a name for 30 days after an approved template with it was deleted), 409 not_connected (connect WhatsApp with your WABA ID first), 422 invalid_template (every problem in meta), 502 meta_rejected (Meta refused it; its details in meta).
Edit a template
PATCH /v1/whatsapp/templates/{id} · scope whatsapp:write
Body { "spec": … }. Meta allows one edit of an approved template per 24 hours and ten per 30 days: past that, 429 edit_limit. When we refused it from the edits we recorded, meta.nextEditAt is the earliest time the next edit is allowed; when Meta itself refused it (an edit made elsewhere, e.g. in WhatsApp Manager), meta.nextEditAt is null and Meta’s code / subcode / detail are in meta.
Other errors: 400 immutable_field (the variable style of a submitted template cannot change), 404 not_found, 409 not_editable (only approved, rejected or paused templates can be edited), 422 category_locked (an approved template’s category cannot change), 422 invalid_template (every problem in meta), 502 meta_rejected (Meta refused the edit; its details in meta).
Delete a template
DELETE /v1/whatsapp/templates/{id} · scope whatsapp:write
Deletes this language; ?allLanguages=true deletes every language of the name. A template used by an agent, a tool, a board action or a running campaign is refused with 409 template_in_use (the users in meta).
Sync templates
POST /v1/whatsapp/templates/sync · scope whatsapp:write
Reads every template from Meta now (202 with { synced, changed, deleted }). The mirror also syncs on its own and on every template webhook.
Upload a header sample
POST /v1/whatsapp/templates/media · scope whatsapp:write
A multipart upload (field file, JPEG / PNG / MP4 / PDF, up to 16 MB) for a media header’s example. Returns 201 { "data": { "handle", "mimeType", "size" } }; put the handle in the header of the template you create.
Webhook events
WhatsApp activity reaches your webhooks as:
| Event | When | Delivery |
|---|---|---|
message.received |
A customer message arrived. | Only to endpoints that list it |
message.sent |
WhatsApp accepted a message you sent. | Only to endpoints that list it |
message.delivered |
A message reached the phone. | Only to endpoints that list it |
message.read |
The customer read it. | Only to endpoints that list it |
message.failed |
A message failed or was refused (with error). |
Only to endpoints that list it |
chat.started |
A conversation with your agent started. | All-events endpoints too |
chat.completed |
It ended (with the transcript and endCause). |
All-events endpoints too |
chat.analyzed |
Its summary, extracted fields, sentiment and outcome are ready. | All-events endpoints too |
chat.evaluated |
It was graded against the agent’s eval criteria (again after a re-run). | Only to endpoints that list it |
chat.taken_over |
Your helpdesk took it over through the API. | All-events endpoints too |
chat.handed_back |
Your helpdesk handed it back. | All-events endpoints too |
chat.assigned |
It was assigned to a teammate (in the console, by the API, or by round-robin), with assignedTo, assignedBy and reason. |
All-events endpoints too |
contact.opted_out |
A customer opted out (channel: "whatsapp"). |
All-events endpoints too |
whatsapp.template.updated |
A template’s status, quality or category changed at Meta. | All-events endpoints too |
message.* events carry the message object above; chat.* events the conversation (see Webhooks).
Errors
Every error body is { error, code, meta? }. The codes the WhatsApp API returns:
| Status | code |
Endpoints | When |
|---|---|---|---|
| 400 | (none) | all | The body or query is not valid; error names the field. |
| 400 | invalid_cursor |
lists | The cursor is not one we returned. |
| 400 | invalid_idempotency_key |
start, send | The Idempotency-Key is over 255 characters. |
| 400 | template_not_found |
start, send | No such template (or name + language) on your account. |
| 400 | immutable_field |
edit template | The variable style of a submitted template can’t change. |
| 401 | (none) | all | The key is missing, unknown, revoked or expired. |
| 402 | insufficient_balance |
start | The wallet can’t pay for a conversation. |
| 403 | api_scope_missing |
all | The key lacks whatsapp:read / whatsapp:write. |
| 403 | whatsapp_disabled / whatsapp_agents_off |
start, send | WhatsApp, or WhatsApp agents, are off for the workspace. |
| 403 | contact_opted_out |
start, send | The person opted out, is on your do-not-call list, or was erased. |
| 403 | opt_in_required |
start, send | A marketing template and no recorded opt-in. |
| 404 | not_found / agent_not_found |
all | No such object in this workspace. |
| 409 | active_session_exists |
start | The customer already has an open conversation (meta.chatId). |
| 409 | agent_not_active / agent_number_missing / agent_number_mismatch |
start | Publish the agent / bind a number / from is not its number. |
| 409 | whatsapp_not_connected / not_connected |
start, send, templates | Connect WhatsApp first. |
| 409 | from_number_mismatch |
send | from is not one of your live numbers. |
| 409 | window_closed |
send | Text or media outside the 24-hour window. |
| 409 | conversation_ended / held_by_teammate |
takeover, handback | It ended, or a teammate holds it in the console. |
| 409 | idempotency_in_progress |
start, send | The same key is still running (or ended in a 500). |
| 409 | name_taken / not_editable / template_in_use |
templates | See Create, Edit, Delete. |
| 413 | file_too_large |
header sample | Over 16 MB. |
| 422 | agent_not_whatsapp / invalid_number / invalid_opt_in |
start | The agent isn’t a WhatsApp agent / to is neither an international number nor a WhatsApp user id / optIn.capturedAt is in the future. |
| 422 | template_unavailable / template_params_invalid |
start, send | The template isn’t approved / a value is missing, extra or too long (meta.issues). |
| 422 | idempotency_key_reused |
start, send | The key was used with a different body. |
| 422 | invalid_template / category_locked |
templates | Every problem in meta / an approved template’s category can’t change. |
| 429 | tier_limit_reached |
start, send | Your WhatsApp messaging limit (new customers per 24 hours) is used up. |
| 429 | rate_limited |
start, send | Over the API rate limit (120 requests per minute per workspace by default); retry after the time in error. |
| 429 | edit_limit |
edit template | One edit per 24 hours and ten per 30 days for an approved template (meta.nextEditAt). |
| 502 | delivery_failed / meta_rejected |
start, send, templates | WhatsApp refused it (Meta’s details in meta). recipient_unsupported (in meta.error.title) means that message type can’t go to a customer known only by a user id. |
| 502 | meta_unavailable |
templates | WhatsApp did not return your templates; retry. |
| 503 | provider_unavailable |
start, send | WhatsApp did not confirm in time; never sent twice. |
| 503 | media_unavailable |
media | Stored media isn’t available on this deployment. |
Exports (CSV of conversations) are in the console (WhatsApp → Inbox → History → Export CSV); the API reads conversations and messages page by page.
Bulk campaigns
To message a whole list with a template - paced within your messaging limit, inside a daily send window, with opt-in filtering, automatic pauses and per-person delivery, read and reply status - create a campaign with channel: "whatsapp": see Campaigns.