# PaalChat API > REST API that lets PaalTech products (Multi-SKUUL, SKUUL, Basic SKUUL, PaalPOS) send and receive WhatsApp messages for their customers. The platform is the Meta Tech Provider: it connects each customer's WhatsApp Business Account via a hosted Meta Embedded Signup page, holds all Meta credentials, sends messages, and reports events through signed HTTPS callbacks. A product never holds a Meta token and never calls Meta (graph.facebook.com) directly. Base URL: https://whatsapp.paaltech.org/api/v1 Full guide (all endpoints, callbacks, use cases): https://whatsapp.paaltech.org/llms-full.txt OpenAPI 3.1 spec: https://whatsapp.paaltech.org/openapi.yaml ## Rules (follow exactly) 1. Authenticate every request with `Authorization: Bearer ` and `Accept: application/json`. Keys look like `sk_live_...` (real businesses) or `sk_test_...` (sandbox businesses only, simulated WhatsApp - never reaches Meta); older `|ptw_...` tokens work as live keys. Keep them server-side only. 2. A "business" is one customer of the product, addressed by the PRODUCT's own `external_id` in every URL (`/businesses/{external_id}/...`). Multi-SKUUL uses the tenant ID. Never use the platform's internal IDs to address a business. 3. Create/update businesses with `POST /businesses` (upsert by external_id). Safe to call repeatedly. 4. To connect WhatsApp: `POST /businesses/{external_id}/whatsapp/connect` -> redirect the customer's admin to `data.url`. Do not build your own Meta/Facebook login. Wait for the `whatsapp.connection` callback with `event: "connected"` (or poll `GET /businesses/{external_id}/whatsapp` until `data.status == "connected"`). 5. Send with `POST /businesses/{external_id}/messages`. ALWAYS include `reference` = your own unique message ID. Re-sending the same reference returns the original message (HTTP 200) and sends nothing; a new message returns HTTP 202. On network errors, retry with the SAME reference. Never create a new reference for a retry. 6. `type: "text"` is allowed only if the recipient messaged the sending number (phone_number_id) in the last 24 hours - the window is per number; otherwise the API returns 422 `outside_service_window` -> send `type: "template"` with an APPROVED template instead. 7. Business-initiated notifications (reminders, receipts, alerts) must be templates; PaalChat checks the parameters against the template before Meta (422 invalid_template_parameters). Get them from `GET /businesses/{external_id}/whatsapp/templates?status=APPROVED`; count `{{n}}` placeholders in `components` to build parameters. 8. `to` is international digits with optional leading `+` (e.g. `+233241234567`). Meta IDs (`waba_id`, `phone_number_id`) are strings. 9. Message status only moves forward: queued < sent < delivered < read. `failed` is final and never follows `read`. Ignore updates that would move a status backwards. `unknown` (after queued) = Meta's answer to the send was lost; the message may or may not have been delivered and is not resent automatically; a later sent/delivered/read/failed can still arrive. Do not blindly resend an `unknown` message with a new reference - that can duplicate it. 10. Every error body is `{"error": {"code": "...", "message": "...", "request_id": "..."}}` (request_id = the X-Request-ID response header; you may send your own X-Request-ID, 8-128 chars [A-Za-z0-9._:-]). Branch on `error.code`, never on the message text. Validation errors (422 `validation_failed`) also include `errors: {field: [messages]}`. 11. Callbacks: verify `X-PaalChat-Signature: t=,v1=` where v1 = HMAC-SHA256(secret, "."), constant-time compare, reject if |now - t| > 300 s. Verify on the raw body before JSON parsing. Respond 2xx within 10 s; process slow work asynchronously. De-duplicate on `X-PaalChat-Delivery`. Callbacks can repeat and arrive out of order. 12. Rate limits: 300 requests/minute per product and 120 per business. On 429 `rate_limited`, wait `Retry-After` seconds. A business may also have a daily message cap (429 `business_limit_reached`). Bulk sends belong in a background queue. 13. Conversations: one per business number and contact; an incoming message reopens pending/resolved/snoozed, after closed a new one starts; a send starts a pending one. The platform keeps contacts, conversations and message content (encrypted; content emptied after 365 days by default). Webhook payloads and callbacks are kept only briefly (3 days; 14 when something failed). Store anything you must keep longer yourself. 14. Meta bills each business's WhatsApp Business Account directly, per delivered message; PaalChat never charges for Meta messages. From 2026-10-01 free-form replies inside the 24-hour window (category `service`) are billable too. `message.status` and the message resource carry `pricing: {billable, category, type, model}` (null until Meta reports it). 15. Any write may carry `Idempotency-Key: `: a retry with the same key and body returns the first response (header `Idempotent-Replayed: true`) for 24 h; the same key with a different body is 409 `idempotency_key_reused`. Messages also have `reference` (same effect, kept forever). 16. Versioning: /v1 only adds (endpoints, optional fields, response fields, events, error codes) - ignore unknown fields, events and codes. Breaking changes only in /v2. Deprecated endpoints send `Deprecation` and `Sunset` headers and keep working at least 6 months. PHP: `composer require paaltech/paalchat-laravel` (or paaltech/paalchat-php). 17. Templates (business-initiated) are checked in order: suppression list -> consent for the category (`category` field, default from the template's category) -> the contact's preferences (`topic` field) -> limits -> the business's quiet hours (`scheduled_for` set; `urgent: true` needs messages.urgent; transactional/AUTHENTICATION templates are never held). Replies inside 24 h are not checked. ## Endpoints | Method | Path | Ability | Success | |---|---|---|---| | GET | /me | any | 200 product slug, key `{name, environment: live|test, prefix, abilities, expires_at}` | | GET | /providers | any | 200 health per provider (meta, arkesel, hubtel, resend): healthy|degraded|unavailable | | GET | /plans | any | 200 PaalChat plans `{key, name, price, limits, features, placeholder}` | | GET | /usage?from=&to=&business= | businesses.read | 200 PaalChat usage per day and type (message.sent, message.received, template.sent, media.processed, callback.delivered, api.request) | | GET | /businesses | businesses.read | 200 list (50/page, `links.next`) | | POST | /businesses | businesses.write | 201 created / 200 updated | | GET | /businesses/{external_id} | businesses.read | 200 | | POST | /businesses/{external_id}/whatsapp/connect | connections.manage | 201 `{url, expires_at}` (72 h, one-time) | | GET | /businesses/{external_id}/whatsapp | connections.read | 200 `{status, wabas[{waba_id, status, messaging_limit, health, phone_numbers[]}]}` | | GET | /businesses/{external_id}/whatsapp/phones | connections.read | 200 phone numbers | | GET | /businesses/{external_id}/whatsapp/templates?status=&name=&language= | templates.read | 200 templates (100/page) | | POST | /businesses/{external_id}/whatsapp/templates | templates.manage | 201 template (PENDING until Meta decides; decision as template.status) `{name, language, category, components, parameter_format?, waba_id?}` | | GET | /template-library?sector=school|retail | templates.read | 200 starter templates `{key, title, category, components}` | | POST | /businesses/{external_id}/whatsapp/templates/from-library | templates.manage | 201 template (copied with the business name, submitted) `{key, name?, waba_id?}` | | GET | /businesses/{external_id}/whatsapp/templates/{name}/versions?language= | templates.read | 200 every version of its content, newest first | | DELETE | /businesses/{external_id}/whatsapp/templates/{name}?language= | templates.manage | 200 `{deleted}` (one language, or all) | | GET | /businesses/{external_id}/whatsapp/transfers | connections.manage | 200 account transfers in/out `{direction, from, to, status, completes_at}` | | POST | /businesses/{external_id}/whatsapp/transfers/{id}/{decision} | connections.manage | 200 transfer (decision: approve or reject; both sides approve, then it moves after 24 h) | | DELETE | /businesses/{external_id}/whatsapp/wabas/{waba_id} | connections.manage | 200 disconnected | | POST | /businesses/{external_id}/messages | messages.send | 202 queued / 200 duplicate reference | | GET | /businesses/{external_id}/messages/{id} | messages.read | 200 message with status and content | | POST | /businesses/{external_id}/sandbox/incoming | messages.send | 201 message (test keys only: simulate a customer writing `{from, text, profile_name?}`) | | POST | /businesses/{external_id}/sandbox/messages/{id}/status | messages.send | 200 message (test keys only: `{status: delivered|read|failed, error?}`) | | POST | /businesses/{external_id}/inbox/sign-in | inbox.manage | 201 `{url, expires_at, member}` - redirect the staff member's browser to url (one use, 5 min) `{staff: {external_id, name, email?, role: admin|agent|viewer}, conversation_id?}` | | GET | /businesses/{external_id}/inbox/members | inbox.manage | 200 members `{external_id, name, role, active, last_seen_at}` | | DELETE | /businesses/{external_id}/inbox/members/{member_id} | inbox.manage | 200 member (access removed; their conversations unassigned) | | POST | /businesses/{external_id}/media | messages.send | 201 media (multipart `file`, optional `filename`; type detected from the bytes) | | GET | /businesses/{external_id}/media/{id} | messages.read | 200 media `{status: pending|stored|failed|deleted, mime_type, size, sha256, url (signed, 15 min, no token), url_expires_at}` | | GET | /businesses/{external_id}/meta-usage?month=YYYY-MM | messages.read | 200 estimated Meta usage per number and category `{lines[{billable, free, estimated_cost, currency}], reconciliation, notice}` (Meta bills the business directly) | | POST | /businesses/{external_id}/meta-usage/estimate | messages.read | 200 `{estimated_cost, currency, unpriced}` for `{category, recipients: {"233": 1200}}` | | GET | /businesses/{external_id}/channels | connections.read | 200 channels (WhatsApp accounts, sms, email) | | PUT | /businesses/{external_id}/channels/{channel} | connections.manage | 200 set up sms `{sender_id?, provider?: arkesel|hubtel, arkesel?: {api_key}}` (own key never returned; `own_key`) or email `{from, from_name?, reply_to?}` | | DELETE | /businesses/{external_id}/channels/{channel} | connections.manage | 200 turned off | | POST | /businesses/{external_id}/notifications | messages.send | 202 notification - tried on channels in order with failover `{channels?, contact_id | to{whatsapp,sms,email}, whatsapp{template}, sms{text}, email{subject,text}, reference}` | | GET | /businesses/{external_id}/notifications/{id} | messages.read | 200 notification with attempts | | GET | /businesses/{external_id}/billing | billing.manage | 200 `{paalchat: {plan, subscription, usage, invoices}, meta_whatsapp: {notice, this_month}}` | | POST | /businesses/{external_id}/billing/invoices/{id}/pay | billing.manage | 200 `{url}` Paystack checkout | | GET | /businesses/{external_id}/contacts?phone=&external_id=&tag=&search= | contacts.read | 200 contacts (50/page) | | POST | /businesses/{external_id}/contacts | contacts.write | 201 created / 200 updated (matched by external_id, else phone) | | GET | /businesses/{external_id}/contacts/{id} | contacts.read | 200 contact with identities, custom_fields, tags | | PATCH | /businesses/{external_id}/contacts/{id} | contacts.write | 200 (tags replaced; custom field null clears it) | | GET | /businesses/{external_id}/contacts/{id}/consents | contacts.read | 200 `{consents: {marketing, notifications, transactional: granted|revoked|null}, preferences}` | | PUT | /businesses/{external_id}/contacts/{id}/consents/{category} | contacts.write | 200 `{status: granted|revoked, source?}` | | PUT | /businesses/{external_id}/contacts/{id}/preferences | contacts.write | 200 `{channels: {whatsapp: bool}, topics: {fees|results|attendance|pta|marketing|system: bool}}` | | POST | /businesses/{external_id}/exports | data.manage | 202 export `{id, status: pending}` -> GET until ready, then download `url` (15 min) | | GET | /businesses/{external_id}/exports/{id} | data.manage | 200 export `{status: pending|ready|failed|expired, url, expires_at}` | | DELETE | /businesses/{external_id}/contacts/{id} | data.manage | 200 erased `{pseudonym}` (right to be forgotten; cannot be undone) | | GET/POST | /businesses/{external_id}/segments | contacts.read / contacts.write | segments `{name, conditions}` (conditions: {"all"|"any": [{field, op, value}]}) | | POST | /businesses/{external_id}/segments/preview | contacts.read | 200 `{count, sample}` | | GET/PATCH/DELETE | /businesses/{external_id}/segments/{id} | contacts.read / contacts.write | segment (GET adds count) | | GET | /businesses/{external_id}/segments/{id}/contacts | contacts.read | 200 matching contacts (100/page) | | GET/POST | /businesses/{external_id}/campaigns | campaigns.read / campaigns.manage | campaigns; create a draft `{name, template{name, language}, parameters{var: "field:name"|"custom:key"|"text:..."}, segment_id|contact_ids, send_at?, repeat?: week|month, per_minute?}` | | GET | /businesses/{external_id}/campaigns/{id} | campaigns.read | 200 campaign + stats (sent, delivered, read, failed, replied, rates, skip_reasons) | | POST | /businesses/{external_id}/campaigns/{id}/preview | campaigns.read | 200 `{recipients, skipped{reason: n}, estimated_meta_cost, messaging_limit}` | | POST | /businesses/{external_id}/campaigns/{id}/launch | campaigns.manage | 200 sending or scheduled | | POST | /businesses/{external_id}/campaigns/{id}/{action} | campaigns.manage | 200 campaign (action: pause, resume or cancel) | | DELETE | /businesses/{external_id}/messages/{id} | messages.send | 200 cancelled (scheduled messages only; else 409 not_cancellable) | | GET/POST | /businesses/{external_id}/automations | automations.read / automations.manage | automations `{name, trigger: message_received|keyword|contact_created|conversation_status|date_reached, trigger_config, conditions?, steps[]}` | | GET/PATCH/DELETE | /businesses/{external_id}/automations/{id} | automations.read / automations.manage | automation (PATCH status: active|paused) | | GET | /businesses/{external_id}/automations/{id}/runs | automations.read | 200 runs with per-step log | | GET | /businesses/{external_id}/suppressions | contacts.read | 200 suppressed numbers | | POST | /businesses/{external_id}/suppressions | contacts.write | 201 `{address, reason?}` | | DELETE | /businesses/{external_id}/suppressions/{address} | contacts.write | 200 removed | | GET | /businesses/{external_id}/contact-fields | contacts.read | 200 custom field definitions | | PUT | /businesses/{external_id}/contact-fields/{key} | contacts.write | 201 created / 200 updated `{label, type: text|number|date|boolean|choice, choices}` | | GET | /businesses/{external_id}/conversations?status=&contact_id=&unread= | conversations.read | 200 conversations (latest activity first) | | GET | /businesses/{external_id}/conversations/{id} | conversations.read | 200 conversation (`window_open` = free-form allowed) | | PATCH | /businesses/{external_id}/conversations/{id} | conversations.manage | 200 `{status, snoozed_until, priority, tags, read: true, assignee: |null}` | | GET | /businesses/{external_id}/conversations/{id}/messages | messages.read | 200 messages with content (newest first) | | GET | /businesses/{external_id}/conversations/{id}/notes | conversations.read | 200 internal notes | | POST | /businesses/{external_id}/conversations/{id}/notes | conversations.manage | 201 note `{body, author}` (never sent to the contact) | ## Send request ```json {"to": "+233241234567", "type": "template", "reference": "notification-4411", "template": {"name": "fees_reminder", "language": "en_US", "components": [{"type": "body", "parameters": [{"type": "text", "text": "Mrs Mensah"}, {"type": "text", "text": "Kofi"}]}]}} ``` ```json {"to": "233241234567", "type": "text", "reference": "inbox-msg-991", "text": {"body": "Yes, Kofi can collect his report on Friday."}} ``` ```json {"to": "233241234567", "type": "document", "reference": "receipt-5521", "media": {"id": 12, "caption": "Your receipt", "filename": "receipt.pdf"}} ``` Optional `channel`: whatsapp (default), sms or email - SMS/email send type text (email also `subject`), always business-initiated. Optional `phone_number_id` (required only when the business has several numbers). Templates may take `send_at` (schedule; cancel with DELETE .../messages/{id}). Text max 4096 chars. Media (image, video, audio, document): upload first (POST .../media), then send `media.id`; like text, only inside the 24 h window. Needs media enabled for the business (else 403 feature_disabled). ## Send response (202 or 200) ```json {"data": {"id": 2, "reference": "notification-4411", "wamid": null, "direction": "outgoing", "contact": "233241234567", "phone_number_id": "106540352242922", "type": "template", "template_name": "fees_reminder", "status": "queued", "error": null, "pricing": null, "created_at": "2026-09-30T18:54:28+00:00", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null}} ``` ## Error codes -> what to do | HTTP | error.code | Action | |---|---|---| | 401 | unauthenticated | Fix/rotate the token. Do not retry. | | 403 | missing_ability | Token lacks the ability. Ask the operator for the right abilities. | | 403 | product_suspended / product_token_required | Stop; contact PaalTech. | | 403 | forbidden | Not allowed; do not retry. | | 404 | not_found | Business/WABA/message not found or not yours. Check external_id; create the business first. | | 404 | unknown_phone_number | phone_number_id not connected for this business. | | 405 / 413 | method_not_allowed / payload_too_large | Fix the request (method, body size). | | 409 | not_connected | No connected number. Offer the connect link (rule 4). | | 409 | connection_paused | PaalChat operators paused sending. Retry later; do not reconnect. | | 403 | sandbox_only | Simulations need a test key. | | 409 | sandbox_not_connected / sandbox_not_sent | Connect the sandbox business first / only sent messages get statuses. | | 422 | invalid_idempotency_key | Idempotency-Key: 1-255 printable chars, no spaces. | | 409 | idempotency_key_reused | Use a new Idempotency-Key for a new request. | | 409 | idempotency_in_progress | Retry shortly with the same key. | | 429 | business_limit_reached | The business hit its daily message cap; retry tomorrow. | | 422 | recipient_suppressed / recipient_opted_out / recipient_preference_off | Do not message first; respect the contact's choice (replies inside 24 h still work). | | 403 | urgent_not_allowed | `urgent: true` needs the messages.urgent ability. | | 422 | invalid_template_parameters | Give every {{variable}} (named: parameter_name), header media and URL-button value the template needs; no new lines/tabs in values. | | 409 | not_cancellable / campaign_not_allowed | Too late to cancel / the campaign cannot do that now. | | 409 | transfer_not_pending | The transfer was already decided, cancelled or completed. | | 409 | channel_not_enabled | Set up SMS/email for the business first (PUT .../channels/{channel}). | | 403 | account_not_verified | Self-serve account: verify the phone number in the dashboard first. | | 429 | sending_limit_reached | New self-serve account: daily template limit reached; retry tomorrow. | | 422 | channel_not_allowed | Use an allowed sender ID / from address / provider. | | 422 | invalid_provider_credentials | Arkesel does not accept the API key; check it in Arkesel. | | 503 | provider_unavailable | Arkesel could not be reached to check the key; retry shortly. | | 402 | plan_limit_reached / plan_feature_missing | The business's PaalChat plan does not allow it; upgrade. | | 503 | payment_unavailable | Retry paying later. | | 409 | business_suspended | Stop sending for this business. | | 422 | validation_failed | Fix fields listed in `errors`. | | 403 | feature_disabled | Media is not enabled for this business; ask PaalTech. Do not retry. | | 422 | unknown_member | Sign the staff member in to the inbox first, then assign by their external_id. | | 404 | unknown_media | Upload the file first; use its id. | | 409 | media_not_ready | Wait for media.updated (stored), then send. | | 422 | media_type_mismatch / unsupported_media / media_too_large | Fix the file or the message type. | | 422 | outside_service_window | Send an APPROVED template instead of text. | | 422 | unknown_template / template_not_approved | Use an APPROVED template name+language from the templates endpoint. | | 422 | phone_number_required | Pass phone_number_id. | | 422 | template_rejected | Meta refused the template; fix it using the message. | | 422 | waba_required | Several WhatsApp accounts: pass waba_id. | | 503 | meta_unavailable | Meta did not answer normally; retry later. | | 422 | return_url_not_allowed | The connect link's return_url origin is not registered for the product. Use a registered origin or ask PaalTech to add it. | | 429 | rate_limited | Wait Retry-After seconds, then retry (same reference). | | 500 | server_error | Retry with exponential backoff (same reference). | ## Callbacks (POST to each of the product's webhook endpoints that wants the event; each endpoint has its own secret and its own X-PaalChat-Delivery ids) Envelope: `{"id": "", "event": "", "occurred_at": "", "business": {"external_id": "..."}, "sandbox": false, "data": {...}}` (`sandbox: true` for test-key businesses) Headers: `X-PaalChat-Event`, `X-PaalChat-Delivery` (= id), `X-PaalChat-Signature`. Retries on non-2xx: 1m, 5m, 30m, 2h, 6h, 12h, then failed. - `message.received` data: `message_id, wamid, from, profile_name, phone_number_id, type, timestamp, message` (`message` = Meta's message object; text at `message.text.body`; quick-reply at `message.button`; interactive at `message.interactive`; media only as `message..id`; reply-to at `message.context.id`). Opens the 24 h window for `from` on that `phone_number_id` only. - `message.status` data: `message_id, reference, wamid, to, phone_number_id, status (sent|delivered|read|failed|unknown), previous_status, error {code, message} | null, pricing {billable, category, type, model} | null, sent_at, delivered_at, read_at, failed_at`. Match your message by `reference`. - `template.status` data: `waba_id, name, language, status (APPROVED|REJECTED|PAUSED|...), previous_status, reason`. - `contact.created` / `contact.updated` data: `contact` (same shape as GET contact). Created on a contact's first message in or out, or through the API. - `conversation.created` / `conversation.updated` data: `change` (created, reopened, status_changed, priority_changed, assigned, unassigned, tag_added, tag_removed), `conversation` (same shape as GET conversation). New messages alone do not fire these. - `message.received` and `message.status` data also carry `contact_id` and `conversation_id`; `message.received` also `media_id` (PaalChat's copy of an attachment, or null). - `contact.consent` data: `contact_id, contact_external_id, phone, category (marketing|notifications|transactional), status (granted|revoked), source (keyword|api|...)`. A STOP reply revokes marketing and notifications; START grants them. - `campaign.updated` data: `campaign_id, parent_id, name, status (scheduled|sending|paused|completed|cancelled), counts, started_at, completed_at`. - `automation.action` data: `automation_id, run_id, contact_id, contact_external_id, conversation_id, data` (a notify step reached). - `notification.updated` data: `notification_id, reference, status (delivered|failed), channel, attempts[{channel, outcome, message_id}]`. - `message.status` data also has `channel` (whatsapp|sms|email). - `media.updated` data: `media {id, direction, status (stored|failed), mime_type, filename, size, sha256, url (signed, 15 min), url_expires_at, error}, message_id, conversation_id`. Fetch the bytes from `url` (no token) soon; later, GET .../media/{id} for a fresh url. - `whatsapp.connection` data: `waba_id, event, status`. status: pending|connecting|connected|degraded|maintenance|suspended|revoked|disconnected (connected and degraded can send). event: `connected` (enable), `setup_failed` / `revoked` (offer reconnect), `disconnected` (disable, offer reconnect), `token_expiring` (status degraded; ask to reconnect within 7 days), `recovered`, `paused` / `resumed` (operators paused sending: 409 connection_paused meanwhile), `suspended` (Meta disabled the account; wait), `released` (the disconnected account left this business; forget it), `transfer_requested` (approve or reject via the transfers endpoints), `transferred_out` / `transferred_in` (an approved transfer moved the account), or a Meta notice name (informational). ## Signature check (PHP) ```php function paalchatSignatureValid(string $header, string $rawBody, string $secret): bool { if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m)) return false; if (abs(time() - (int) $m[1]) > 300) return false; return hash_equals(hash_hmac('sha256', $m[1].'.'.$rawBody, $secret), $m[2]); } ``` ## Common tasks -> calls - New customer: POST /businesses -> POST .../whatsapp/connect -> redirect admin -> callback whatsapp.connection connected. - Notification: GET .../whatsapp/templates?status=APPROVED -> POST .../messages type=template with reference -> callbacks message.status. - Inbox reply: callback message.received -> store -> POST .../messages type=text to `from` from the same phone_number_id within 24 h (on outside_service_window use a template). - Incoming photo/document: callback message.received (media_id) -> callback media.updated (stored) -> GET media.url, keep the bytes yourself. - Staff open the inbox: POST .../inbox/sign-in for the logged-in staff member -> redirect their browser to data.url (never reuse it). Staff leave: DELETE .../inbox/members/{their id}. - Retry after timeout: POST .../messages again with the same body and reference. - Customer leaves: GET .../whatsapp -> DELETE .../whatsapp/wabas/{waba_id} for each WABA. - Connection broken (whatsapp.connection revoked/setup_failed/disconnected/token_expiring or 409 not_connected): show GET .../whatsapp health.last_error -> new connect link. ## Not available (do not attempt) - Calling Meta's Graph API directly with any token.