# PaalChat - full documentation Generated from https://whatsapp.paaltech.org/docs. API base URL: https://whatsapp.paaltech.org/api/v1 --- # PaalChat Documentation Connect your customers' WhatsApp Business numbers and message their customers from one API. PaalChat connects PaalTech products - Multi-SKUUL, SKUUL, Basic SKUUL, PaalPOS and the ones to come - to the WhatsApp Business Platform. It runs Meta's Embedded Signup for your customers, keeps their Meta credentials, sends and receives WhatsApp messages, and tells your product what happened through signed callbacks. Your product never holds a Meta token and never calls Meta. It calls PaalChat and receives callbacks. Use the guides when you need context, then switch to the [API Reference](/docs/api) for exact request and response details. The API lives at `https://whatsapp.paaltech.org/api/v1`. ## Jump right in - [Quickstart](/docs/quickstart) - Make your first authenticated request and send a template message. - [Connect WhatsApp](/docs/connect-whatsapp) - Send your customer to the hosted Meta signup and know when they are connected. - [Send messages](/docs/sending-messages) - Templates, free-form text, the 24-hour window and safe references. - [Receive messages](/docs/receiving-messages) - Build an inbox from message.received callbacks and reply in time. - [Callbacks](/docs/callbacks) - Verify signatures, acknowledge fast and handle retries. - [API Reference](/docs/api) - Every endpoint, parameter, response and error. ## How it fits together ```text Your product (e.g. Multi-SKUUL) PaalChat Meta ───────────────────────────── ──────── ────── POST /businesses ───────────▶ business "presec" POST .../whatsapp/connect ───────────▶ one-time link ─────────────▶ school admin opens it, hosted signup page ◀──────▶ Embedded Signup (Meta login) verifies with Meta, stores the encrypted token ◀── callback whatsapp.connection (connected) POST .../messages ───────────▶ queued ──────────────────▶ Cloud API /messages ◀── callback message.status (sent, delivered, read / failed) ◀── Meta webhook customer replies ◀──────── Meta webhook ◀── callback message.received ``` Three parties, three kinds of identifiers: | Who | Identified by | Example | |---|---|---| | Your product | its API token | `7\|ptw_4gV...` | | Your customer (a school, a shop) | **your** `external_id` | `presec` (Multi-SKUUL: the tenant ID) | | Meta objects | Meta's numeric IDs | WABA `102290129340398`, phone number `106540352242922` | PaalChat never needs to know your database. It only stores the `external_id` you give it. ## Requests and responses - JSON in and out: send `Content-Type: application/json` and `Accept: application/json`. - Successful responses wrap the resource in `data`; lists add `links` and `meta` for paging. - Every error has the same shape: `{"error": {"code": "...", "message": "..."}}`. See [Errors and status codes](/docs/errors). - Timestamps are ISO 8601 in UTC. Meta IDs are strings. ## Downloads - [Postman collection](/docs/paalchat.postman_collection.json) - every endpoint, with variables for the base URL, token and business. - [OpenAPI 3.1 spec](/openapi.yaml) - for client generators. - [AI version](/docs/ai) - every page as Markdown, plus `llms.txt`. ## Next steps 1. [Get a token and make your first call](/docs/quickstart). 2. [Understand abilities](/docs/authentication) and ask only for what you need. 3. [Go through the go-live checklist](/docs/go-live-checklist) before production. --- # Quickstart Make your first authenticated request, connect a customer and send a template message. > **In a nutshell:** Register your customer as a business, send their admin to the connect link, then send an approved template with a reference. Everything else arrives as callbacks. ## Before you start You need a **product token** from a PaalChat operator. It is shown once - put it in your server's secret store, for example `PAALCHAT_TOKEN`. > [!WARNING] > Never use the token in a browser, mobile app or public repository. All calls to > PaalChat must come from your server. ## 1. Check the token ```bash curl https://whatsapp.paaltech.org/api/v1/me \ -H "Authorization: Bearer $PAALCHAT_TOKEN" \ -H "Accept: application/json" ``` ```php $me = Http::withToken(config('services.paalchat.token')) ->acceptJson() ->get('https://whatsapp.paaltech.org/api/v1/me') ->throw() ->json('data'); ``` ```javascript const res = await fetch('https://whatsapp.paaltech.org/api/v1/me', { headers: { Authorization: `Bearer ${process.env.PAALCHAT_TOKEN}`, Accept: 'application/json' }, }); const { data: me } = await res.json(); ``` ```python import os, requests me = requests.get( "https://whatsapp.paaltech.org/api/v1/me", headers={"Authorization": f"Bearer {os.environ['PAALCHAT_TOKEN']}", "Accept": "application/json"}, ).json()["data"] ``` The response names your product and the token's abilities: ```json {"data": {"product": "multi-skuul", "name": "Multi-SKUUL", "token": {"name": "production", "abilities": ["businesses.read", "businesses.write", "connections.read", "connections.manage", "templates.read", "messages.read", "messages.send"], "expires_at": null}}} ``` ## 2. Register a customer as a business Use your own ID for the customer as `external_id`. Calling it again updates the business. ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{"external_id": "presec", "name": "Presec Legon", "email": "info@presec.edu.gh"}' ``` ```php Http::withToken(config('services.paalchat.token'))->acceptJson() ->post('https://whatsapp.paaltech.org/api/v1/businesses', [ 'external_id' => 'presec', 'name' => 'Presec Legon', 'email' => 'info@presec.edu.gh', ])->throw(); ``` ```javascript await fetch('https://whatsapp.paaltech.org/api/v1/businesses', { method: 'POST', headers: { Authorization: `Bearer ${process.env.PAALCHAT_TOKEN}`, Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ external_id: 'presec', name: 'Presec Legon', email: 'info@presec.edu.gh' }), }); ``` ```python requests.post( "https://whatsapp.paaltech.org/api/v1/businesses", headers={"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"}, json={"external_id": "presec", "name": "Presec Legon", "email": "info@presec.edu.gh"}, ).raise_for_status() ``` ## 3. Connect their WhatsApp Ask for a one-time link and redirect the customer's administrator to it. ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/connect \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{"return_url": "https://presec.skuuls.paaltech.org/settings/whatsapp"}' ``` ```php $link = Http::withToken(config('services.paalchat.token'))->acceptJson() ->post('https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/connect', [ 'return_url' => route('settings.whatsapp'), ])->throw()->json('data'); return redirect()->away($link['url']); ``` ```javascript const { data: link } = await (await fetch(`${BASE}/businesses/presec/whatsapp/connect`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ return_url: 'https://presec.skuuls.paaltech.org/settings/whatsapp' }), })).json(); res.redirect(link.url); ``` ```python link = requests.post(f"{BASE}/businesses/presec/whatsapp/connect", headers=HEADERS, json={"return_url": "https://presec.skuuls.paaltech.org/settings/whatsapp"}).json()["data"] return redirect(link["url"]) ``` The admin logs in to Meta on PaalChat's page and picks a number. You receive a `whatsapp.connection` callback with `"event": "connected"`. See [Connect WhatsApp](/docs/connect-whatsapp). ## 4. Send a template Business-initiated messages must use an approved template. Always send your own `reference`. ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses/presec/messages \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{ "to": "+233241234567", "type": "template", "reference": "skuul-msg-8812", "template": {"name": "fees_reminder", "language": "en_US", "components": [{"type": "body", "parameters": [ {"type": "text", "text": "Mrs Mensah"}, {"type": "text", "text": "Kofi"}, {"type": "text", "text": "450.00"}, {"type": "text", "text": "30 October"}]}]} }' ``` ```php $message = Http::withToken(config('services.paalchat.token'))->acceptJson() ->post('https://whatsapp.paaltech.org/api/v1/businesses/presec/messages', [ 'to' => '+233241234567', 'type' => 'template', 'reference' => 'skuul-msg-'.$notification->id, 'template' => [ 'name' => 'fees_reminder', 'language' => 'en_US', 'components' => [['type' => 'body', 'parameters' => [ ['type' => 'text', 'text' => 'Mrs Mensah'], ['type' => 'text', 'text' => 'Kofi'], ['type' => 'text', 'text' => '450.00'], ['type' => 'text', 'text' => '30 October'], ]]], ], ])->throw()->json('data'); ``` ```javascript const { data: message } = await (await fetch(`${BASE}/businesses/presec/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ to: '+233241234567', type: 'template', reference: `skuul-msg-${notification.id}`, template: { name: 'fees_reminder', language: 'en_US', components: [{ type: 'body', parameters: [ { type: 'text', text: 'Mrs Mensah' }, { type: 'text', text: 'Kofi' }, { type: 'text', text: '450.00' }, { type: 'text', text: '30 October' }] }] }, }), })).json(); ``` ```python message = requests.post(f"{BASE}/businesses/presec/messages", headers=HEADERS, json={ "to": "+233241234567", "type": "template", "reference": f"skuul-msg-{notification.id}", "template": {"name": "fees_reminder", "language": "en_US", "components": [{"type": "body", "parameters": [ {"type": "text", "text": "Mrs Mensah"}, {"type": "text", "text": "Kofi"}, {"type": "text", "text": "450.00"}, {"type": "text", "text": "30 October"}]}]}, }).json()["data"] ``` `202 Accepted` means the message is queued. `200 OK` means that `reference` was already sent - you get the original message back and nothing is sent twice. ## 5. Receive callbacks A PaalChat operator registers your callback URL and gives you a signing secret. You will receive `message.status` as the message is sent, delivered and read, and `message.received` when the parent replies. Verify every callback - see [Callbacks](/docs/callbacks). ## Next steps - [Sending messages](/docs/sending-messages) in depth, including free-form text and the 24-hour window. - [Receiving messages](/docs/receiving-messages) to build an inbox. - [Safe retries](/docs/safe-retries) so network failures never send twice. --- # Authentication API keys, abilities, live and test, and how to keep keys safe. > **In a nutshell:** Send your API key as a Bearer token on every request. sk_live_ keys work on real businesses, sk_test_ keys on sandbox businesses only. Each key has abilities; a request without the needed ability gets 403 missing_ability. ## API keys Every request carries one of your product's API keys: ```http Authorization: Bearer sk_live_4gVx... Accept: application/json ``` - **`sk_live_...`** keys work on your real businesses. **`sk_test_...`** keys work on sandbox businesses only - a separate set, with simulated WhatsApp that never reaches Meta (see [Testing](/docs/testing)). The two never see each other's businesses. - Keys are created by a PaalChat operator and shown **once**. PaalChat stores only a hash and the first characters (shown as `prefix`), so a lost key cannot be recovered - it is replaced instead. Revoked or expired keys stop working at once (`401`). - A key can be limited to your servers' IP addresses; from anywhere else it is refused. - The prefixes let secret scanners spot leaked keys. Older `|ptw_...` tokens still work, as live keys. - Operator console logins do not work on the API; only API keys do. - If your product is suspended, every key is refused with `403 product_suspended`. > [!WARNING] > Don't use your token in a frontend. Never call PaalChat from a browser or mobile > app: all requests must come from your server, and your frontend talks to your server. ## Abilities Each token has abilities. Ask only for the ones your integration needs. | Ability | Grants | |---|---| | `businesses.read` | [List businesses](/docs/api/list-businesses), [Get business](/docs/api/get-business) | | `businesses.write` | [Create or update business](/docs/api/upsert-business) | | `connections.read` | [WhatsApp state](/docs/api/get-whatsapp), [phone numbers](/docs/api/list-phone-numbers) | | `connections.manage` | [Create connect link](/docs/api/create-connect-link), [Disconnect WABA](/docs/api/disconnect-waba) | | `templates.read` | [List templates](/docs/api/list-templates) | | `templates.manage` | [Create template](/docs/api/create-template), [Delete template](/docs/api/delete-template) | | `messages.read` | [Get message](/docs/api/get-message) | | `messages.send` | [Send message](/docs/api/send-message) | | `contacts.read` | [List contacts](/docs/api/list-contacts), [Get contact](/docs/api/get-contact), [contact fields](/docs/api/list-contact-fields) | | `contacts.write` | [Create or update contact](/docs/api/upsert-contact), [Update contact](/docs/api/update-contact), [Define a contact field](/docs/api/define-contact-field) | | `conversations.read` | [List conversations](/docs/api/list-conversations), [Get conversation](/docs/api/get-conversation), [List notes](/docs/api/list-conversation-notes) | | `businesses.read` (also) | [Get usage](/docs/api/get-usage) | | `messages.send` (also, test keys) | [Simulate an incoming message](/docs/api/sandbox-incoming), [Simulate a delivery status](/docs/api/sandbox-status) | | `messages.urgent` | `urgent: true` on [Send message](/docs/api/send-message) - skip quiet hours | | `data.manage` | [Export business data](/docs/api/create-export), [Get export](/docs/api/get-export), [Erase contact](/docs/api/erase-contact) | | `campaigns.read` / `campaigns.manage` | [Campaigns](/docs/campaigns): list, get, preview / create, launch, pause, resume, cancel | | `automations.read` / `automations.manage` | [Automations](/docs/automations): list, get, runs / create, update, delete | | `billing.manage` | [Get billing](/docs/api/get-billing), [Pay invoice](/docs/api/pay-invoice) | | `conversations.manage` | [Update conversation](/docs/api/update-conversation), [Add note](/docs/api/add-conversation-note) | | `inbox.manage` | [Sign staff in to the inbox](/docs/api/inbox-sign-in), [List inbox members](/docs/api/list-inbox-members), [Remove inbox access](/docs/api/disable-inbox-member) | `webhooks.read` / `webhooks.manage` are reserved for endpoints that are coming; a token can hold them already. > [!NOTE] > Until 1 October 2026 the scopes were `businesses:read`, `businesses:write`, > `whatsapp:read`, `whatsapp:connect` and `whatsapp:send`. Tokens issued with them > were converted automatically (`whatsapp:read` became `connections.read`, > `templates.read` and `messages.read`); `GET /me` shows a token's current scopes. ## Check a token [`GET /me`](/docs/api/get-me) returns your product and the token's abilities. Call it when your app starts and fail loudly if an ability you need is missing. ## Issuing, rotating and revoking Operators manage tokens on the PaalChat server: ```bash php artisan products:token multi-skuul --name=production --ability=messages.send --ability=connections.read --ability=templates.read php artisan products:token multi-skuul --name=production --all --rotate # replace it php artisan products:token multi-skuul --list php artisan products:token multi-skuul --revoke=12 ``` Rotate immediately if a token may have leaked. ## Errors | HTTP | `error.code` | Meaning | |---|---|---| | 401 | `unauthenticated` | Missing, wrong, revoked or expired token | | 403 | `missing_ability` | The token lacks the ability for this endpoint | | 403 | `product_suspended` | Your product is suspended on PaalChat | | 403 | `product_token_required` | The credential is not a product token | --- # Businesses Register each of your customers once, by your own ID. > **In a nutshell:** A business is one customer of your product - a school, a shop. Create it with POST /businesses using your own external_id, and use that ID in every URL afterwards. ## Your ID, not ours A business is addressed by **your** `external_id` in every URL: ```text /api/v1/businesses/{external_id}/... ``` For Multi-SKUUL it is the tenant ID; for PaalPOS it could be the shop ID. PaalChat never needs to know your database schema. Two products may use the same `external_id` without clashing - a product only ever sees its own businesses. `external_id` rules: 1-191 characters from `A-Z a-z 0-9 . _ : -`. ## Create or update [`POST /businesses`](/docs/api/upsert-business) creates the business, or updates it when the `external_id` already exists. Call it when a customer signs up and whenever their name or contact details change. It is safe to repeat. ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{"external_id": "st-augustines", "name": "St. Augustine'\''s College", "email": "info@augustines.edu.gh"}' ``` ```php $business = Http::withToken(config('services.paalchat.token'))->acceptJson() ->post('https://whatsapp.paaltech.org/api/v1/businesses', [ 'external_id' => $school->tenant_id, 'name' => $school->name, 'email' => $school->email, ])->throw()->json('data'); ``` ```javascript const { data: business } = await (await fetch(`${BASE}/businesses`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ external_id: shop.id, name: shop.name, email: shop.email }), })).json(); ``` ```python business = requests.post(f"{BASE}/businesses", headers=HEADERS, json={"external_id": shop.id, "name": shop.name, "email": shop.email}).json()["data"] ``` `201` when created, `200` when updated: ```json { "data": { "external_id": "st-augustines", "name": "St. Augustine's College", "email": "info@augustines.edu.gh", "phone": null, "status": "active", "whatsapp": {"status": "not_connected", "wabas": 0}, "created_at": "2026-09-30T18:54:27+00:00" } } ``` ## Read - [`GET /businesses`](/docs/api/list-businesses) - your businesses, newest first, 50 per page. Follow `links.next`. - [`GET /businesses/{external_id}`](/docs/api/get-business) - one business. `404 not_found` if it is not yours. ## Status | Field | Values | Meaning | |---|---|---| | `status` | `active`, `suspended` | A PaalChat operator can suspend a business; it then cannot connect or send (`409 business_suspended`). | | `whatsapp.status` | `not_connected` or a [connection state](/docs/connect-whatsapp#connection-states) | `connected` if any of its WhatsApp accounts is connected, else `degraded` if any is; otherwise the latest account's state; `not_connected` if it has none. | ## Next [Connect the business's WhatsApp](/docs/connect-whatsapp). --- # Connect WhatsApp Send your customer to PaalChat's hosted Meta signup and know when they are connected. > **In a nutshell:** Ask for a one-time connect link, redirect the customer's admin to it, and wait for the whatsapp.connection callback. PaalChat runs Meta's Embedded Signup, verifies everything with Meta and keeps the credentials. ## How it works 1. **You ask for a link.** `POST /businesses/{external_id}/whatsapp/connect` returns a one-time URL. 2. **The admin opens it.** On PaalChat's page they log in to Meta in Meta's own window, choose or create the WhatsApp Business account and phone number, and approve access. 3. **PaalChat verifies with Meta.** It checks the grant, reads the account and its numbers, registers the number, subscribes webhooks and syncs templates. 4. **You are told.** A `whatsapp.connection` callback with `event: connected` arrives, and the admin can follow the link back to your app. Your product never sees the Meta login or the Meta token. ## 1. Create the link ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/connect \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{"return_url": "https://presec.skuuls.paaltech.org/settings/whatsapp"}' ``` ```php public function connect() { $link = Http::withToken(config('services.paalchat.token'))->acceptJson() ->post('https://whatsapp.paaltech.org/api/v1/businesses/'.tenant('id').'/whatsapp/connect', [ 'return_url' => route('settings.whatsapp'), ])->throw()->json('data'); return redirect()->away($link['url']); } ``` ```javascript app.post('/settings/whatsapp/connect', async (req, res) => { const r = await fetch(`${BASE}/businesses/${req.shop.id}/whatsapp/connect`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ return_url: 'https://pos.example.com/settings/whatsapp' }), }); const { data } = await r.json(); res.redirect(data.url); }); ``` ```python link = requests.post(f"{BASE}/businesses/{shop.id}/whatsapp/connect", headers=HEADERS, json={"return_url": "https://pos.example.com/settings/whatsapp"}).json()["data"] return redirect(link["url"]) ``` ```json {"data": {"url": "https://whatsapp.paaltech.org/connect/EsCRzWiltOSLzMBAvEyNHt4bdoWsdAYXwkPKiL2KT83aQk98NFn5coKqweclo4Ih", "expires_at": "2026-10-03T18:54:28+00:00"}} ``` > [!WARNING] > Treat the link as a secret: whoever holds it can connect a WhatsApp account to > this business. Send it only to the customer's administrator, and do not store it. ## Link rules - Valid for **72 hours** and until it is used successfully. If the admin closes Meta's window, they can try again with the same link. - Asking for a new link cancels older unused links for that business. - `return_url` must be `https://` and on your product's **registered return origins** - ask PaalTech to register them (for example `https://*.skuuls.paaltech.org` for every school subdomain). Any other URL gets `422 return_url_not_allowed`, so the connect page can never send your customers somewhere else. After success the page offers a link back to it with `whatsapp=connected` added to the query. - A suspended business gets `409 business_suspended`. ## 2. Wait for the result The `whatsapp.connection` callback may arrive before or after the admin returns. When they land on your `return_url`, read the state: ```http GET /api/v1/businesses/presec/whatsapp ``` ```json {"data": {"status": "connected", "wabas": [{"waba_id": "102290129340398", "status": "connected", "phone_numbers": [{"phone_number_id": "106540352242922", "display_phone_number": "+233 30 200 0000", "verified_name": "Presec Legon"}]}]}} ``` Show the number and verified name on your settings page. > [!NOTE] > The business pays Meta for conversations directly. Remind the admin to add a > payment method for the number in Meta's WhatsApp Manager. ## If setup fails If a step fails at Meta, the WhatsApp account goes back to `pending` (you get a `whatsapp.connection` callback with `event: setup_failed`) and `health.last_error` says why, in Meta's words. The same link still works, so the admin can simply try again. Reconnecting the same account updates it in place - history and messages are kept. ## Connection states | State | Meaning | Can send? | What to do | |---|---|---|---| | `pending` | Not set up yet, or setup failed (`health.last_error` says why) | No | Offer a new link | | `connecting` | Signup finished, setup at Meta in progress | No | Wait | | `connected` | Ready | Yes | - | | `degraded` | Working, with a warning - usually the access token expires within 7 days | Yes | Ask the admin to reconnect soon | | `maintenance` | Paused by PaalChat operators; sends get `409 connection_paused` | No | Retry later; incoming messages still arrive | | `suspended` | Meta disabled the account | No | The business resolves it with Meta; it comes back by itself | | `revoked` | The credential stopped working, or the customer removed PaalChat's access in Meta | No | Offer a new link | | `disconnected` | Disconnected on purpose, by you or an operator | No | Offer a new link if they want it back | ## Disconnect When a customer leaves, disconnect each of their WhatsApp accounts: ```http DELETE /api/v1/businesses/presec/whatsapp/wabas/102290129340398 ``` PaalChat deletes its Meta token and stops sending. The number stays the customer's at Meta. ## Moving an account to another business A WhatsApp account can move between two businesses on PaalChat (for example when a school's campuses merge). PaalTech requests the move; each business approves or rejects it through its product ([List account transfers](/docs/api/list-transfers), [Approve or reject a transfer](/docs/api/decide-transfer)); after both approve it moves 24 hours later, with its numbers and templates. Messages and conversations stay with the business they happened in. --- # Sending messages Templates, free-form text, the 24-hour window and references that make retries safe. > **In a nutshell:** POST a message with your own reference. Templates can be sent any time; free-form text only within 24 hours of the customer's last message. 202 means queued, 200 means that reference was already sent. ## Templates or text? | | Template | Text | |---|---|---| | When | Any time - notifications, reminders, receipts | Only within **24 hours** of the customer's last message to the business | | Content | An `APPROVED` template plus parameters | Up to 4096 characters | | Outside the window | Allowed | `422 outside_service_window` | The 24-hour window is Meta's customer service window. PaalChat checks it before Meta does, from the last message the customer sent to that business. ## The request [`POST /businesses/{external_id}/messages`](/docs/api/send-message) | Field | Required | Notes | |---|---|---| | `to` | yes | International digits, optional `+`: `+233241234567` | | `type` | yes | `text` or `template` | | `text.body` | for `text` | Max 4096 characters. `text.preview_url` shows a link preview | | `template.name`, `template.language` | for `template` | An `APPROVED` template of the business, e.g. `fees_reminder` / `en_US` | | `template.components` | no | Parameters in Meta's format - see [Templates](/docs/templates) | | `reference` | strongly recommended | Your own ID for the message - see below | | `phone_number_id` | only with several numbers | Which number to send from | ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses/presec/messages \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{"to": "233241234567", "type": "text", "reference": "inbox-msg-991", "text": {"body": "Yes, Kofi can collect his report on Friday."}}' ``` ```php $response = Http::withToken(config('services.paalchat.token'))->acceptJson() ->post("https://whatsapp.paaltech.org/api/v1/businesses/{$tenantId}/messages", [ 'to' => $contact->phone, 'type' => 'text', 'reference' => 'inbox-msg-'.$reply->id, 'text' => ['body' => $reply->body], ]); if ($response->json('error.code') === 'outside_service_window') { // Offer the agent a template instead. } ``` ```javascript const r = await fetch(`${BASE}/businesses/presec/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ to: '233241234567', type: 'text', reference: `inbox-msg-${reply.id}`, text: { body: 'Yes, Kofi can collect his report on Friday.' } }), }); const body = await r.json(); if (body.error?.code === 'outside_service_window') { /* offer a template */ } ``` ```python r = requests.post(f"{BASE}/businesses/presec/messages", headers=HEADERS, json={ "to": "233241234567", "type": "text", "reference": f"inbox-msg-{reply.id}", "text": {"body": "Yes, Kofi can collect his report on Friday."}}) if r.json().get("error", {}).get("code") == "outside_service_window": ... # offer a template ``` ## The response **`202 Accepted`** - a new message, queued: ```json { "data": { "id": 3, "reference": "inbox-msg-991", "wamid": null, "direction": "outgoing", "contact": "233241234567", "phone_number_id": "106540352242922", "type": "text", "template_name": null, "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 } } ``` **`200 OK`** - this `reference` was already submitted for this business. You get the original message with its current status, and **nothing is sent again**. ## Always send a reference The `reference` is your own ID for the message (for example your database row ID). The same reference for the same business is always the same message, however many times you submit it. That makes every retry safe - see [Idempotency](/docs/idempotency). ## What is checked before queueing | Check | Error | |---|---| | The business is active | `409 business_suspended` | | It has a connected number | `409 not_connected` | | Sending is not paused by PaalChat operators | `409 connection_paused` | | `phone_number_id` (if given) is connected for this business | `404 unknown_phone_number` | | One number, or `phone_number_id` given | `422 phone_number_required` | | Text: the customer messaged **this number** in the last 24 hours (the window is per number) | `422 outside_service_window` | | Template: exists for that language | `422 unknown_template` | | Template: is `APPROVED` | `422 template_not_approved` | A queued message can still fail at Meta - for example if the number is not on WhatsApp. That arrives as a [`message.status`](/docs/api/callbacks/message-status) callback with `status: failed` and Meta's error. ## Tracking delivery ```text queued ──▶ sent ──▶ delivered ──▶ read │ │ │ │ └──────────┴──▶ failed (never after read; failed is final) ├────────────────────────▶ failed └──▶ unknown ──▶ sent / delivered / read / failed (only if Meta reports it) ``` Statuses only move forward. If `delivered` arrives after `read`, keep `read`. **`unknown`** means PaalChat sent the message to Meta but Meta's answer was lost (a timeout or a Meta server error). The message may or may not have reached the customer, so PaalChat never sends it again by itself. If Meta did deliver it, a `sent`, `delivered` or `read` status still arrives and the message moves on. If it stays `unknown`, decide in your app: resending with a **new** reference can duplicate the message, so for anything important check with the customer first. Show ticks from [`message.status`](/docs/api/callbacks/message-status) callbacks; use [`GET .../messages/{id}`](/docs/api/get-message) only if you cannot receive callbacks. Every message costs the business money at Meta, including replies inside the 24-hour window from 1 October 2026. See [WhatsApp pricing](/docs/pricing). ## Several numbers If a business has more than one connected number, list them with [`GET .../whatsapp/phones`](/docs/api/list-phone-numbers), let the customer pick a default, and pass its `phone_number_id` on every send. --- # Templates Find approved templates and fill in their parameters. > **In a nutshell:** Create templates through the API (Meta reviews them) or in WhatsApp Manager; PaalChat mirrors them. List the APPROVED ones, count the {{n}} placeholders and send one parameter per placeholder. ## Where templates come from A template is a message format Meta has reviewed. Create it through the API ([Create template](/docs/api/create-template)) or in Meta's WhatsApp Manager - PaalChat syncs every template of the account and keeps its status current. A new template is `PENDING` until Meta decides; the decision arrives as a [`template.status`](/docs/api/callbacks/template-status) callback. Delete one language, or all of them, with [Delete template](/docs/api/delete-template). ## List them [`GET /businesses/{external_id}/whatsapp/templates?status=APPROVED`](/docs/api/list-templates) ```json { "data": [ { "name": "fees_reminder", "language": "en_US", "category": "UTILITY", "status": "APPROVED", "rejection_reason": null, "components": [ {"type": "BODY", "text": "Dear {{1}}, the fees balance for {{2}} is GHS {{3}}. Please pay before {{4}}.", "example": {"body_text": [["Mrs Mensah", "Kofi", "450.00", "30 October"]]}} ], "waba_id": "102290129340398", "last_synced_at": "2026-09-30T17:54:27+00:00" } ] } ``` Filters: `status`, `name`, `language`. 100 per page. Cache the list and refresh it when a [`template.status`](/docs/api/callbacks/template-status) callback arrives. ## Fill in the parameters `components` are exactly what Meta returns. For each component with `{{n}}` placeholders, send one parameter per placeholder, in order: | Template component | Send | |---|---| | `HEADER` with `{{1}}` (text) | `{"type": "header", "parameters": [{"type": "text", "text": "..."}]}` | | `BODY` with `{{1}}..{{n}}` | `{"type": "body", "parameters": [{"type": "text", "text": "..."}, ...]}` | | `BUTTONS` → URL button with `{{1}}` | `{"type": "button", "sub_type": "url", "index": "0", "parameters": [{"type": "text", "text": "..."}]}` | | No placeholders | Send no component for it | For `fees_reminder` above: ```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"}, {"type": "text", "text": "450.00"}, {"type": "text", "text": "30 October"}]}]} } ``` > [!TIP] > Count placeholders with a regular expression such as `/\{\{\d+\}\}/g` on each > component's `text`, and validate your parameters before sending. A mismatch fails > at Meta with error `132000`. ## Template status | Status | Can send? | |---|---| | `APPROVED` | Yes | | `PENDING`, `IN_APPEAL` | Not yet | | `REJECTED` (see `rejection_reason`), `PAUSED`, `DISABLED`, `FLAGGED`, `LIMIT_EXCEEDED`, `ARCHIVED`, `DELETED` | No - `422 template_not_approved` | When a template is rejected or paused, stop offering it. The customer edits it in WhatsApp Manager, and an `APPROVED` callback follows when Meta approves. ## Versions and history Every change to a template's content is kept as a version - [List template versions](/docs/api/list-template-versions) shows what each said and Meta's decision on it. A template deleted at Meta (through the API or WhatsApp Manager) is kept with status `REMOVED`; list it with `include_removed=1`. Nothing is ever deleted from the history. ## Checked before sending PaalChat checks a send against the template before Meta sees it: every body and header variable needs a value (named templates: `parameter_name`), a media header needs its image, video or document, and a URL button with a variable needs its value. Values may not contain new lines, tabs or more than four spaces in a row. Otherwise the send is refused with `422 invalid_template_parameters`, listing what is wrong. ## Starter templates The [template library](/docs/api/list-template-library) has ready-made templates for schools (fee reminder, results, PTA meeting, absence, admission offer) and shops (order confirmed, receipt, delivery update). [Create template from library](/docs/api/create-template-from-library) copies one with the business's name and submits it to Meta. --- # Receiving messages Build an inbox from message.received callbacks. > **In a nutshell:** Every WhatsApp message a customer sends arrives once as a message.received callback with Meta's message object. PaalChat does not keep the text, so store it. It also opens the 24-hour window for replies. ## The callback [`message.received`](/docs/api/callbacks/message-received) arrives once per WhatsApp message: ```json { "id": "7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b", "event": "message.received", "occurred_at": "2026-09-30T18:54:28+00:00", "business": {"external_id": "presec"}, "data": { "message_id": 57, "wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg", "from": "233241234567", "profile_name": "Ama Mensah", "phone_number_id": "106540352242922", "type": "text", "timestamp": "1759240000", "message": { "from": "233241234567", "id": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg", "timestamp": "1759240000", "type": "text", "text": {"body": "Good morning. Is there school on Friday?"} } } } ``` > [!IMPORTANT] > PaalChat keeps the conversation history, encrypted, for 365 days by default > ([details](/docs/faq)). Store what you need for longer yourself. ## Message types `data.message` is Meta's message object, unchanged: | `type` | Where the content is | |---|---| | `text` | `message.text.body` | | `button` | `message.button.text`, `message.button.payload` (quick reply on a template) | | `interactive` | `message.interactive.button_reply` or `list_reply` (`id`, `title`) | | `image`, `document`, `audio`, `video`, `sticker` | `message..id` (Meta's ID), `mime_type`, `caption`; PaalChat's copy is `media_id` - see [Media](/docs/media) | | `location` | `message.location.latitude`, `longitude`, `name`, `address` | | `reaction` | `message.reaction.message_id`, `emoji` | | `contacts` | `message.contacts[]` | | `unsupported` | nothing usable | `message.context.id` is present when the customer replied to a specific message - it is that message's `wamid`. ## Build an inbox 1. **Store it.** Save `data.message` under contact `data.from`, keyed by `data.wamid` so a repeated callback does not add a duplicate. Show `data.profile_name`. 2. **Note the window.** The contact can now receive free-form text for 24 hours - from the number they wrote to (`data.phone_number_id`). 3. **Reply.** Send `type: text` to `data.from` from that `phone_number_id`, with your own `reference`. 4. **Fall back.** On `422 outside_service_window`, offer the agent a template instead. ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { abort_unless(paalchatSignatureValid( $request->header('X-PaalChat-Signature', ''), $request->getContent(), config('services.paalchat.callback_secret'), ), 401); $event = $request->json()->all(); if ($event['event'] === 'message.received') { InboxMessage::firstOrCreate(['wamid' => $event['data']['wamid']], [ 'contact' => $event['data']['from'], 'name' => $event['data']['profile_name'], 'type' => $event['data']['type'], 'body' => $event['data']['message']['text']['body'] ?? null, 'payload' => $event['data']['message'], ]); } return response()->noContent(); } ``` ```javascript app.post('/paalchat/callback', express.raw({ type: 'application/json' }), async (req, res) => { if (!paalchatSignatureValid(req.get('X-PaalChat-Signature'), req.body.toString(), process.env.PAALCHAT_CALLBACK_SECRET)) { return res.sendStatus(401); } const event = JSON.parse(req.body); if (event.event === 'message.received') { await inbox.upsert({ wamid: event.data.wamid, contact: event.data.from, body: event.data.message.text?.body }); } res.sendStatus(204); }); ``` ```python @app.post("/paalchat/callback") def paalchat_callback(): if not paalchat_signature_valid(request.headers.get("X-PaalChat-Signature", ""), request.get_data(), CALLBACK_SECRET): abort(401) event = request.get_json() if event["event"] == "message.received": inbox.upsert(wamid=event["data"]["wamid"], contact=event["data"]["from"], body=event["data"]["message"].get("text", {}).get("body")) return "", 204 ``` The signature helpers are on the [Callbacks](/docs/callbacks) page. --- # Media Receive customers' photos, documents and voice notes, and send files - stored by PaalChat, fetched through short-lived links. > **In a nutshell:** PaalChat downloads every incoming attachment from Meta and stores it (message.received has its media_id; media.updated follows with a 15-minute link). To send a file, upload it, then send an image, video, audio or document message with its media.id - inside the 24-hour window. Media is enabled per business. Media is enabled per business by PaalTech. Without it, `message.received` still carries Meta's own media object, but PaalChat does not fetch the file, and uploads and media sends are refused with `feature_disabled`. ## Receiving 1. A customer sends a photo, document, voice note, video or sticker. [`message.received`](/docs/api/callbacks/message-received) arrives with `type` (`image`, `document`...) and `media_id` - PaalChat's copy, `pending` for now. 2. PaalChat downloads the file from Meta (Meta's links last only minutes), checks its size and SHA-256 and stores it privately. 3. [`media.updated`](/docs/api/callbacks/media-updated) arrives with `status: stored` and a signed `url`. Fetch the bytes from it - no token needed - and keep them if you need them. If Meta would not give the file, `status` is `failed` with an `error`. The `url` expires after 15 minutes. For a fresh one, call [Get media](/docs/api/get-media). Never store or share the link itself; it is the credential. ```bash curl -L -o photo.jpg "$MEDIA_URL" ``` ## Sending 1. [Upload media](/docs/api/upload-media) - `multipart/form-data` with a `file` field: ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses/presec/media \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -F file=@receipt.pdf ``` 2. [Send message](/docs/api/send-message) with the matching type and the `media.id`: ```json {"to": "233241234567", "type": "document", "reference": "receipt-5521", "media": {"id": 12, "caption": "Your receipt", "filename": "receipt.pdf"}} ``` Like free-form text, media goes only inside the 24-hour customer service window; outside it, send a template. An uploaded file can be sent as often as you like. | Type | Files | Up to | caption | filename | |---|---|---|---|---| | `image` | JPEG, PNG | 5 MB | yes | - | | `video` | MP4, 3GP | 16 MB | yes | - | | `audio` | AAC, AMR, MP3, M4A, OGG | 16 MB | - | - | | `document` | PDF, Word, Excel, PowerPoint, text | 100 MB | yes | yes | The type is detected from the file itself, not its name. A file that does not suit the message type is refused with `media_type_mismatch`; one WhatsApp cannot send at all with `unsupported_media`. ## How long files are kept Files are deleted with the conversation history, after 365 days by default (see the [FAQ](/docs/faq)). The media record stays, with `status: deleted`. --- # Contacts and conversations The people a business talks to and its conversations with them - kept by PaalChat, read and worked through the API. > **In a nutshell:** PaalChat keeps a contact for everyone a business messages or hears from, and one conversation per business number and contact. Read them, add your own ids, fields and tags, work conversations (status, priority, tags, read, notes), and listen for contact.* and conversation.* events. ## What PaalChat keeps - **Contacts** - created automatically by the first message in or out (named from the person's WhatsApp profile), or by you. Each has its **identities** (how it is reached: WhatsApp number today), your own `external_id`, the business's **custom fields** and **tags**. - **Conversations** - one per business number and contact. Every message, in and out, belongs to one (`conversation_id` on messages and their callbacks). - **Message content** - what was said, encrypted, for the conversation history ([how long](/docs/faq)). ## How messages join conversations ```text customer writes ──▶ open ──(you resolve)──▶ resolved ──(customer writes)──▶ open you write first ──▶ pending ──(customer replies)──▶ open closed ──(next message)──▶ a new conversation ``` - An incoming message joins the current conversation on that number, **reopening** it if it was pending, resolved or snoozed, and adds to `unread_count`. - A send joins the current conversation, or starts a **pending** one - so a broadcast does not fill your inbox with open conversations. - `window_open` says whether free-form text is allowed (the contact wrote in the last 24 hours to that number). ## Link contacts to your records ```bash curl -X POST https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts \ -H "Authorization: Bearer $PAALCHAT_TOKEN" -H "Content-Type: application/json" \ -d '{"external_id": "parent-77", "phone": "+233241234567", "name": "Ama Mensah", "tags": ["parent"]}' ``` The contact is matched by `external_id`, else by `phone`, else created. Define the business's own fields first ([Define a contact field](/docs/api/define-contact-field)), then set them with `custom_fields`. ## Work a conversation - [Update conversation](/docs/api/update-conversation): `status`, `priority`, `tags`, and `read: true` once your staff have seen it. - [Add note](/docs/api/add-conversation-note): an internal note - never sent to the contact. - [List conversation messages](/docs/api/list-conversation-messages): the history, with each message's `content`. ## Events | Event | When | |---|---| | [`contact.created`](/docs/api/callbacks/contact-created) | A contact's first message in or out, or you created it | | [`contact.updated`](/docs/api/callbacks/contact-updated) | Its details, fields or tags changed | | [`conversation.created`](/docs/api/callbacks/conversation-created) | A conversation started | | [`conversation.updated`](/docs/api/callbacks/conversation-updated) | Status, priority, tags or assignee changed, or the contact reopened it | New messages alone arrive as `message.received` / `message.status`, not as `conversation.updated`. --- # SMS, email and failover Send by SMS and email next to WhatsApp, and let a notification fall back from one channel to the next. > **In a nutshell:** Set up SMS (Arkesel by default, or Hubtel, under a sender ID - optionally with the business's own Arkesel key) or email (Resend, from an address on a verified domain) per business, then send with channel sms or email. A notification tries its channels in order - WhatsApp, then SMS, then email - and moves on when a channel cannot be used or its message fails. ## SMS and email PaalTech's SMS and Resend accounts send for every business, so there is nothing to connect: - **SMS** - [Set up SMS or email](/docs/api/enable-channel) with a `sender_id` and, if you like, a `provider`: `arkesel` (the default) or `hubtel`. Each business uses one; call again to switch. A business's own sender ID must first be approved by that provider. Arkesel reports delivery back to PaalChat; Hubtel's delivery is checked for two days after sending. - **SMS with the business's own Arkesel account** - pass its key as `arkesel.api_key`. PaalChat checks it with Arkesel (`invalid_provider_credentials` if Arkesel refuses it), stores it encrypted and never returns it - the connection shows `own_key: true`. The business then pays Arkesel itself. Send `"arkesel": {"api_key": null}` to go back to PaalTech's account. - **Email** - set it up with a `from` address on a domain PaalChat sends for, and optionally `from_name` and `reply_to`. Delivery, bounces and spam complaints come back from Resend; bounced and complaining addresses go on the business's suppression list. ```json {"provider": "arkesel", "sender_id": "PRESEC", "arkesel": {"api_key": "the-school's-arkesel-key"}} ``` Then send with [Send message](/docs/api/send-message) and `channel`: ```json {"channel": "sms", "to": "+233241234567", "type": "text", "reference": "closing-sms-1", "text": {"body": "Presec: school closes at 1pm today."}} ``` ```json {"channel": "email", "to": "parent@example.com", "type": "text", "subject": "Report cards", "text": {"body": "Report cards are ready at the school office."}} ``` SMS and email are always business-initiated, so the suppression list, consent, preferences and quiet hours apply, like WhatsApp templates. `message.status` callbacks carry `channel`. ## Notifications with failover [Send notification](/docs/api/send-notification) sends one notification on the first channel that takes it: ```json {"contact_id": 41, "reference": "closing-2026-10-02", "channels": ["whatsapp", "sms", "email"], "whatsapp": {"template": {"name": "school_closing", "language": "en_US"}}, "sms": {"text": "Presec: school closes at 1pm today."}, "email": {"subject": "School closes early", "text": "School closes at 1pm today."}} ``` A channel is passed over when there is no content or address for it, the business has not set it up, its provider is unavailable, or the send is refused. If the message then fails - for example the number is not on WhatsApp - the next channel is tried. A message whose fate is unknown (the provider's answer was lost) never moves on, because it may already have arrived. [`notification.updated`](/docs/api/callbacks/notification-updated) says how it ended, and [Get notification](/docs/api/get-notification) lists every attempt. ## Provider health [Get provider health](/docs/api/get-providers) says whether Meta, Arkesel, Hubtel and Resend work right now; the [status page](/status) shows the same. --- # Segments, scheduling and campaigns Send one template to many contacts - at a time, on a schedule, or every week - safely and at a steady pace. > **In a nutshell:** Save an audience as a segment, create a campaign with an APPROVED template and where each variable comes from, preview it (audience, skips, estimated Meta cost), then launch it now, at send_at, or repeating. Every person goes through suppression, consent, preferences, limits and quiet hours; results come back with clear definitions. ## Segments A segment is an audience saved as conditions over the business's contacts: ```json {"name": "SHS 2 owing fees", "conditions": {"all": [ {"field": "custom.class", "op": "eq", "value": "SHS 2"}, {"field": "custom.fee_balance", "op": "gt", "value": 0}, {"any": [{"field": "tag", "op": "has", "value": "boarders"}, {"field": "consent.notifications", "op": "eq", "value": "granted"}]} ]}} ``` | Field | Operators | |---|---| | `name`, `phone`, `email`, `locale`, `timezone`, `external_id` | `eq`, `neq`, `contains`, `starts_with`, `in`, `exists`, `not_exists` | | `created_at` | `eq`, `gt`, `gte`, `lt`, `lte`, `within_days`, `older_than_days` | | `custom.` | by the field's type - numbers and dates also `gt`, `gte`, `lt`, `lte` | | `tag` | `has`, `not_has` | | `consent.` | `eq` `granted`, `revoked` or `none` | | `last_incoming_at` | `within_days`, `older_than_days` - when they last wrote | Groups nest up to three levels and hold up to 30 conditions. Erased and inactive contacts are never included. [Preview segment](/docs/api/preview-segment) shows the count and a sample. ## Scheduling one message Add `send_at` to a template send ([Send message](/docs/api/send-message)): it is accepted (`202`, `scheduled_for` set) and sent at that time - quiet hours still apply then. Until it goes, [Cancel scheduled message](/docs/api/cancel-message) withdraws it (`cancelled`). ## Campaigns 1. [Create campaign](/docs/api/create-campaign) - an APPROVED template, a segment (or `contact_ids`) and where each variable comes from: ```json {"name": "October fee reminders", "template": {"name": "school_fee_reminder", "language": "en_US"}, "parameters": {"parent": "field:name", "amount": "custom:fee_balance", "student": "custom:ward_name", "due_date": "text:30 October"}, "segment_id": 4, "topic": "fees"} ``` Sources: `field:name` (also `phone`, `email`, `external_id`), `custom:`, `text:`. 2. [Preview campaign](/docs/api/preview-campaign) - who it reaches, who is skipped and why, the **estimated Meta cost** ("Meta bills your WhatsApp Business Account directly. PaalChat cost: included in your plan.") and the number's Meta messaging tier. 3. [Launch campaign](/docs/api/launch-campaign) - now, at `send_at`, or every `week` or `month` (`repeat`). Each repeat is a run with the audience as it is then. Campaigns send at `per_minute` messages a minute (default 60). Every person goes through the same checks as a single send; anyone who cannot be sent is **skipped** with a reason (`no_whatsapp`, `missing_parameter`, `recipient_suppressed`, `recipient_opted_out`, `recipient_preference_off`) - never sent a broken template. Pause, resume or cancel with [Pause, resume or cancel campaign](/docs/api/change-campaign). Status changes arrive as [`campaign.updated`](/docs/api/callbacks/campaign-updated). ## Results [Get campaign](/docs/api/get-campaign) returns `stats`: | Figure | Means | |---|---| | `audience` | People fixed when the run started | | `queued` / `skipped` / `cancelled` | What PaalChat did with each (`skip_reasons` per reason) | | `sent`, `delivered`, `read`, `failed` | The messages' statuses now - delivered includes read | | `replied` | People who wrote back on the same conversation within 72 hours of the send | | `rates` | delivery, read and reply are shares of sent; failure is failed of sent + failed | | `estimated_meta_cost` | From Meta's pricing reports - an estimate; Meta bills the business | Before a big campaign, keep Meta's messaging tier in mind: it limits how many people a number may start conversations with in 24 hours. --- # Automations When something happens to a contact, do something - reply to keywords, chase unpaid fees, follow up - with waits in between. > **In a nutshell:** An automation is a trigger (message received, keyword, contact created, conversation status, a date field), optional conditions (the segment tree) and steps (send a template or text, tag, set status, note, notify your product, wait). Sends go through all the usual checks; conditions are re-checked after every wait. ## Triggers | Trigger | When | `trigger_config` | |---|---|---| | `message_received` | A contact writes in | - | | `keyword` | A contact's message is exactly one of the keywords (any case) | `{"keywords": ["FEES", "TERM DATES"]}` | | `contact_created` | A new contact appears (first message, or created by you) | - | | `conversation_status` | A conversation becomes a status | `{"status": "resolved"}` | | `date_reached` | A contact's date field is today, plus an offset | `{"field": "fee_due_date", "offset_days": -3}` | `conditions` is the [segment condition tree](/docs/campaigns): the run only starts if the contact matches, and stops after a wait if they no longer do - so a fee reminder chain ends as soon as `fee_balance` is 0. ## Steps | Step | Does | |---|---| | `send_template` | Sends an APPROVED template; `parameters` map variables like campaigns (`field:name`, `custom:`, `text:...`) | | `send_text` | Sends text - only inside the 24-hour window, otherwise skipped | | `add_tag` / `remove_tag` | Tags the contact | | `set_status` | Sets the conversation's status (open, pending, resolved, closed) | | `add_note` | Adds an internal note to the conversation | | `notify` | Sends you an [`automation.action`](/docs/api/callbacks/automation-action) callback with the step's `data` | | `wait` | Waits `minutes`, `hours` or `days`, then re-checks the conditions | Every send goes through suppression, consent, preferences, limits and quiet hours; a step that cannot send is skipped with the reason and the run carries on. [List automation runs](/docs/api/list-automation-runs) shows what each step did. ## Safety - Nothing an automation does starts automations - no loops. - Each trigger runs an automation once for a contact, and at most three times a day. - Pausing an automation stops its waiting runs. --- # Inbox Let a business's staff answer WhatsApp in PaalChat's inbox, signed in from your product - no second password. > **In a nutshell:** Your product signs a logged-in staff member in with POST .../inbox/sign-in and redirects them to the one-time url it returns (5 minutes, one use). PaalChat shows that business's conversations only; roles admin, agent and viewer decide what they may do. Remove access with DELETE .../inbox/members/{id} when staff leave. PaalChat has its own inbox where a business's staff read and answer WhatsApp: assign conversations, reply inside the 24-hour window, send approved templates outside it, add internal notes and tags, and change status and priority. Your product does not need to build one. The inbox is enabled per business by PaalTech. The inbox works on phones and desktops, and follows each person's light or dark setting (or their device's), switchable in its header. ## Signing staff in PaalChat never holds staff passwords: your product, where the staff member is already signed in, vouches for them. 1. When the staff member clicks "WhatsApp inbox" in your product, call [Sign staff in to the inbox](/docs/api/inbox-sign-in) with who they are and their role: ```json {"staff": {"external_id": "teacher-12", "name": "Kwame Owusu", "email": "owusu@presec.edu.gh", "role": "agent"}} ``` 2. Redirect their browser to `data.url`. It works **once**, within **5 minutes** - request a new one every time, from your server, never ahead of time. 3. They land in the inbox of that business only. Add `conversation_id` to open a conversation directly (for example from a notification). The name and role you send replace the stored ones each time, so a promotion takes effect on the next sign-in. | Role | Can | |---|---| | `viewer` | Read conversations, messages, notes and tags | | `agent` | Also reply, send templates, add notes, change status, priority and tags, take or release a conversation | | `admin` | Also assign conversations to anyone | ## When staff leave Call [Remove inbox access](/docs/api/disable-inbox-member) with their `external_id`. An open inbox signs out on its next click, and their conversations become unassigned. Signing them in again restores access. [List inbox members](/docs/api/list-inbox-members) shows who has access. ## Assigning from your product [Update conversation](/docs/api/update-conversation) takes `assignee`: an inbox member's `external_id` (sign them in once first), or `null`. Conversations carry their `assignee`, and changes arrive as `conversation.updated` with `change: assigned` or `unassigned`. ## What PaalTech sees PaalTech operators see conversation counts and statuses for support - never message text. Message content is for the business's own staff, in the inbox or through your product. --- # Consent, opt-outs and quiet hours Who may be messaged first, and when - consent, STOP replies, preferences, the suppression list and quiet hours. > **In a nutshell:** Before a business-initiated template goes out, PaalChat checks the suppression list, the contact's consent for its category, their preferences, the business's limits and its quiet hours. A STOP reply opts a contact out of marketing and notifications. Replies inside the 24-hour window are never blocked. WhatsApp expects businesses to message people only when they want it. PaalChat applies the rules for you on every **business-initiated** message (templates), in this order: 1. **Suppression list** - numbers the business must not message first ([Suppress a number](/docs/api/add-suppression)). Refused: `recipient_suppressed`. 2. **Consent** for the message's category - `marketing`, `notifications` or `transactional`. A revoked category is refused: `recipient_opted_out`. 3. **Preferences** - WhatsApp switched off, or the message's `topic` turned off. Refused: `recipient_preference_off`. 4. **Limits** - the business's [daily cap](/docs/rate-limits). 5. **Quiet hours** - outside them the message goes now; inside, it is accepted and held until they end (`scheduled_for`). Replies inside the customer's 24-hour window skip all of this: the customer just wrote. ## Categories Each send has a category. Pass `category` on [Send message](/docs/api/send-message), or let PaalChat take it from the template: `MARKETING` → `marketing`, `AUTHENTICATION` → `transactional`, anything else → `notifications`. ## Opt-outs and opt-ins - A contact who replies **STOP** (or UNSUBSCRIBE, OPT OUT, CANCEL...) is opted out of `marketing` and `notifications`. **START** opts them back in. Transactional messages - codes and receipts - are not stopped by a keyword. - Record opt-ins and opt-outs you collect yourself with [Record consent](/docs/api/set-consent). - Every change is kept in the contact's consent history and sent to you as [`contact.consent`](/docs/api/callbacks/contact-consent). - PaalTech can require a recorded opt-in before any marketing template; ask if your business needs it. ## Preferences [Set notification preferences](/docs/api/set-preferences) stores what the contact chose in your product: WhatsApp on or off, and topics - `fees`, `results`, `attendance`, `pta`, `marketing`, `system`. Send `topic` with a template so PaalChat can respect it. ## Quiet hours and time zones Set a business's `timezone` and `quiet_hours` (for example `22:00`-`06:00`) with [Create or update business](/docs/api/upsert-business). During quiet hours, non-urgent templates are accepted (`202`) and held until the hours end, in the **contact's** time zone when it is known, else the business's. `transactional` messages are never held. To send something urgent anyway, add `urgent: true` - this needs a key with the `messages.urgent` ability. --- # Retention, export and erasure How long PaalChat keeps a business's data, and how to export it or erase a person. > **In a nutshell:** Message content, notes and media are kept 365 days by default - set a business's own retention_days on Create or update business. Export a business's data with POST .../exports. Erase a person with DELETE .../contacts/{id}. A business that leaves PaalChat is erased by PaalTech on request. ## Retention PaalChat keeps message content, conversation notes and media files for **365 days** by default, then empties them; the message rows (status, times, pricing) stay. A business can keep them for less - or longer - with `retention.message_content_days` (7 to 3,650) on [Create or update business](/docs/api/upsert-business). Webhook payloads and callbacks are kept only days, whatever the policy (see the [FAQ](/docs/faq)). ## Export [Export business data](/docs/api/create-export) builds a ZIP of JSON Lines files - `contacts.jsonl` (with consent and preferences), `conversations.jsonl` (with notes), `messages.jsonl` (with content), `media.jsonl` and `business.json`. Poll [Get export](/docs/api/get-export) until it is `ready`, then download `url` within 15 minutes. The file is deleted after 24 hours. Needs the `data.manage` ability. ## Erasing a person [Erase contact](/docs/api/erase-contact) removes everything that identifies them - name, number, email, your `external_id`, custom fields, tags, preferences, the content of their messages, their media and the notes on their conversations. Records that must remain (message statuses, conversations, the consent history) point at a pseudonym such as `contact_8f2a91c0`. It cannot be undone. A number on the suppression list stays there, so they are not messaged again. ## Erasing a business When a business leaves and asks for its data to be deleted, PaalTech erases it: contacts, conversations, messages, media, inbox members and its WhatsApp connection (the WhatsApp account becomes free to connect elsewhere). PaalTech's audit records keep only a pseudonym. Export first - erasure cannot be undone. --- # Errors and status codes One error shape for every endpoint, and what to do about each code. > **In a nutshell:** Every error is {"error": {"code", "message"}}. Branch on error.code - it is stable - and show error.message to people. Validation errors also list the failing fields. ## The error envelope ```json {"error": {"code": "outside_service_window", "message": "The customer has not messaged in the last 24 hours. Send an approved template instead."}} ``` Validation errors add the failing fields, keyed in dot notation: ```json { "error": {"code": "validation_failed", "message": "The to field must be an international phone number (digits, optional leading +). (and 2 more errors)"}, "errors": { "to": ["The to field must be an international phone number (digits, optional leading +)."], "text": ["The text field is required when type is text."], "text.body": ["The text.body field is required when type is text."] } } ``` > [!NOTE] > Messages may be reworded; codes will not change within `v1`. Never parse the message. ## Request IDs Every response has an `X-Request-ID` header, and error bodies repeat it as `error.request_id`. Send your own `X-Request-ID` (8-128 characters: letters, digits, `.` `_` `:` `-`) to tie PaalChat's logs to yours; otherwise PaalChat makes one. Log it with failed calls and quote it when you contact PaalTech support - it identifies the request, and the send it queued, in PaalChat's logs. ## Codes | HTTP | `error.code` | Meaning | Retry? | |---|---|---|---| | 401 | `unauthenticated` | Missing, wrong, revoked or expired token | No - fix the token | | 403 | `missing_ability` | The token lacks the ability for this endpoint | No | | 403 | `product_suspended` | Your product is suspended on PaalChat | No - contact PaalTech | | 403 | `product_token_required` | Not a product token | No | | 403 | `forbidden` | Not allowed for another reason | No | | 404 | `not_found` | Business, WhatsApp account or message not found - or not yours | No | | 404 | `unknown_phone_number` | `phone_number_id` is not connected for this business | No | | 405 | `method_not_allowed` | Wrong HTTP method | No | | 403 | `sandbox_only` | A simulation called with a live key | With a test key | | 409 | `sandbox_not_connected` | The sandbox business has no connected (simulated) WhatsApp yet | After connecting it | | 409 | `sandbox_not_sent` | Only sent outgoing messages get simulated statuses | No | | 422 | `invalid_idempotency_key` | The Idempotency-Key header is not 1-255 printable characters | With a valid key | | 409 | `idempotency_key_reused` | This Idempotency-Key was used for a different request | With a new key | | 409 | `idempotency_in_progress` | The first request with this key is still running | Yes, shortly, same key | | 429 | `business_limit_reached` | The business reached its daily message limit | Tomorrow | | 422 | `recipient_suppressed` | The number is on the business's suppression list | After removing it | | 422 | `recipient_opted_out` | The contact opted out of this category (or marketing needs an opt-in) | After they opt in | | 422 | `recipient_preference_off` | The contact turned WhatsApp or this topic off | No | | 403 | `urgent_not_allowed` | `urgent` needs the `messages.urgent` ability | With such a key, or without urgent | | 422 | `invalid_template_parameters` | The template's variables, header media or button value are missing, extra or invalid (the message lists them) | With the right parameters | | 409 | `not_cancellable` | The scheduled message is already being sent | No | | 409 | `campaign_not_allowed` | The campaign cannot do that in its state, or its template is gone | After fixing it | | 409 | `transfer_not_pending` | The account transfer is no longer waiting for an answer | No | | 409 | `channel_not_enabled` | The business has not set up SMS or email | After Set up SMS or email | | 403 | `account_not_verified` | A self-serve account sent a template before verifying its phone number in the dashboard | After verifying | | 429 | `sending_limit_reached` | A new self-serve account reached its daily limit of business-initiated messages (it rises as the account builds a good record) | Tomorrow | | 422 | `channel_not_allowed` | A sender ID, from address or provider PaalChat cannot use | With an allowed one | | 422 | `invalid_provider_credentials` | Arkesel does not accept the business's own API key | With a working key | | 503 | `provider_unavailable` | Arkesel could not be reached to check the business's own key | Shortly | | 402 | `plan_limit_reached` | The business's PaalChat plan allows no more (members, contacts) | After upgrading the plan | | 402 | `plan_feature_missing` | The plan does not include this (inbox, campaigns, automations) | After upgrading the plan | | 503 | `payment_unavailable` | Online payment is not set up, or Paystack did not answer | Later | | 409 | `business_suspended` | The business is suspended on PaalChat | No | | 409 | `not_connected` | No connected WhatsApp number | After reconnecting | | 409 | `connection_paused` | PaalChat operators paused sending for this WhatsApp account | Later, when resumed | | 413 | `payload_too_large` | Request body too large | No - shorten it | | 422 | `validation_failed` | Fields invalid - see `errors` | No - fix the request | | 403 | `feature_disabled` | Media is not enabled for this business | No - ask PaalTech to enable it | | 422 | `unknown_member` | No active inbox member with that external_id | After signing them in to the inbox | | 404 | `unknown_media` | No such media for this business | After uploading the file | | 409 | `media_not_ready` | The media is still being fetched, failed or was deleted | When `media.updated` says stored | | 422 | `media_type_mismatch` | The file does not suit the message type (e.g. a PDF as image) | With the right type | | 422 | `unsupported_media` | WhatsApp cannot send this kind of file | With a supported file | | 422 | `media_too_large` | Over WhatsApp's size limit for the type | With a smaller file | | 422 | `outside_service_window` | Free-form text outside 24 hours | Send a template | | 422 | `unknown_template` | No such template in that language | No | | 422 | `template_not_approved` | The template is not `APPROVED` | When approved | | 422 | `phone_number_required` | Several numbers - pass `phone_number_id` | With the field | | 422 | `template_rejected` | Meta refused the template (the message gives Meta's reason) | After fixing the template | | 422 | `waba_required` | Several WhatsApp accounts - pass `waba_id` | With the field | | 503 | `meta_unavailable` | Meta did not answer normally | Yes, later | | 422 | `return_url_not_allowed` | The connect link's `return_url` origin is not registered for your product | After PaalTech registers it | | 429 | `rate_limited` | Over 300 requests per minute | After `Retry-After` seconds | | 500 | `server_error` | A PaalChat problem | Yes, with backoff | ## Delivery failures A message accepted with `202` can still fail at Meta. That is not an HTTP error: it arrives as a [`message.status`](/docs/api/callbacks/message-status) callback with `status: failed` and `error: {code, message}`. Common codes: | `error.code` | Meaning | |---|---| | `131026` | Undeliverable - the number is not on WhatsApp or uses an old app | | `131047` | More than 24 hours since the customer's last message | | `131049` | Meta limited marketing messages to this user | | `131050` | The user stopped marketing messages | | `132000` | Template parameter count does not match | | `132001` | Template missing or not approved at Meta | | `130429`, `131056` | Meta rate limits | | `connection_unavailable` | The WhatsApp account was disconnected before the message was sent | | `no_message_id` | Meta accepted the message but returned no ID | Other numeric codes are Meta's [error codes](https://developers.facebook.com/documentation/business-messaging/whatsapp/support/error-codes). ## Handling strategy - **4xx except 429**: do not retry; fix the cause or show the message. - **429 and 5xx**: retry with backoff - see [Safe retries](/docs/safe-retries). - **Network errors on a send**: retry with the **same** `reference`. --- # Idempotency and references Your IDs make every call safe to repeat. > **In a nutshell:** Use your own IDs everywhere - external_id for businesses, reference for messages, the delivery ID for callbacks. Repeating a request with the same ID never creates a second business or sends a second message. ## Three IDs, three guarantees | ID | Where | Guarantee | |---|---|---| | `external_id` | [`POST /businesses`](/docs/api/upsert-business) | Same ID = same business. Repeating updates it. | | `reference` | [`POST .../messages`](/docs/api/send-message) | Same reference for the same business = same message. Repeating returns the original (`200`) and sends nothing. | | `X-PaalChat-Delivery` | [Callbacks](/docs/callbacks) | Same ID = same event. Process it once. | | `Idempotency-Key` header | Any write (`POST`, `PUT`, `PATCH`, `DELETE`) | Same key + same request within 24 hours = the first response again, nothing done twice. | ## The Idempotency-Key header Any write accepts an `Idempotency-Key` header - your own unique ID for that request (1-255 printable characters). A retry with the same key and the same body gets the first response back, with `Idempotent-Replayed: true`, and nothing happens twice. - The same key with a **different** request is refused: `409 idempotency_key_reused`. - A retry while the first request is still running gets `409 idempotency_in_progress` - wait a moment and retry with the same key. - Keys are kept for 24 hours, per product and per environment (live and test). - Server errors (`5xx`) and `429` are not kept, so retrying with the same key works. - For messages, `reference` does the same job without a time limit - use both if you like. ## References for messages Use your own database ID for the message, for example `notification-4411` or `m8812`. Rules: 1-191 printable characters, no spaces, unique per business. ```text First call POST .../messages reference=notification-4411 → 202 Accepted (queued) Retry POST .../messages reference=notification-4411 → 200 OK (the same message, not sent again) ``` > [!WARNING] > Never generate a new reference for a retry. A new reference is a new message. Without a reference, every call sends a new message - a retry after a timeout may send it twice. Always send one. ## References in callbacks [`message.status`](/docs/api/callbacks/message-status) callbacks carry your `reference`, so you can update your own row without storing PaalChat's IDs. The platform's `message_id` and Meta's `wamid` are included too. ## Callbacks can repeat A callback can arrive more than once (retries, resends) and out of order. Store `X-PaalChat-Delivery` and skip deliveries you have processed. For messages, also key on `wamid` and apply statuses only forward. --- # Webhooks and callbacks Receive events safely - verify signatures, acknowledge fast, handle retries. > **In a nutshell:** PaalChat POSTs signed JSON events to your callback URL. Verify the X-PaalChat-Signature on the raw body, answer 2xx within 10 seconds, de-duplicate on X-PaalChat-Delivery, and do slow work in a queue. ## Set up A PaalChat operator registers your callback URL and generates the signing secret: ```bash php artisan products:callback multi-skuul --url=https://skuuls.paaltech.org/api/paalchat/callback ``` The secret (`pcs_...`) is printed once - keep it in your secret store. Rotate it with `--rotate-secret`. ### Several endpoints A product can have more than one webhook endpoint - for example one service for incoming messages and another for delivery receipts. Each endpoint has its **own secret**, and can be limited to some events (`message.received`, `message.status`, `template.status`, `whatsapp.connection`), some businesses (by `external_id`) or some channels. Each matching endpoint gets its own delivery, with its own `X-PaalChat-Delivery` id and its own retries. ```bash php artisan products:webhook multi-skuul add --url=https://receipts.example.com/hook --event=message.status php artisan products:webhook multi-skuul # list ``` Ask a PaalChat operator to add, filter, pause or remove endpoints; self-service comes with the developer portal. ## Design a reliable endpoint - Use HTTPS, accept only `POST`. - Verify the signature **before** parsing JSON. - Record the event, then answer `2xx` quickly. Do slow work afterwards, in a queue. - Make processing idempotent: the same event may be delivered more than once. ## The request ```http POST /api/paalchat/callback Content-Type: application/json X-PaalChat-Event: message.status X-PaalChat-Delivery: 7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b X-PaalChat-Signature: t=1759258468,v1=5d41402abc4b2a76b9719d911017c592e0d5ad0d4e6a1b4c9e3f2a1b0c9d8e7f ``` ```json { "id": "7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b", "event": "message.status", "occurred_at": "2026-09-30T18:54:28+00:00", "business": {"external_id": "presec"}, "data": {} } ``` `id` equals `X-PaalChat-Delivery`. ## Verify the signature `v1` is the hex HMAC-SHA256 of `"."` with your secret. Reject the request if it does not match (constant-time compare) or if `t` is more than 300 seconds from now. ```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]); } ``` ```javascript const crypto = require('crypto'); function paalchatSignatureValid(header, rawBody, secret) { const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || ''); if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; const expected = crypto.createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2])); } ``` ```python import hmac, hashlib, re, time def paalchat_signature_valid(header: str, raw_body: bytes, secret: str) -> bool: m = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header or "") if not m or abs(time.time() - int(m[1])) > 300: return False expected = hmac.new(secret.encode(), m[1].encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, m[2]) ``` > [!WARNING] > Verify against the exact bytes received. Re-encoding parsed JSON changes spacing > and breaks the signature. ## Retries - Any `2xx` within 10 seconds acknowledges the delivery. - Anything else, a timeout or a redirect is a failure. PaalChat retries after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h (7 attempts, about 21 hours), then marks the delivery failed; an operator can resend it. - Redirects are not followed - register the final URL. ## Events | Event | When | Reference | |---|---|---| | `message.received` | A customer sent the business a message | [message.received](/docs/api/callbacks/message-received) | | `message.status` | An outgoing message was sent, delivered, read or failed, or its outcome became unknown | [message.status](/docs/api/callbacks/message-status) | | `template.status` | Meta approved, rejected or paused a template | [template.status](/docs/api/callbacks/template-status) | | `whatsapp.connection` | A WhatsApp account connected, disconnected, broke or its token is expiring | [whatsapp.connection](/docs/api/callbacks/whatsapp-connection) | ## Verify the final state Callbacks can arrive out of order. Apply message statuses only forward, and for anything important (a connection change, a failed payment receipt), read the current state from the API before acting. --- # Safe retries Retry the right failures, the right way. > **In a nutshell:** Retry 429 and 5xx with exponential backoff, and network failures on sends with the same reference. Never retry other 4xx errors - fix the request instead. ## What to retry | Outcome | Retry? | How | |---|---|---| | `2xx` | - | Done | | Timeout / connection reset | Yes | Same request, same `reference` | | `429 rate_limited` | Yes | After the `Retry-After` seconds | | `500 server_error` | Yes | Exponential backoff | | `409 not_connected` | After the business reconnects | Offer a connect link first | | `422 template_not_approved` | After approval | Wait for a `template.status` callback | | Other `4xx` | No | Fix the request | ## Backoff Wait longer after each failure and add jitter, for example 1 s, 2 s, 4 s, 8 s... up to a few minutes, then give up and alert. Run retries in a background queue, never in a user's web request. ```php // A Laravel job: retried by the queue with backoff; the reference makes it safe. class SendWhatsAppNotification implements ShouldQueue { public $tries = 6; public $backoff = [5, 30, 120, 600, 1800]; public function handle(): void { $response = Http::withToken(config('services.paalchat.token'))->acceptJson()->timeout(20) ->post("https://whatsapp.paaltech.org/api/v1/businesses/{$this->tenant}/messages", $this->payload); if ($response->status() === 429 || $response->serverError()) { $this->release((int) $response->header('Retry-After') ?: 60); return; } $response->throw(); // other 4xx: fail and surface the error code } } ``` ```javascript async function sendWithRetry(body, attempt = 0) { try { const r = await fetch(`${BASE}/businesses/presec/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify(body) }); if (r.status === 429 || r.status >= 500) throw Object.assign(new Error('retry'), { wait: Number(r.headers.get('Retry-After')) || 0 }); return await r.json(); } catch (e) { if (attempt >= 5) throw e; const wait = e.wait || Math.min(2 ** attempt, 300) + Math.random(); await new Promise((ok) => setTimeout(ok, wait * 1000)); return sendWithRetry(body, attempt + 1); // same body, same reference } } ``` ```python import random, time def send_with_retry(body, attempts=6): for attempt in range(attempts): try: r = requests.post(f"{BASE}/businesses/presec/messages", headers=HEADERS, json=body, timeout=20) if r.status_code != 429 and r.status_code < 500: return r.json() wait = int(r.headers.get("Retry-After", 0)) or min(2 ** attempt, 300) except requests.RequestException: wait = min(2 ** attempt, 300) time.sleep(wait + random.random()) raise RuntimeError("PaalChat unavailable") ``` ## Your callback endpoint PaalChat retries callbacks too. Make your endpoint idempotent (see [Idempotency](/docs/idempotency)) and answer `2xx` only after the event is safely recorded. --- # Rate limits PaalChat's request limit and Meta's messaging limits. > **In a nutshell:** 300 API requests per minute per product, 120 per business. Meta separately limits how many new conversations each business can start per day. Send in bulk from a queue and respect Retry-After. ## API requests Each product may make **300 requests per minute**, across all its businesses. Every response carries: ```http X-RateLimit-Limit: 300 X-RateLimit-Remaining: 287 ``` Over the limit you get `429 rate_limited` with a `Retry-After` header (seconds). Each business also has its own budget of **120 requests per minute** for its `/businesses/{external_id}/...` endpoints, so one busy business cannot use up your product's whole allowance. PaalTech can set a **daily message cap** for a business; sends beyond it get `429 business_limit_reached` until the next day. ## Meta's messaging limits Meta limits how many conversations a business can start in 24 hours. The tier is in [`GET .../whatsapp`](/docs/api/get-whatsapp) as `messaging_limit`: | Tier | Business-initiated conversations / 24 h | |---|---| | `TIER_250` | 250 | | `TIER_1K` | 1,000 | | `TIER_10K` | 10,000 | | `TIER_100K` | 100,000 | | `TIER_UNLIMITED` | Unlimited | Meta raises the tier as the business sends good-quality messages. Sends beyond it fail with a `message.status` callback. A `whatsapp.connection` callback reports tier changes (events such as `UPGRADE`, `DOWNGRADE`). ## Quality `quality_rating` (`GREEN`, `YELLOW`, `RED`) reflects how recipients react. Many blocks and reports lower it and can lower the tier. Send only messages people expect. ## Bulk sending - Queue sends in a background worker; pace them under your 300/minute budget. - Give every message its own `reference` so a crashed batch can simply be resent. - See the [bulk announcements](/docs/use-cases/bulk-announcements) use case. --- # WhatsApp pricing Who pays Meta, what Meta charges for, and the pricing reported on each message. > **In a nutshell:** Meta bills each business's WhatsApp Business Account directly, per delivered message; PaalChat never charges for Meta messages. From 1 October 2026 replies inside the 24-hour window are billable too. Each outgoing message's pricing arrives in message.status as billable, category, type and model. ## Who pays whom | Charge | Paid by | Paid to | |---|---|---| | WhatsApp messages (Meta's rate card) | The business, through its WhatsApp Business Account | Meta | | PaalChat | Arranged with PaalTech separately | PaalTech | PaalChat is a Meta Tech Provider. It never collects, pre-pays or marks up Meta's charges. The business adds a payment method to its WhatsApp Business Account in WhatsApp Manager, and Meta invoices it there. ## What Meta charges for Meta charges **per delivered message**, at a rate set by the recipient's country and the message's category. Rates change, so PaalChat does not repeat them here - see Meta's pricing pages and the rate card in WhatsApp Manager. | Message | Charged | |---|---| | Marketing template | Always | | Utility template | Always, from 1 October 2026 (before that, free inside the 24-hour window) | | Authentication template | Always | | Free-form reply inside the 24-hour window (category `service`) | From 1 October 2026, at the same rate as utility in that market | | Messages inside the 72-hour window opened by a Click-to-WhatsApp ad | Free | > [!IMPORTANT] > From **1 October 2026** Meta charges for service messages: the free-form replies > a business sends while the 24-hour window is open. That includes automatic > replies from bots. Keep automated replies purposeful. Source: Meta, [Upcoming pricing updates for service and utility messages](https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing/non-template-messages). Meta's own pages are authoritative when they differ from this guide. ## Pricing on each message When Meta reports a message as sent, delivered or read, it says how it prices it. PaalChat records that on the message and passes it on as `pricing` in the [`message.status`](/docs/api/callbacks/message-status) callback and in [`GET .../messages/{id}`](/docs/api/get-message): ```json "pricing": { "billable": true, "category": "service", "type": "regular", "model": "PMP" } ``` | Field | Meaning | |---|---| | `billable` | Whether Meta charges for this message | | `category` | Meta's pricing category: `marketing`, `marketing_lite`, `utility`, `authentication`, `authentication-international`, `service`, `referral_conversion` | | `type` | `regular` (charged), `free_customer_service` or `free_entry_point` | | `model` | Meta's pricing model; `PMP` is per-message pricing | `pricing` is `null` until Meta reports it, for messages that failed before being sent, and for incoming messages. Treat unknown category or type values as informational - Meta adds new ones over time. ## Keeping costs predictable - Count billable messages per business from `message.status` if you show usage to your customers; label it as Meta's charge, billed by Meta. - Send templates only to people who expect them, and batch announcements. - For bots, reply once with what the customer needs rather than several short messages. ## Usage and estimates in PaalChat PaalChat records Meta's pricing for every delivered message and estimates what it costs from Meta's published rate card. [Get Meta usage](/docs/api/get-meta-usage) shows a month per number and category; [Estimate Meta cost](/docs/api/estimate-meta-cost) prices an audience before a bulk send. Each month is compared with Meta's own analytics, and where they differ Meta's figures win. These are **estimates** - Meta bills the business's WhatsApp Business Account directly, and Meta amounts never appear on a PaalChat invoice. ## PaalChat's own plans PaalChat's subscription is separate from Meta's charges and covers PaalChat only: the API, callbacks, inbox, templates, automations, campaigns and analytics, within the plan's limits (WhatsApp numbers, inbox members, contacts). [List plans](/docs/api/list-plans) shows them; plans marked `placeholder` are examples until PaalTech publishes its prices. [Get billing](/docs/api/get-billing) shows a business's plan, usage and invoices next to - and apart from - its estimated Meta charges; [Pay invoice](/docs/api/pay-invoice) gives a Paystack checkout link. When a plan's limit is reached, new inbox members and new contacts from the API are refused with `402 plan_limit_reached`; contacts who write in are always kept, and a number already sending keeps sending. --- # Testing your integration Test safely before you go live. > **In a nutshell:** Use a test key (sk_test_). It works on sandbox businesses, whose WhatsApp is simulated end to end - connect, send, receive, statuses, callbacks - without Meta. Then test the failure paths on purpose. ## The sandbox A **test key** (`sk_test_...`) works on sandbox businesses only - a separate set from your live ones, created the usual way with [`POST /businesses`](/docs/api/upsert-business). Their WhatsApp is simulated: nothing reaches Meta or anyone's phone. 1. **Connect.** Create a connect link and open it: the page connects a simulated account in one click, with a number and approved templates (`fee_reminder`, `welcome`), and sends `whatsapp.connection`. 2. **Receive.** Simulate an incoming message - it creates the contact and conversation and sends `message.received`, as live. 3. **Send.** Send text (inside the window) or templates as usual. Messages are `sent` at once; to a number ending in `0001` they fail with `131026`, like a number not on WhatsApp. 4. **Deliver.** Simulate a delivery status - `delivered`, `read` or `failed` - and get `message.status`. Callbacks from sandbox businesses carry `"sandbox": true`. Templates you create in the sandbox are approved at once. ## Staging with a real number 1. **Ask for a staging product.** A PaalChat operator creates it and gives you a token with the abilities you need. 2. **Connect a test number.** Meta gives every app a test WhatsApp number that can message up to five verified recipients. Connect it through the normal connect link. 3. **Expose your callback endpoint.** Run your app locally behind an HTTPS tunnel and ask the operator to set that URL as the staging callback URL. 4. **Import the Postman collection.** Download it, set `token` and `external_id`, and call `Get product`. > [!NOTE] > With a live key and Meta's test number, messages are real WhatsApp messages, so only > use numbers you control. ## Test these paths on purpose - [ ] `GET /me` shows every ability your code uses. - [ ] Creating the same business twice returns `201` then `200`. - [ ] Free-form text to a number that has not messaged you returns `422 outside_service_window`, and your app offers a template. - [ ] A send to a number that is not on WhatsApp ends as `message.status` `failed` with code `131026`. - [ ] Sending the same `reference` twice returns `202` then `200`, and only one message arrives. - [ ] Your callback endpoint answers `401` to a bad signature and to a signature older than 5 minutes. - [ ] Delivering the same callback twice changes nothing the second time. - [ ] A `whatsapp.connection` `disconnected` callback disables WhatsApp features in your app. --- # PHP SDK and Laravel package paaltech/paalchat-php and paaltech/paalchat-laravel - typed errors, safe retries and verified callbacks. > **In a nutshell:** composer require paaltech/paalchat-laravel, set PAALCHAT_API_KEY and PAALCHAT_WEBHOOK_SECRET. Writes get an Idempotency-Key automatically and are retried safely; errors are typed exceptions with error codes; callbacks arrive verified and de-duplicated as Laravel events. ## Install ```bash composer require paaltech/paalchat-laravel # Laravel (includes the PHP client) composer require paaltech/paalchat-php # any PHP 8.2+ project ``` ```dotenv PAALCHAT_API_KEY=sk_live_... # sk_test_... for the sandbox PAALCHAT_WEBHOOK_SECRET=... # your webhook endpoint's secret ``` ## Send ```php use PaalTech\PaalChat\Laravel\Facades\PaalChat; PaalChat::businesses()->upsert(['external_id' => 'presec', 'name' => 'Presec Legon']); PaalChat::messages('presec')->sendTemplate( '+233241234567', 'fee_reminder', 'en_US', ['parent' => 'Mrs Mensah', 'student' => 'Kofi'], reference: 'notification-4411', ); ``` Without Laravel: `$paalchat = new PaalTech\PaalChat\PaalChat(getenv('PAALCHAT_API_KEY'));` then the same calls on `$paalchat`. | Area | Calls | |---|---| | Product | `me()`, `usage([...])` | | Businesses | `businesses()->upsert()`, `get()`, `list()` | | WhatsApp | `whatsapp($business)->connectLink()`, `status()`, `phones()`, `disconnect()` | | Templates | `templates($business)->list()`, `create()`, `delete()` | | Messages | `messages($business)->send()`, `sendText()`, `sendTemplate()`, `get()` | | Media | `media($business)->upload($path)`, `get($id)` | | Contacts | `contacts($business)->list()`, `upsert()`, `get()`, `update()` | | Conversations | `conversations($business)->list()`, `get()`, `update()`, `messages()`, `addNote()` | | Inbox | `inbox($business)->signIn($staff)`, `members()`, `removeAccess()` | | Sandbox | `sandbox($business)->incoming()`, `status()` | ## Retries and errors - Every write gets an `Idempotency-Key` (reused across its retries), so a retry never acts twice. Pass your own as the last argument of `send()` if you retry later yourself. - Network errors, `429` (after `Retry-After`) and `5xx` are retried with backoff (`max_retries`, default 2). - Errors are exceptions: `AuthenticationException` (401), `PermissionException` (403), `NotFoundException` (404), `ConflictException` (409), `ValidationException` (422, with `$e->errors`), `RateLimitException` (429, `$e->retryAfter`), `ServerException`, `ConnectionException`. Each has `$e->errorCode` and `$e->requestId`. ```php use PaalTech\PaalChat\Exceptions\ValidationException; try { PaalChat::messages('presec')->sendText('233241234567', 'Hello', reference: 'inbox-991'); } catch (ValidationException $e) { if ($e->errorCode === 'outside_service_window') { // offer a template instead } } ``` ## Callbacks The Laravel package registers `POST /paalchat/callback` (set `PAALCHAT_WEBHOOK_PATH` to change it; exclude it from CSRF). Each callback is verified on the raw body, de-duplicated on `X-PaalChat-Delivery`, answered at once, and fired as `PaalChatEventReceived` and `paalchat.`: ```php use Illuminate\Support\Facades\Event; use PaalTech\PaalChat\Event as PaalChatEvent; Event::listen('paalchat.message.status', function (PaalChatEvent $event) { Notification::where('id', $event->data['reference'])->first()?->advanceTo($event->data['status']); }); ``` Without Laravel, verify yourself: ```php use PaalTech\PaalChat\Webhook; $event = Webhook::constructEvent(file_get_contents('php://input'), $_SERVER['HTTP_X_PAALCHAT_SIGNATURE'] ?? '', $secret); ``` --- # Versioning and deprecations What may change inside /v1, how you hear about it, and how a /v2 would run alongside. > **In a nutshell:** Inside /v1 PaalChat only adds - new endpoints, optional fields, response fields, events and error codes. Anything that would break you waits for /v2. Deprecated endpoints keep working for at least six months and say so in Deprecation and Sunset headers. Every response carries `X-PaalChat-API-Version: v1`. The version is in the URL (`/api/v1/...`), so nothing changes under you without a new URL. ## What may change inside v1 These are **not** breaking, and may happen at any time - build your client to tolerate them: - New endpoints, and new optional request fields or query parameters. - New fields in responses and in callback `data` (ignore fields you don't know). - New callback events (endpoints only get the events they subscribe to - or all, if they subscribe to none, so ignore event names you don't know). - New `error.code` values (handle unknown codes by their HTTP status). - New values in documented open lists - for example message `type`, a template's `status`, or `whatsapp.connection` events. - Longer limits (larger maximum lengths, higher rate limits). ## What waits for a new version These would break existing clients, so they only happen in `/v2`: - Removing or renaming an endpoint, a field or an event. - Changing a field's type or meaning, or making an optional field required. - Changing the error envelope, authentication, or callback signing. - Narrowing what is accepted (shorter limits, stricter formats) where valid calls would start failing. ## Deprecations When something in `/v1` is to go away (because `/v2` has a better way): 1. It is announced in the [changelog](/docs/changelog) and marked deprecated in the [API reference](/docs/api). 2. Its responses carry `Deprecation` (when it was deprecated), `Sunset` (when it stops working) and a `Link` to the migration notes. Log these headers in your client. 3. It keeps working for **at least six months** after the announcement. ## A new version `/v2` would run **alongside** `/v1`, with the same keys, businesses and data - you move endpoint by endpoint. `/v1` stays available for at least twelve months after `/v2` is released, and its sunset date is announced at least six months ahead. --- # Go-live checklist Everything to confirm before production traffic. > **In a nutshell:** Production token in a secret store, verified callbacks, references on every send, retries in a queue, and connection problems visible to your support team. ## Credentials - [ ] A **production** token, separate from staging, with only the abilities you use. - [ ] The token lives in your server's secret store or environment - never in a browser, app, repository or log. - [ ] Your app calls [`GET /me`](/docs/api/get-me) at startup and fails if an ability is missing. ## Callbacks - [ ] The production callback URL is HTTPS and registered by a PaalChat operator. - [ ] Every callback's signature is verified on the raw body; old timestamps are rejected. - [ ] Deliveries are de-duplicated on `X-PaalChat-Delivery`. - [ ] The endpoint answers `2xx` within 10 seconds and queues slow work. ## Sending - [ ] Every send has a `reference` from your own database. - [ ] Sends run in a background queue with backoff on `429` and `5xx`. - [ ] `outside_service_window` falls back to a template. - [ ] Message statuses are applied only forward. ## Customers - [ ] Connect links go only to the customer's administrator. - [ ] Your UI shows the connection state and `health.last_error`, and offers "Reconnect". - [ ] Admins are told to add a payment method in WhatsApp Manager. - [ ] `message.received` content is stored under your retention and access rules. - [ ] PaalTech has registered the origins of every `return_url` you use for connect links. ## Operations - [ ] Alerts for `whatsapp.connection` `revoked` / `setup_failed` / `suspended` / `disconnected` / `token_expiring`. - [ ] A support view of each customer's WhatsApp health (see [Connection health](/docs/use-cases/connection-health)). - [ ] A plan to rotate the token and callback secret. --- # School fee reminders Remind parents of balances with an approved template and track delivery. > **In a nutshell:** Pick the APPROVED fees template, fill its placeholders per student, send each with your own reference from a queue, and update the reminder's status from message.status callbacks. ## The flow 1. **Load the template.** `GET /businesses/{school}/whatsapp/templates?status=APPROVED&name=fees_reminder` - cache it. 2. **Create your rows first.** One reminder row per parent in your database, status `pending`. 3. **Send from a queue.** For each row, `POST .../messages` with `type: template` and `reference: "fee-reminder-{row id}"`. Store `data.id`. 4. **Track.** `message.status` callbacks carry your reference - move the row to sent, delivered, read or failed. ## The template Created by the school in WhatsApp Manager (category `UTILITY`): ```text Dear {{1}}, the fees balance for {{2}} is GHS {{3}}. Please pay before {{4}}. ``` ## Sending ```php foreach ($reminders as $reminder) { SendFeeReminder::dispatch($reminder->id); // queued; see Safe retries } // In the job: Http::withToken(config('services.paalchat.token'))->acceptJson() ->post("https://whatsapp.paaltech.org/api/v1/businesses/{$tenantId}/messages", [ 'to' => $reminder->parent_phone, 'type' => 'template', 'reference' => 'fee-reminder-'.$reminder->id, 'template' => [ 'name' => 'fees_reminder', 'language' => 'en_US', 'components' => [['type' => 'body', 'parameters' => [ ['type' => 'text', 'text' => $reminder->parent_name], ['type' => 'text', 'text' => $reminder->student_name], ['type' => 'text', 'text' => number_format($reminder->balance, 2)], ['type' => 'text', 'text' => $reminder->due_date->format('j F')], ]]], ], ])->throw(); ``` ```javascript for (const r of reminders) { await queue.add('fee-reminder', { id: r.id }); } // worker await fetch(`${BASE}/businesses/${school}/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ to: r.parentPhone, type: 'template', reference: `fee-reminder-${r.id}`, template: { name: 'fees_reminder', language: 'en_US', components: [{ type: 'body', parameters: [ { type: 'text', text: r.parentName }, { type: 'text', text: r.studentName }, { type: 'text', text: r.balance.toFixed(2) }, { type: 'text', text: r.dueDate }] }] }, }) }); ``` ## Tracking ```json "data": {"reference": "fee-reminder-4411", "status": "failed", "error": {"code": "131026", "message": "Recipient is not on WhatsApp"}} ``` Show failed reminders to the bursar so they can call or SMS those parents instead. > [!TIP] > Send reminders during the day. Messages at night get blocked more often, which > lowers the number's quality rating. --- # Parent inbox Let staff chat with parents inside your product. > **In a nutshell:** Store message.received callbacks as conversations, reply with free-form text while the 24-hour window is open, fall back to a template when it is closed, and show ticks from message.status. ## Building blocks | Step | Use | |---|---| | Parent writes | [`message.received`](/docs/api/callbacks/message-received) → store under `data.from` | | Staff replies | [`POST .../messages`](/docs/api/send-message) `type: text` with a `reference` | | Window closed | `422 outside_service_window` → offer a template | | Ticks | [`message.status`](/docs/api/callbacks/message-status) → update the reply | | Parent replies to a message | `data.message.context.id` is the `wamid` of the message they replied to | ## The window, in your UI Keep `last_incoming_at` per contact (from `message.received`). If it is older than 24 hours, disable the text box and show "Send a template" instead of waiting for the error. ## Matching the parent `data.from` is the parent's WhatsApp number without `+` (for example `233241234567`). Normalise the phone numbers in your own records the same way to find the parent, their children and classes. ## Statuses in the thread | Status | Show | |---|---| | `queued` | clock | | `sent` | ✓ | | `delivered` | ✓✓ | | `read` | blue ✓✓ | | `failed` | ⚠ with `error.message`, and a retry button that re-sends with a **new** reference | > [!NOTE] > Retrying a *failed* message is a new attempt, so it gets a new reference. Retrying > after a *timeout* (you do not know the outcome) uses the same reference. --- # Bulk announcements Send one announcement to hundreds of parents or customers, safely. > **In a nutshell:** Write one row per recipient first, then send each from a queue with its own reference, paced under 300 requests per minute. If anything crashes, resend everything unconfirmed - references stop duplicates. ## The flow 1. **Choose an approved template** - announcements start conversations, so they must be templates. 2. **Create one row per recipient** in your database before sending anything. 3. **Queue the sends** with `reference: "announcement-{id}-{recipient id}"`. 4. **Pace them** under your product's 300 requests per minute - across all your businesses. 5. **Resume after failure** by resending every row not yet `202`/`200`: the same references never send twice. ## Limits that apply | Limit | What happens | |---|---| | PaalChat: 300 requests/minute per product | `429 rate_limited` - wait `Retry-After` | | Meta: the business's `messaging_limit` tier | Sends beyond it fail as `message.status` `failed` | | Meta: marketing opt-outs | `131050` failures for users who stopped marketing messages | Check the business's `messaging_limit` with [`GET .../whatsapp`](/docs/api/get-whatsapp) before a large send, and split the audience across days if needed. > [!WARNING] > Use `UTILITY` templates for information parents expect (closures, exams, fees). > Promotional content is `MARKETING`, costs more, and is limited more by Meta. --- # PaalPOS receipts Send a WhatsApp receipt when a sale completes. > **In a nutshell:** Each shop is a business. When a sale completes and the customer gave a number, send an approved receipt template with the sale ID as reference. A URL button can link to the full receipt. ## Setup per shop 1. `POST /businesses` with `external_id` = the shop ID. 2. The shop owner connects WhatsApp through a [connect link](/docs/connect-whatsapp). 3. The owner creates a `UTILITY` template in WhatsApp Manager, for example: ```text Thank you for shopping at {{1}}. Total: GHS {{2}}. Receipt no. {{3}}. [Button: View receipt → https://pos.example.com/r/{{1}}] ``` ## On each sale ```php Http::withToken(config('services.paalchat.token'))->acceptJson() ->post("https://whatsapp.paaltech.org/api/v1/businesses/{$shop->id}/messages", [ 'to' => $sale->customer_phone, 'type' => 'template', 'reference' => 'receipt-'.$sale->id, 'template' => [ 'name' => 'sale_receipt', 'language' => 'en_US', 'components' => [ ['type' => 'body', 'parameters' => [ ['type' => 'text', 'text' => $shop->name], ['type' => 'text', 'text' => number_format($sale->total, 2)], ['type' => 'text', 'text' => $sale->number], ]], ['type' => 'button', 'sub_type' => 'url', 'index' => '0', 'parameters' => [ ['type' => 'text', 'text' => $sale->public_token], ]], ], ], ]); ``` ```javascript await fetch(`${BASE}/businesses/${shop.id}/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ to: sale.customerPhone, type: 'template', reference: `receipt-${sale.id}`, template: { name: 'sale_receipt', language: 'en_US', components: [ { type: 'body', parameters: [{ type: 'text', text: shop.name }, { type: 'text', text: sale.total.toFixed(2) }, { type: 'text', text: sale.number }] }, { type: 'button', sub_type: 'url', index: '0', parameters: [{ type: 'text', text: sale.publicToken }] }, ] }, }) }); ``` > [!TIP] > Send receipts from a queue so a slow network never delays the till. The > `receipt-{sale id}` reference means a retried job never sends a second receipt. --- # Connection health Spot and fix broken WhatsApp connections before customers notice. > **In a nutshell:** React to whatsapp.connection callbacks, and give your support team a view built from GET .../whatsapp - status, last error, quality, token expiry and last webhook. ## Signals | Signal | Source | Action | |---|---|---| | `event: revoked` or `setup_failed` | [`whatsapp.connection`](/docs/api/callbacks/whatsapp-connection) | Show `health.last_error`, offer "Reconnect" | | `event: disconnected` | callback | Disable WhatsApp features, offer "Connect" | | `event: token_expiring` (status `degraded`) | callback | Ask the admin to reconnect within 7 days | | `event: suspended` | callback | Tell the business Meta disabled the account; sending resumes when Meta reinstates it | | `event: paused` / `resumed` | callback | Show "WhatsApp paused by PaalChat" until resumed | | `quality_rating: RED` | [`GET .../whatsapp`](/docs/api/get-whatsapp) | Review what is being sent | | `health.last_webhook_at` old for an active customer | `GET .../whatsapp` | Contact PaalTech support | | Sends failing with `409 not_connected` | API | Same as disconnected | ## Reconnecting A new [connect link](/docs/connect-whatsapp) fixes `pending`, `revoked`, `disconnected` and an expiring token (`degraded`). Reconnecting the same WhatsApp account updates it in place - no history or messages are lost. ## A support dashboard For each customer, call `GET /businesses/{external_id}/whatsapp` (for example every few hours, and after each callback) and show: - `status` and `health.has_credential` - `health.last_error` in Meta's words - `phone_numbers[].display_phone_number`, `quality_rating`, `messaging_limit` - `health.token_expires_at` (`null` means no expiry) - `health.last_webhook_at` and `health.last_synced_at` --- # FAQ Short answers to common questions. ## Does PaalChat store message text? Yes, encrypted, as each business's conversation history: PaalChat keeps contacts, conversations and the content of every message sent or received. Content is emptied after **365 days** by default (per-business retention policies are coming); the message itself - status, times, who it was with - stays. PaalTech operators see conversation details but never message content. Text also passes briefly through: | Where | What | How long | |---|---|---| | Send queue | Your outgoing message, until Meta accepts it | Until sent (a job that crashes is kept in the failed-job list) | | Webhook log | Meta's delivery, including incoming text | Emptied 3 days after processing; 14 days if processing failed | | Callback outbox | The event sent to your callback URL | Deleted 3 days after it was queued; 14 days if delivery failed | Everything is encrypted at rest. Keep your own copy of anything you must hold longer, under your own retention rules. ## Who pays Meta? The business, directly, through the payment method on its WhatsApp Business Account. Meta charges per delivered message; PaalChat never bills for Meta messages. See [WhatsApp pricing](/docs/pricing). ## Can I create templates through the API? Yes - [Create template](/docs/api/create-template) submits it to Meta for review, and the decision arrives as a `template.status` callback. Templates made in WhatsApp Manager are synced too. ## Can I download incoming images and documents? Yes, for businesses with media enabled. PaalChat downloads each attachment from Meta and stores it; `message.received` carries its `media_id` and `media.updated` follows with a short-lived link. See [Media](/docs/media). ## The API accepted my text message but it failed with 131047. Why? The 24-hour window closed between queueing and sending, or the customer's last message reached PaalChat late. Use a template. ## Can two products share one WhatsApp number? No. A WhatsApp account and its numbers belong to one business on PaalChat. ## What happens if Meta is down? Sends are retried while Meta throttles; other Meta failures fail the message with Meta's reason. Meta's webhooks are stored and processed when things recover, and your callbacks follow. ## Is there a sandbox? Not yet. Use a staging product and Meta's test number - see [Testing](/docs/testing). ## Where do I get a token or a callback secret? From a PaalChat operator at PaalTech. Both are shown once. ## Is there a status page? Yes: [/status](/status) shows the API, callbacks, WhatsApp sending and receiving, and the console and inbox, with incidents of the last 90 days. Machines can read [/status.json](/status.json). --- # Changelog Changes to the PaalChat API. ## 2026-10-04 - **Self-serve accounts: trust rules.** Accounts that sign up themselves verify their phone by SMS before sending templates (`account_not_verified`), and new accounts have a daily limit of business-initiated messages that rises with the account's age and record (`sending_limit_reached`). When Meta flags a number's quality, the business's campaigns pause. Products PaalTech set up are not affected by the limits. - **AI assistant (dashboard)**: self-serve businesses can switch on an assistant that answers customers from what they teach it (FAQs, text, documents, web pages) - Suggest drafts replies for staff, Auto replies and hands over to people. Scoped to the business's own purpose, per WhatsApp's rules. No API changes yet; assistant settings through the API come next. ## 2026-10-03 - **Arkesel SMS**: SMS can go through Arkesel (now the default for new SMS setups) or Hubtel, chosen per business with `provider` on [Set up SMS or email](/docs/api/enable-channel). A business may send with its own Arkesel account (`arkesel.api_key`, checked and stored encrypted; `own_key` on the connection). New errors `invalid_provider_credentials` and `provider_unavailable`; `arkesel` in provider health. - **Inbox, Connect and status pages**: light and dark themes (following the device by default), a refreshed design, and an inbox that shows delivery ticks on each reply. No API changes. ## 2026-10-02 - **PaalChat billing** (off until prices are published): plans with limits and features, Get billing (PaalChat and Meta shown apart), invoices paid through Paystack. New scope `billing.manage`; errors `plan_limit_reached`, `plan_feature_missing`, `payment_unavailable`. - **SMS, email and failover**: SMS through Hubtel and email through Resend per business (`channel` on Send message), notifications that fall back between channels (`notification.updated`), and provider health (`GET /providers`). Errors `channel_not_enabled`, `channel_not_allowed`. - **Operations**: a public [status page](/status) (and `/status.json`), and WhatsApp account transfers between businesses that both sides approve (`whatsapp.connection` events `transfer_requested`, `transferred_out`, `transferred_in`). - **Automations**: triggers (message received, keyword, contact created, conversation status, date field), conditions, steps with waits, run logs, and the `automation.action` callback. New scopes `automations.read` / `automations.manage`. - **Segments, scheduling and campaigns**: saved segments over contact fields, tags and consent; `send_at` on template sends with Cancel scheduled message (status `cancelled`); campaigns with preview (skips, estimated Meta cost), pacing, repeat, pause/resume/cancel and results (`campaign.updated`). Scopes `campaigns.read` / `campaigns.manage` are now in use. - **Templates as a product**: template versions and history (removed templates are kept), parameters checked before sending (`invalid_template_parameters`), and a template library for schools and shops. **Fix**: templates created through the API now reach Meta with their full components (text and examples were dropped before). - **Meta usage estimates**: Get Meta usage (per number and category, compared with Meta's own analytics) and Estimate Meta cost for an audience. Estimates only; Meta bills businesses directly. - **Data rights**: per-business retention (`retention.message_content_days`), Export business data, and Erase contact; new ability `data.manage`. - **Consent and quiet hours**: STOP/START replies, Record consent, notification preferences, a suppression list, and business quiet hours with time zones - checked before every business-initiated template. New callback `contact.consent`; new fields `category`, `topic`, `urgent` on Send message and `scheduled_for` on messages; ability `messages.urgent`. - **PHP SDK and Laravel package** (`paaltech/paalchat-php`, `paaltech/paalchat-laravel`) and a written **versioning policy**; responses carry `X-PaalChat-API-Version`, and deprecated endpoints will carry `Deprecation` and `Sunset` headers. - **API keys**: `sk_live_` and `sk_test_` keys, revocable, optionally tied to IP addresses; `GET /me` shows the key's `environment` and `prefix`. Older `ptw_` tokens keep working. - **Sandbox**: test keys work on sandbox businesses with simulated WhatsApp - connect in one click, simulate incoming messages and delivery statuses. Callbacks carry `sandbox`. - **Idempotency-Key** header on every write; **per-business rate limit** (120/minute) and optional daily message cap; **Get usage** for PaalChat usage per day. - **Inbox**: a business's staff work their conversations in PaalChat, signed in from your product with a one-time link (Sign staff in to the inbox). Roles admin, agent and viewer; remove access when staff leave. Conversations have an `assignee` (an inbox member), set through Update conversation. New ability `inbox.manage`, error `unknown_member`. - **Media**: incoming attachments are downloaded and stored by PaalChat (`media_id` on `message.received`, then a `media.updated` callback with a 15-minute signed link); upload files and send image, video, audio and document messages. Endpoints Upload media and Get media; errors `feature_disabled`, `unknown_media`, `media_not_ready`, `media_type_mismatch`, `unsupported_media`, `media_too_large`. Enabled per business. ## 2026-10-01 - **Templates through the API**: create (submitted to Meta for review) and delete (one language or all) with the `templates.manage` scope. New errors `template_rejected`, `waba_required`, `meta_unavailable`. - **Contacts and conversations API**: list, get and upsert contacts (your own `external_id`, custom fields, tags), define custom fields, list and work conversations (status, priority, tags, read), read their messages with content, add internal notes. New scopes `conversations.read` / `conversations.manage`; new events `contact.created`, `contact.updated`, `conversation.created`, `conversation.updated`. Messages and their callbacks carry `contact_id` and `conversation_id`. See [Contacts and conversations](/docs/contacts-conversations). - PaalChat now keeps each business's **contacts and conversations**, and the content of messages (encrypted, 365 days by default) - see the [FAQ](/docs/faq). API access to them is coming. - A product can have several webhook endpoints, each with its own secret and an optional filter by event, business or channel - see [Callbacks](/docs/callbacks#several-endpoints). Your existing callback URL is your default endpoint and receives everything, as before. - Every response carries `X-Request-ID` (send your own to correlate), repeated in error bodies as `error.request_id` - see [Errors](/docs/errors#request-ids). - **Token scopes renamed** to `.`: `businesses.read`, `businesses.write`, `connections.read`, `connections.manage`, `templates.read`, `messages.read`, `messages.send` (plus reserved scopes for coming endpoints). Existing tokens were converted automatically - see [Authentication](/docs/authentication). - Messages carry Meta's `pricing` (billable, category, type, model) in the message resource and `message.status`. New guide: [WhatsApp pricing](/docs/pricing). - New message status `unknown`: Meta's answer to a send was lost, so it may or may not have been delivered; it is never resent automatically. - The 24-hour window is checked per sending number, as Meta applies it. - Connect links accept a `return_url` only on your product's registered origins (`422 return_url_not_allowed`). - "Free text" is now called "free-form text" in the docs. - **Connection states** are now `pending`, `connecting`, `connected`, `degraded`, `maintenance`, `suspended`, `revoked` and `disconnected` (`error` is gone - see [Connection states](/docs/connect-whatsapp#connection-states)), with matching `whatsapp.connection` events. New `409 connection_paused`. - A WhatsApp account's `health.credential` (a status) is now `health.has_credential` (true/false); the account's own `status` says whether it works. ## 2026-09-30 - v1 - Businesses: list, get, create or update by `external_id`. - WhatsApp: hosted Embedded Signup connect links, connection state and health, phone numbers, templates, disconnect. - Messages: send text and template messages with idempotent references; read a message's status. - Callbacks: `message.received`, `message.status`, `template.status`, `whatsapp.connection`, signed with `X-PaalChat-Signature`. - One error format for every endpoint: `{"error": {"code", "message"}}`. --- # API Reference --- ## Get product `GET https://whatsapp.paaltech.org/api/v1/me` Returns the calling product and the key used: its environment (`live` for real businesses, `test` for the sandbox), prefix and abilities. Call it when your app starts and fail loudly if an ability you need is missing. Ability: `any` ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/me' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The product and token. ```json { "data": { "product": "multi-skuul", "name": "Multi-SKUUL", "token": { "name": "production", "environment": "live", "prefix": "sk_live_4gVx", "abilities": [ "businesses.read", "businesses.write", "connections.read", "connections.manage", "templates.read", "messages.read", "messages.send" ], "expires_at": null } } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get usage `GET https://whatsapp.paaltech.org/api/v1/usage` Your PaalChat usage per day and type - `message.sent`, `message.received`, `template.sent`, `media.processed`, `callback.delivered`, `api.request` - for all your businesses or one, in the key's environment. Defaults to the last 30 days; at most a year per call. Meta bills WhatsApp messages separately, directly to each business. Ability: `businesses.read` ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `from` | string | no | Format date | | `to` | string | no | Format date | | `business` | string | no | A business's external_id. | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/usage' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Usage per day and type. ```json { "data": { "from": "2026-09-03", "to": "2026-10-02", "business": "presec", "days": [ {"date": "2026-10-01", "type": "message.sent", "quantity": 412}, {"date": "2026-10-01", "type": "template.sent", "quantity": 398}, {"date": "2026-10-01", "type": "message.received", "quantity": 57} ] } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List businesses `GET https://whatsapp.paaltech.org/api/v1/businesses` Your businesses, newest first, 50 per page. Follow `links.next` until it is null. Ability: `businesses.read` ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - A page of businesses. ```json { "data": [ { "external_id": "presec", "name": "Presec Legon", "email": "info@presec.edu.gh", "phone": "+233302000000", "status": "active", "whatsapp": {"status": "connected", "wabas": 1}, "created_at": "2026-09-30T18:54:27+00:00" } ], "links": { "first": "https://whatsapp.paaltech.org/api/v1/businesses?page=1", "last": "https://whatsapp.paaltech.org/api/v1/businesses?page=1", "prev": null, "next": null }, "meta": { "current_page": 1, "last_page": 1, "per_page": 50, "total": 1, "from": 1, "to": 1, "path": "https://whatsapp.paaltech.org/api/v1/businesses" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create or update business `POST https://whatsapp.paaltech.org/api/v1/businesses` Creates the business, or updates it when the `external_id` already exists. Call it when a customer signs up and whenever their name or contact details change. Safe to repeat. Ability: `businesses.write` ### Body | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `name` | string | yes | Max 255 characters | | `email` | string | null | no | Max 255 characters. Format email | | `phone` | string | null | no | Max 32 characters | | `timezone` | string | no | IANA time zone (default Africa/Accra), used for quiet hours. Example `Africa/Accra` | | `quiet_hours` | object | null | no | Non-urgent business-initiated messages wait until the end (local time of the contact, else the business). null switches them off. | | `quiet_hours.start` | string | no | Pattern `^[0-2][0-9]:[0-5][0-9]$`. Example `22:00` | | `quiet_hours.end` | string | no | Pattern `^[0-2][0-9]:[0-5][0-9]$`. Example `06:00` | | `retention` | object | null | no | How long PaalChat keeps message content, notes and media files for this business (7-3650 days; null = the default, 365). | | `retention.message_content_days` | integer | no | | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "external_id": "st-augustines", "name": "St. Augustine'\''s College", "email": "info@augustines.edu.gh" }' ``` ### Responses - **200** - Updated - the external_id already existed. - **201** - Created. ```json { "data": { "external_id": "st-augustines", "name": "St. Augustine's College", "email": "info@augustines.edu.gh", "phone": null, "status": "active", "whatsapp": {"status": "not_connected", "wabas": 0}, "created_at": "2026-09-30T18:54:27+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get business `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}` One of your businesses, with its overall WhatsApp status. Ability: `businesses.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The business. ```json { "data": { "external_id": "presec", "name": "Presec Legon", "email": "info@presec.edu.gh", "phone": "+233302000000", "status": "active", "whatsapp": {"status": "connected", "wabas": 1}, "created_at": "2026-09-30T18:54:27+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List library templates `GET https://whatsapp.paaltech.org/api/v1/template-library` Starter templates by sector - school (fee reminder, results, PTA meeting, absence, admission offer) and retail (order confirmed, receipt, delivery update) - with named variables and Meta's required example values. Copy one for a business with [Create template from library](/docs/api/create-template-from-library). Ability: `templates.read` ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `sector` | string | no | One of: `school`, `retail` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/template-library' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The library. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create connect link `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/connect` A one-time link to PaalChat's hosted "Connect WhatsApp" page (Meta Embedded Signup) for this business. Redirect the business's administrator to `url`. Valid for 72 hours and until used successfully; a new link cancels older unused ones. Success triggers a `whatsapp.connection` callback with `event: connected`. Treat the link as a secret. Ability: `connections.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `return_url` | string | no | https URL offered to the user after success; `whatsapp=connected` is added to its query. Its origin must be registered for your product by PaalTech (for example `https://*.skuuls.paaltech.org`); any other URL is refused with `return_url_not_allowed`. Max 2048 characters | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/connect' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"return_url": "https://presec.skuuls.paaltech.org/settings/whatsapp"}' ``` ### Responses - **201** - Link created. ```json { "data": { "url": "https://whatsapp.paaltech.org/connect/EsCRzWiltOSLzMBAvEyNHt4bdoWsdAYXwkPKiL2KT83aQk98NFn5coKqweclo4Ih", "expires_at": "2026-10-03T18:54:28+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - business_suspended - the business is suspended on PaalChat. ```json { "error": { "code": "business_suspended", "message": "This business is suspended on the platform." } } ``` - **422** - validation_failed (fields invalid), or return_url_not_allowed (the return_url's origin is not registered for your product). ```json { "error": { "code": "return_url_not_allowed", "message": "This return_url is not on your product's registered return origins. Ask PaalTech to register its origin." } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get WhatsApp state `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp` The business's WhatsApp side: overall status, each WhatsApp Business Account with its health and phone numbers. Never contains a Meta token. Ability: `connections.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The WhatsApp state. ```json { "data": { "status": "connected", "wabas": [ { "waba_id": "102290129340398", "name": "Presec Legon", "status": "connected", "currency": "USD", "timezone_id": "1", "messaging_limit": "TIER_1K", "webhook_subscribed": true, "health": { "has_credential": true, "token_expires_at": null, "last_health_check_at": "2026-09-30T18:34:27+00:00", "last_error": null, "last_webhook_at": "2026-09-30T18:51:27+00:00", "last_synced_at": "2026-09-30T17:54:27+00:00" }, "phone_numbers": [ { "phone_number_id": "106540352242922", "display_phone_number": "+233 30 200 0000", "verified_name": "Presec Legon", "quality_rating": "GREEN", "messaging_limit": "TIER_1K", "code_verification_status": "VERIFIED", "last_synced_at": "2026-09-30T17:54:27+00:00" } ] } ] } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List phone numbers `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/phones` Phone numbers across all the business's WhatsApp Business Accounts. Ability: `connections.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/phones' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The phone numbers. ```json { "data": [ { "phone_number_id": "106540352242922", "display_phone_number": "+233 30 200 0000", "verified_name": "Presec Legon", "quality_rating": "GREEN", "messaging_limit": "TIER_1K", "code_verification_status": "VERIFIED", "waba_id": "102290129340398", "last_synced_at": "2026-09-30T17:54:27+00:00" } ] } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List templates `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/templates` Message templates of the business's WhatsApp accounts, 100 per page, by name. Components are exactly as Meta returns them. Templates Meta deleted are hidden unless `include_removed=1`. Ability: `templates.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `status` | string | no | Case-insensitive, e.g. APPROVED, PENDING, REJECTED, PAUSED. Example `APPROVED` | | `name` | string | no | Exact template name. | | `language` | string | no | Template language, e.g. en_US. Example `en_US` | | `include_removed` | boolean | no | Also list templates Meta no longer has (status REMOVED), kept with their history. | | `page` | integer | no | Default `1` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates?status=APPROVED' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - A page of templates. ```json { "data": [ { "name": "fees_reminder", "language": "en_US", "category": "UTILITY", "status": "APPROVED", "rejection_reason": null, "components": [ { "type": "BODY", "text": "Dear {{1}}, the fees balance for {{2}} is GHS {{3}}. Please pay before {{4}}.", "example": {"body_text": [["Mrs Mensah", "Kofi", "450.00", "30 October"]]} } ], "waba_id": "102290129340398", "last_synced_at": "2026-09-30T17:54:27+00:00" } ], "links": { "first": "https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates?page=1", "last": "https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates?page=1", "prev": null, "next": null }, "meta": { "current_page": 1, "last_page": 1, "per_page": 100, "total": 1, "from": 1, "to": 1, "path": "https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create template `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/templates` Submits a new template to Meta for review on the business's WhatsApp account and mirrors it (usually `PENDING`). Meta's decision arrives as a `template.status` callback. `components` are Meta's template components, passed through as they are; `category` is marketing, utility or authentication. With several WhatsApp accounts, pass `waba_id`. Meta allows 100 new templates per account per hour. Ability: `templates.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Max 512 characters. Pattern `^[a-z0-9_]+$` | | `language` | string | yes | Example `en_US` | | `category` | string | yes | One of: `marketing`, `utility`, `authentication` | | `parameter_format` | string | null | no | One of: `named`, `positional`, `` | | `components` | array of objects | yes | Meta's template components (HEADER, BODY, FOOTER, BUTTONS), with examples. Max 10 items | | `waba_id` | string | null | no | Needed only when the business has several WhatsApp accounts. | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "name": "fee_reminder", "language": "en_US", "category": "utility", "parameter_format": "named", "components": [ { "type": "BODY", "text": "Dear {{parent}}, the fees for {{student}} are due.", "example": { "body_text_named_params": [ {"param_name": "parent", "example": "Ama"}, {"param_name": "student", "example": "Kofi"} ] } } ] }' ``` ### Responses - **201** - Submitted to Meta and mirrored. ```json { "data": { "name": "fee_reminder", "language": "en_US", "category": "UTILITY", "status": "PENDING", "rejection_reason": null, "components": [ { "type": "BODY", "text": "Dear {{parent}}, the fees for {{student}} are due.", "example": { "body_text_named_params": [ {"param_name": "parent", "example": "Ama"}, {"param_name": "student", "example": "Kofi"} ] } } ], "waba_id": "102290129340398", "last_synced_at": "2026-10-01T10:00:00+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - business_suspended - the business is suspended on PaalChat. ```json { "error": { "code": "business_suspended", "message": "This business is suspended on the platform." } } ``` - **422** - validation_failed, waba_required (several accounts, no waba_id) or template_rejected (Meta refused it - the message says why). ```json { "error": { "code": "template_rejected", "message": "Meta refused the template: Character limit exceeded" } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` - **503** - meta_unavailable - Meta did not answer normally. Retry later. --- ## Create template from library `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/templates/from-library` Copies a [library template](/docs/api/list-template-library) for the business (its name filled in) and submits it to Meta for review, like Create template. The name defaults to the library key. Ability: `templates.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `key` | string | yes | Example `school_fee_reminder` | | `name` | string | null | no | Pattern `^[a-z0-9_]+$` | | `waba_id` | string | null | no | | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates/from-library' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"key": "school_fee_reminder"}' ``` ### Responses - **201** - Submitted to Meta and mirrored. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - business_suspended - the business is suspended on PaalChat. ```json { "error": { "code": "business_suspended", "message": "This business is suspended on the platform." } } ``` - **422** - validation_failed, waba_required or template_rejected. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` - **503** - meta_unavailable. --- ## List template versions `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/templates/{name}/versions` Every version of a template's content, newest first, per language - what it said, its category, Meta's decision on it and where the change came from (`sync`, `api`, `library`, `webhook`). History is never deleted, including for removed templates. Ability: `templates.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `name` | string | yes | | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `language` | string | no | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates/name/versions' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The versions. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Delete template `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/templates/{name}` Deletes the template at Meta - one language with `?language=`, otherwise every language of the name - and removes it from PaalChat's mirror. Messages already sent are not affected. Ability: `templates.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `name` | string | yes | Pattern `^[a-z0-9_]+$` | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `language` | string | no | | | `waba_id` | string | no | Needed only when several of the business's accounts have the name. | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/templates/name' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Deleted. ```json {"data": {"name": "fee_reminder", "waba_id": "102290129340398", "deleted": 1}} ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found (business) or unknown_template. - **409** - business_suspended - the business is suspended on PaalChat. ```json { "error": { "code": "business_suspended", "message": "This business is suspended on the platform." } } ``` - **422** - waba_required or template_rejected. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` - **503** - meta_unavailable. --- ## List account transfers `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/transfers` Requests to move a WhatsApp account into or out of this business (`direction` incoming / outgoing). PaalTech operators request them; each business approves or rejects through its product. After both approve, the account moves 24 hours later with its numbers, templates and connection; messages stay where they happened. Ability: `connections.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/transfers' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The transfers. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Approve or reject a transfer `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/transfers/{id}/{decision}` This business's answer. Ask the business's owner first - a transfer moves their WhatsApp number. A rejection ends the transfer; once both sides approve it completes after 24 hours (`completes_at`), announced as `whatsapp.connection` `transferred_out` / `transferred_in`. Ability: `connections.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | | `decision` | string | yes | One of: `approve`, `reject` | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/transfers/2/decision' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Recorded. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - transfer_not_pending - already approved by both, rejected, cancelled or completed. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Disconnect WABA `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/whatsapp/wabas/{waba_id}` Disconnects one WhatsApp Business Account: PaalChat deletes its Meta token and stops sending for it. History is kept and the number stays the customer's at Meta. Triggers a `whatsapp.connection` callback with `event: disconnected`. Ability: `connections.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `waba_id` | string | yes | Meta's WhatsApp Business Account ID, from Get WhatsApp state. Pattern `^[1-9][0-9]*$` | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/whatsapp/wabas/102290129340398' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Disconnected. ```json {"data": {"waba_id": "102290129340398", "status": "disconnected"}} ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Send message `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/messages` Queues a text, template or media (image, video, audio, document) message - on WhatsApp, or with `channel` sms or email (text; set up with [Set up SMS or email](/docs/api/enable-channel)). SMS and email are always business-initiated, so suppression, consent and quiet hours apply. `202` = a new message, queued; `200` = this `reference` was already submitted and the original message is returned (nothing is sent again). Progress arrives as `message.status` callbacks. Free-form text and media need an open 24-hour customer service window; otherwise send an APPROVED template. For media, [upload the file](/docs/api/upload-media) first and pass its `media.id` (businesses with media enabled). Ability: `messages.send` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `channel` | string | no | SMS and email send `type` text (email also needs `subject`); the business must have the channel set up. One of: `whatsapp`, `sms`, `email`. Default `"whatsapp"` | | `to` | string | yes | International digits, optional leading + (an email address for email). | | `subject` | string | null | no | Email only. Max 200 characters | | `type` | string | yes | One of: `text`, `template`, `image`, `video`, `audio`, `document` | | `text` | object | no | Required when type is text; not allowed otherwise. | | `text.body` | string | yes | Max 4096 characters | | `text.preview_url` | boolean | no | Default `false` | | `template` | object | no | Required when type is template; not allowed otherwise. | | `template.name` | string | yes | Max 512 characters | | `template.language` | string | yes | Max 20 characters | | `template.components` | array of objects | no | Meta's send-time components (header/body/button parameters). Max 20 items | | `media` | object | no | Required when type is image, video, audio or document; not allowed otherwise. The file must suit the type. | | `media.id` | integer | yes | PaalChat's media ID, from Upload media (or an incoming message's media_id). | | `media.caption` | string | null | no | Images, videos and documents. Max 1024 characters | | `media.filename` | string | null | no | Documents; defaults to the uploaded name. Max 240 characters | | `category` | string | null | no | Templates only - the consent category checked. Defaults from the template (MARKETING -> marketing, AUTHENTICATION -> transactional, else notifications). One of: `marketing`, `notifications`, `transactional`, `` | | `topic` | string | null | no | Templates only - refused if the contact turned this topic off. One of: `fees`, `results`, `attendance`, `pta`, `marketing`, `system`, `` | | `send_at` | string | null | no | Templates only - send at this time (up to 90 days ahead); held until then and cancellable with Cancel scheduled message. Quiet hours still apply at that time. Format date-time | | `urgent` | boolean | no | Templates only - skip the business's quiet hours. Needs the messages.urgent ability. Default `false` | | `reference` | string | null | no | Your ID for the message - the idempotency key. Strongly recommended. Max 191 characters. Pattern `^[\x21-\x7E]+$` | | `phone_number_id` | string | null | no | Required only when the business has several connected numbers. | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/messages' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "to": "+233241234567", "type": "template", "reference": "skuul-msg-8812", "template": { "name": "fees_reminder", "language": "en_US", "components": [ { "type": "body", "parameters": [ {"type": "text", "text": "Mrs Mensah"}, {"type": "text", "text": "Kofi"}, {"type": "text", "text": "450.00"}, {"type": "text", "text": "30 October"} ] } ] } }' ``` ### Responses - **200** - Duplicate reference - the original message, with its current status. - **202** - Queued. ```json { "data": { "id": 2, "reference": "skuul-msg-8812", "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 } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found (business), unknown_phone_number or unknown_media. ```json { "error": { "code": "unknown_phone_number", "message": "That phone number is not connected for this business." } } ``` - **409** - not_connected, connection_paused, business_suspended or media_not_ready. ```json { "error": { "code": "not_connected", "message": "This business has no connected WhatsApp number." } } ``` - **422** - validation_failed, outside_service_window, unknown_template, template_not_approved, phone_number_required, media_type_mismatch, recipient_suppressed, recipient_opted_out , recipient_preference_off or invalid_template_parameters. ```json { "error": { "code": "outside_service_window", "message": "The customer has not messaged in the last 24 hours. Send an approved template instead." } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get message `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/messages/{id}` A message's current status, timestamps and error. Prefer callbacks; use this when you cannot receive them. Ability: `messages.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's message ID (`data.id` from Send message). | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/messages/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The message. ```json { "data": { "id": 2, "reference": "skuul-msg-8812", "wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAERgSQ0Q", "direction": "outgoing", "contact": "233241234567", "phone_number_id": "106540352242922", "type": "template", "template_name": "fees_reminder", "status": "failed", "error": {"code": "131026", "message": "Recipient is not on WhatsApp"}, "pricing": null, "created_at": "2026-09-30T18:54:28+00:00", "sent_at": "2026-09-30T18:54:31+00:00", "delivered_at": null, "read_at": null, "failed_at": "2026-09-30T18:54:35+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Cancel scheduled message `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/messages/{id}` Withdraws a message still waiting for its `send_at` (or for the business's quiet hours to end). Its status becomes `cancelled` and a `message.status` callback follows. Once it is being sent it cannot be cancelled (`409 not_cancellable`). Ability: `messages.send` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's message ID (`data.id` from Send message). | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/messages/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Cancelled. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - not_cancellable. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get Meta usage `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/meta-usage` The business's WhatsApp usage as Meta priced it, for a month (default this one): per sending number and pricing category, billable and free messages and an **estimated** cost from Meta's published rate card (list rates, before volume discounts). `reconciliation` shows the month compared with Meta's own analytics once checked; where they differ, Meta's figures win. Meta bills the business's WhatsApp Business Account directly - these amounts never appear on a PaalChat invoice. Ability: `messages.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `month` | string | no | YYYY-MM. Pattern `^[0-9]{4}-[0-9]{2}$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/meta-usage' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The month. ```json { "data": { "month": "2026-10", "notice": "Estimated - Meta bills your WhatsApp Business Account directly.", "lines": [ { "phone_number_id": "106540352242922", "category": "marketing", "billable": 412, "free": 0, "estimated_cost": "9.270000", "currency": "USD" }, { "phone_number_id": "106540352242922", "category": "utility", "billable": 1290, "free": 57, "estimated_cost": "5.160000", "currency": "USD" } ], "reconciliation": [] } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Estimate Meta cost `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/meta-usage/estimate` Before a bulk send: the estimated Meta cost of one category of message to an audience, given as recipients per calling code. Markets without a rate are listed in `unpriced`, not guessed. An estimate from list rates - Meta bills the business directly. Ability: `messages.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `category` | string | yes | One of: `marketing`, `utility`, `authentication`, `authentication_international`, `service` | | `recipients` | object | yes | Calling code -> number of recipients. | | `currency` | string | null | no | Default: the WhatsApp account's currency. | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/meta-usage/estimate' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"category": "marketing", "recipients": {"233": 1200, "234": 40}}' ``` ### Responses - **200** - The estimate. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List plans `GET https://whatsapp.paaltech.org/api/v1/plans` PaalChat's plans - monthly price, limits (null = no limit) and features. `placeholder` plans are examples until PaalTech publishes its prices. Meta's WhatsApp charges are never part of a plan. Ability: `any` ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/plans' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The plans. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get billing `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/billing` Two separate blocks. `paalchat`: the business's plan, subscription, usage against its limits and its PaalChat invoices. `meta_whatsapp`: this month's Meta charges, estimated - Meta bills the business's WhatsApp Business Account directly; they never appear on a PaalChat invoice. Limits apply only while `billing_enabled`. Ability: `billing.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/billing' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Billing. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Pay invoice `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/billing/invoices/{id}/pay` A Paystack checkout link (card or mobile money) for an open PaalChat invoice. Send the business's billing contact there; the invoice is marked paid when Paystack confirms. Ability: `billing.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Body | Name | Type | Required | Description | |---|---|---|---| | `email` | string | null | no | Receipt address (default the billing email). Format email | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/billing/invoices/2/pay' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"email": "bursar@presec.edu.gh"}' ``` ### Responses - **200** - The checkout link. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` - **503** - payment_unavailable - online payment is not set up, or Paystack did not answer. --- ## Get provider health `GET https://whatsapp.paaltech.org/api/v1/providers` Whether each provider works right now - Meta (WhatsApp), Arkesel and Hubtel (SMS), Resend (email): `healthy`, `degraded` (more than a fifth of the last 15 minutes' sends failed) or `unavailable` (calls paused after failures, or not set up). Notifications skip unavailable channels. Ability: `any` ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/providers' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Health per provider. ```json { "data": { "meta": {"channel": "whatsapp", "status": "healthy", "failure_rate": 1.2}, "arkesel": {"channel": "sms", "status": "healthy", "failure_rate": 0.5}, "hubtel": {"channel": "sms", "status": "healthy", "failure_rate": null}, "resend": {"channel": "email", "status": "unavailable", "failure_rate": null} } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List channels `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/channels` The business's channels - WhatsApp accounts, SMS and email - with their state and their provider's health. Ability: `connections.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/channels' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The channels. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Set up SMS or email `PUT https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/channels/{channel}` Turns SMS or email on for the business. Call again to change anything. **SMS** goes through `provider` - `arkesel` (the default) or `hubtel` - under `sender_id` (1-11 letters, digits or spaces; a business's own sender ID must first be approved by that provider). PaalTech's account sends by default. With Arkesel a business may send with its own Arkesel account instead: pass `arkesel.api_key`. PaalChat checks the key with Arkesel, stores it encrypted and never returns it (`own_key: true`); omit it to keep a saved key, send `null` to remove it. Switching to Hubtel removes it. **Email** goes through Resend from `from` - an address on a domain PaalChat sends for. Ability: `connections.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `channel` | string | yes | One of: `sms`, `email` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `sender_id` | string | null | no | SMS: 1-11 letters, digits or spaces. Max 11 characters | | `provider` | string | null | no | SMS: the provider (default arkesel). One of: `arkesel`, `hubtel`, `` | | `arkesel` | object | null | no | SMS through Arkesel: the business's own account. | | `arkesel.api_key` | string | null | no | The business's own Arkesel API key; null removes a saved one. Never returned. | | `from` | string | no | Email - required. Format email | | `from_name` | string | null | no | Max 100 characters | | `reply_to` | string | null | no | Format email | ### Request ```bash curl --request PUT \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/channels/channel' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"sender_id": "PRESEC", "provider": "arkesel"}' ``` ### Responses - **200** - Set up (status connected, or pending until PaalTech has set the provider up). - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed; channel_not_allowed (a sender ID, from address or provider PaalChat cannot use); invalid_provider_credentials (Arkesel does not accept the key). - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` - **503** - provider_unavailable - Arkesel could not be reached to check the key; try again shortly. --- ## Turn off SMS or email `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/channels/{channel}` Sends on the channel are refused (`409 channel_not_enabled`) until it is set up again. Ability: `connections.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `channel` | string | yes | One of: `sms`, `email` | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/channels/channel' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Turned off. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Send notification `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/notifications` One notification, tried on `channels` in order (default whatsapp, sms, email) until one takes it. A channel is passed over when there is no content or address for it, the business has not set it up, its provider is unavailable, or the send is refused (opted out, suppressed...). If its message later fails, the next channel is tried. A message whose fate is unknown never triggers the next channel - it may already have arrived. Addresses come from `to`, or from the contact (`contact_id`). Same `reference`, same notification. Ability: `messages.send` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `channels` | array of strings | no | | | `contact_id` | integer | null | no | | | `to` | object | null | no | | | `to.whatsapp` | string | no | | | `to.sms` | string | no | | | `to.email` | string | no | | | `whatsapp` | object | null | no | | | `whatsapp.template` | object | no | Name, language and components - as on Send message. | | `sms` | object | null | no | | | `sms.text` | string | no | Max 918 characters | | `email` | object | null | no | | | `email.subject` | string | no | | | `email.text` | string | no | | | `reference` | string | null | no | Max 150 characters | | `category` | string | null | no | One of: `marketing`, `notifications`, `transactional`, `` | | `topic` | string | null | no | | | `urgent` | boolean | no | | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/notifications' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "contact_id": 41, "reference": "closing-2026-10-02", "whatsapp": {"template": {"name": "school_closing", "language": "en_US"}}, "sms": {"text": "Presec: school closes at 1pm today."}, "email": {"subject": "School closes early", "text": "School closes at 1pm today."} }' ``` ### Responses - **200** - Duplicate reference - the original notification. - **202** - Accepted; on its first usable channel. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get notification `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/notifications/{id}` Its status and every attempt - channel, outcome and message. Ability: `messages.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/notifications/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The notification. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List segments `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments` The business's saved segments, by name. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The segments. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create segment `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments` Saves an audience as conditions over the business's contacts (see the [Campaigns guide](/docs/campaigns) for fields and operators). Conditions are checked when saved; contacts are matched each time the segment is used. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Max 191 characters | | `conditions` | object | yes | {"all": [...]} or {"any": [...]} of conditions {"field", "op", "value"} or nested groups (3 levels, 30 conditions). Fields: name, phone, email, locale, timezone, external_id, created_at, tag (has, not_has), consent. (eq granted\|revoked\|none), last_incoming_at (within_days, older_than_days), custom. (by the field's type). Ops: eq, neq, gt, gte, lt, lte, contains, starts_with, in, exists, not_exists, within_days, older_than_days. Example `{"all":[{"field":"custom.class","op":"eq","value":"SHS 2"},{"field":"custom.fee_balance","op":"gt","value":0}]}` | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "name": "SHS 2 owing fees", "conditions": { "all": [ {"field": "custom.class", "op": "eq", "value": "SHS 2"}, {"field": "custom.fee_balance", "op": "gt", "value": 0} ] } }' ``` ### Responses - **201** - Saved. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Preview segment `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments/preview` How many contacts match conditions (saved or not), with five of them. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `conditions` | object | yes | {"all": [...]} or {"any": [...]} of conditions {"field", "op", "value"} or nested groups (3 levels, 30 conditions). Fields: name, phone, email, locale, timezone, external_id, created_at, tag (has, not_has), consent. (eq granted\|revoked\|none), last_incoming_at (within_days, older_than_days), custom. (by the field's type). Ops: eq, neq, gt, gte, lt, lte, contains, starts_with, in, exists, not_exists, within_days, older_than_days. Example `{"all":[{"field":"custom.class","op":"eq","value":"SHS 2"},{"field":"custom.fee_balance","op":"gt","value":0}]}` | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments/preview' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"conditions": {"all": [{"field": "tag", "op": "has", "value": "boarders"}]}}' ``` ### Responses - **200** - The count and a sample. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get segment `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments/{id}` A segment with how many contacts match it now (`count`). Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The segment. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Update segment `PATCH https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments/{id}` Rename it or replace its conditions. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Body | Name | Type | Required | Description | |---|---|---|---| | `name` | string | no | Max 191 characters | | `conditions` | object | no | {"all": [...]} or {"any": [...]} of conditions {"field", "op", "value"} or nested groups (3 levels, 30 conditions). Fields: name, phone, email, locale, timezone, external_id, created_at, tag (has, not_has), consent. (eq granted\|revoked\|none), last_incoming_at (within_days, older_than_days), custom. (by the field's type). Ops: eq, neq, gt, gte, lt, lte, contains, starts_with, in, exists, not_exists, within_days, older_than_days. Example `{"all":[{"field":"custom.class","op":"eq","value":"SHS 2"},{"field":"custom.fee_balance","op":"gt","value":0}]}` | ### Request ```bash curl --request PATCH \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"name": "SHS 2 and SHS 3 owing"}' ``` ### Responses - **200** - Updated. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Delete segment `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments/{id}` Deletes the saved segment; campaigns that used it keep their recipients. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Deleted. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List segment contacts `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/segments/{id}/contacts` The contacts matching the segment now, 100 per page. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/segments/2/contacts' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The contacts. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List campaigns `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/campaigns` The business's campaigns and their runs, newest first, 50 per page. Ability: `campaigns.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/campaigns' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The campaigns. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create campaign `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/campaigns` A draft broadcast of one APPROVED template to a segment or a list of contacts. `parameters` maps each template variable to a source per contact - `field:name` (name, phone, email, external_id), `custom:` or `text:`. Preview it, then launch it - now, at `send_at`, or repeating every `week` or `month`. Ability: `campaigns.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Max 191 characters | | `template` | object | yes | | | `template.name` | string | yes | | | `template.language` | string | yes | | | `parameters` | object | no | | | `segment_id` | integer | null | no | The audience - or contact_ids. | | `contact_ids` | array of integers | null | no | Max 10000 items | | `phone_number_id` | string | null | no | | | `category` | string | null | no | One of: `marketing`, `notifications`, `transactional`, `` | | `topic` | string | null | no | One of: `fees`, `results`, `attendance`, `pta`, `marketing`, `system`, `` | | `send_at` | string | null | no | Format date-time | | `repeat` | string | null | no | One of: `week`, `month`, `` | | `per_minute` | integer | null | no | Pace: messages per minute (default 60). | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/campaigns' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "name": "October fee reminders", "template": {"name": "school_fee_reminder", "language": "en_US"}, "parameters": { "parent": "field:name", "amount": "custom:fee_balance", "student": "custom:ward_name", "due_date": "text:30 October" }, "segment_id": 4, "topic": "fees" }' ``` ### Responses - **201** - The draft. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed or unknown_template. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get campaign `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/campaigns/{id}` A campaign with its results (`stats`). Definitions: `audience` - recipients fixed when the run started; `queued` / `skipped` (with `skip_reasons`) / `cancelled` - what PaalChat did with each; `sent`, `delivered`, `read`, `failed` - the messages' statuses now (delivered includes read); `replied` - recipients who wrote back on the same conversation within 72 hours of the send; rates are of sent messages, failure of sent plus failed; `estimated_meta_cost` - from Meta's pricing reports, an estimate. Ability: `campaigns.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/campaigns/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The campaign and its results. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Preview campaign `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/campaigns/{id}/preview` Before launching: how many contacts it will reach, how many will be skipped and why (`no_whatsapp`, `missing_parameter`, `recipient_suppressed`, `recipient_opted_out`, `recipient_preference_off`), the estimated Meta cost - "Meta bills your WhatsApp Business Account directly. PaalChat cost: included in your plan." - and the number's Meta messaging tier. Ability: `campaigns.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/campaigns/2/preview' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The preview. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - campaign_not_allowed (e.g. the template is gone). - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Launch campaign `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/campaigns/{id}/launch` Starts a draft: now (`sending`), or `scheduled` for `send_at` / its repeat. Each person goes through the usual checks - suppression, consent, preferences, limits, quiet hours - at the campaign's pace. Repeating campaigns start a run each time (a campaign with `parent_id`), with the audience as it is then. Ability: `campaigns.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/campaigns/2/launch' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Launched. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - campaign_not_allowed - not a draft, or its template is gone. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Pause, resume or cancel campaign `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/campaigns/{id}/{action}` `pause` stops sending after the current batch; `resume` carries on; `cancel` stops it for good - people not reached yet are marked cancelled and messages still held for quiet hours are withdrawn. Cancelling a repeating campaign stops its future runs. Ability: `campaigns.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | | `action` | string | yes | One of: `pause`, `resume`, `cancel` | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/campaigns/2/action' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Changed. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - campaign_not_allowed - e.g. resuming a campaign that is not paused. - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List automations `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/automations` The business's automations, by name, with how many runs are waiting. Ability: `automations.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/automations' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The automations. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create automation `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/automations` When `trigger` happens to a contact and the optional `conditions` (a segment condition tree) hold, run `steps` in order. See the [Automations guide](/docs/automations) for triggers and steps. The definition is checked when saved (templates must be APPROVED). Ability: `automations.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Max 191 characters | | `trigger` | string | yes | One of: `message_received`, `keyword`, `contact_created`, `conversation_status`, `date_reached` | | `trigger_config` | object | null | no | keyword: {keywords: [...]}; conversation_status: {status}; date_reached: {field: a date custom field, offset_days: -3 = three days before}. | | `conditions` | object | null | no | {"all": [...]} or {"any": [...]} of conditions {"field", "op", "value"} or nested groups (3 levels, 30 conditions). Fields: name, phone, email, locale, timezone, external_id, created_at, tag (has, not_has), consent. (eq granted\|revoked\|none), last_incoming_at (within_days, older_than_days), custom. (by the field's type). Ops: eq, neq, gt, gte, lt, lte, contains, starts_with, in, exists, not_exists, within_days, older_than_days. Example `{"all":[{"field":"custom.class","op":"eq","value":"SHS 2"},{"field":"custom.fee_balance","op":"gt","value":0}]}` | | `steps` | array of objects | yes | In order. send_template {template, language, parameters, category?, topic?}; send_text {text} (inside the 24-hour window); add_tag / remove_tag {tag}; set_status {status}; add_note {text}; notify {data} (automation.action callback); wait {minutes \| hours \| days}. Max 20 items | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/automations' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "name": "Chase unpaid fees", "trigger": "date_reached", "trigger_config": {"field": "fee_due_date", "offset_days": -3}, "conditions": {"all": [{"field": "custom.fee_balance", "op": "gt", "value": 0}]}, "steps": [ { "action": "send_template", "template": "school_fee_reminder", "language": "en_US", "parameters": { "parent": "field:name", "amount": "custom:fee_balance", "student": "custom:ward_name", "due_date": "custom:fee_due_date" } }, {"action": "wait", "days": 3}, { "action": "send_template", "template": "school_fee_reminder", "language": "en_US", "parameters": { "parent": "field:name", "amount": "custom:fee_balance", "student": "custom:ward_name", "due_date": "custom:fee_due_date" } }, {"action": "notify", "data": {"reason": "fees_overdue"}} ] }' ``` ### Responses - **201** - Saved and active. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get automation `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/automations/{id}` An automation and its run counts. Ability: `automations.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/automations/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The automation. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Update automation `PATCH https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/automations/{id}` Change any part, or `status` (active or paused). Pausing stops its waiting runs. Ability: `automations.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Body | Name | Type | Required | Description | |---|---|---|---| | `name` | string | yes | Max 191 characters | | `trigger` | string | yes | One of: `message_received`, `keyword`, `contact_created`, `conversation_status`, `date_reached` | | `trigger_config` | object | null | no | keyword: {keywords: [...]}; conversation_status: {status}; date_reached: {field: a date custom field, offset_days: -3 = three days before}. | | `conditions` | object | null | no | {"all": [...]} or {"any": [...]} of conditions {"field", "op", "value"} or nested groups (3 levels, 30 conditions). Fields: name, phone, email, locale, timezone, external_id, created_at, tag (has, not_has), consent. (eq granted\|revoked\|none), last_incoming_at (within_days, older_than_days), custom. (by the field's type). Ops: eq, neq, gt, gte, lt, lte, contains, starts_with, in, exists, not_exists, within_days, older_than_days. Example `{"all":[{"field":"custom.class","op":"eq","value":"SHS 2"},{"field":"custom.fee_balance","op":"gt","value":0}]}` | | `steps` | array of objects | yes | In order. send_template {template, language, parameters, category?, topic?}; send_text {text} (inside the 24-hour window); add_tag / remove_tag {tag}; set_status {status}; add_note {text}; notify {data} (automation.action callback); wait {minutes \| hours \| days}. Max 20 items | | `status` | string | no | One of: `active`, `paused` | ### Request ```bash curl --request PATCH \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/automations/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"status": "paused"}' ``` ### Responses - **200** - Updated. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Delete automation `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/automations/{id}` Deletes it with its runs. Ability: `automations.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/automations/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Deleted. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List automation runs `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/automations/{id}/runs` Its runs, newest first, 50 per page - per contact, with what each step did. Ability: `automations.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/automations/2/runs' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The runs. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Erase contact `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts/{id}` The right to be forgotten: removes the contact's name, number, email, your `external_id`, custom fields, preferences, tags and identities, the content of their messages, their media files and the notes on their conversations. What remains - message statuses, conversations, the consent history - points at a pseudonym. Cannot be undone. Their number stays on the suppression list if it is there. Ability: `data.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's contact ID. | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Erased. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get consent and preferences `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts/{id}/consents` The contact's consent per category (granted, revoked = opted out, or null) and their notification preferences. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts/2/consents' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Consent and preferences. ```json { "data": { "contact_id": 41, "consents": {"marketing": "granted", "notifications": null, "transactional": null}, "preferences": { "channels": {"whatsapp": true}, "topics": {"fees": true, "marketing": false} } } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Record consent `PUT https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts/{id}/consents/{category}` Record an opt-in you collected (`granted`) or an opt-out (`revoked`). Business-initiated templates in a revoked category are refused with `recipient_opted_out`. Every change is kept in the contact's consent history and announced as `contact.consent`. Contacts also opt out of marketing and notifications by replying STOP, and back in with START. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | | `category` | string | yes | One of: `marketing`, `notifications`, `transactional` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `status` | string | yes | One of: `granted`, `revoked` | | `source` | string | null | no | Where it came from, e.g. signup_form (default api). Max 24 characters | | `note` | string | null | no | Max 191 characters | ### Request ```bash curl --request PUT \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts/2/consents/category' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"status": "granted", "source": "signup_form"}' ``` ### Responses - **200** - The contact's consent and preferences now. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Set notification preferences `PUT https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts/{id}/preferences` What the contact wants: channels (`whatsapp`) and topics (`fees`, `results`, `attendance`, `pta`, `marketing`, `system`) on or off. Only the keys you send change. A template with a `topic` the contact turned off - or any template when WhatsApp is off - is refused with `recipient_preference_off`. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Body | Name | Type | Required | Description | |---|---|---|---| | `channels` | object | no | Example `{"whatsapp":true}` | | `topics` | object | no | fees, results, attendance, pta, marketing, system. Example `{"fees":true,"marketing":false}` | ### Request ```bash curl --request PUT \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts/2/preferences' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"topics": {"marketing": false}}' ``` ### Responses - **200** - The contact's consent and preferences now. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List suppressed numbers `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/suppressions` Numbers the business must not message first (newest first, up to 1,000). Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/suppressions' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The suppression list. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Suppress a number `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/suppressions` Business-initiated templates to this number are refused with `recipient_suppressed` until it is removed. Replies inside the 24-hour window are still possible. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `address` | string | yes | WhatsApp number. Pattern `^\+?[1-9]\d{6,14}$` | | `reason` | string | null | no | Max 191 characters | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/suppressions' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"address": "+233241234567", "reason": "Parent asked not to be contacted"}' ``` ### Responses - **201** - Added (200 when it was already there). - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Remove a suppressed number `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/suppressions/{address}` The business may message the number first again. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `address` | string | yes | | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/suppressions/address' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Removed. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Export business data `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/exports` Starts an export of everything PaalChat holds for the business - contacts (with consent and preferences), conversations with notes, messages with content, media details - as a ZIP of JSON Lines files. Poll [Get export](/docs/api/get-export) until `status` is `ready`, then download `url` (15 minutes; the file is kept for 24 hours). Ability: `data.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/exports' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **202** - Started. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get export `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/exports/{id}` An export's status and, once ready, a fresh 15-minute download link. Ability: `data.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/exports/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The export. ```json { "data": { "id": 3, "status": "ready", "size": 482113, "url": "https://whatsapp.paaltech.org/exports/3?expires=1759400000&signature=9a1f...", "expires_at": "2026-10-03T09:00:00+00:00", "error": null, "created_at": "2026-10-02T09:00:00+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Simulate an incoming message `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/sandbox/incoming` Test keys only. A customer writes to a connected sandbox business: the message goes through the live path - contact, conversation, inbox, `message.received` callback - and opens the 24-hour window for `from`. Ability: `messages.send` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `from` | string | yes | The customer's WhatsApp number (digits). Pattern `^[1-9][0-9]{6,14}$` | | `text` | string | yes | Max 4096 characters | | `profile_name` | string | null | no | | | `phone_number_id` | string | null | no | Which sandbox number receives it (when there are several). | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/sandbox/incoming' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "from": "233241234567", "text": "Is there school on Friday?", "profile_name": "Ama Mensah" }' ``` ### Responses - **201** - The stored incoming message. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, or sandbox_only (a live key). - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - sandbox_not_connected - connect the sandbox business first. - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Simulate a delivery status `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/sandbox/messages/{id}/status` Test keys only. Meta reports `delivered`, `read` or `failed` for a message you sent in the sandbox: it goes through the live path and fires `message.status`. Statuses only move forward, as live. Ability: `messages.send` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's message ID. | ### Body | Name | Type | Required | Description | |---|---|---|---| | `status` | string | yes | One of: `delivered`, `read`, `failed` | | `error` | object | null | no | For failed - the code and message to report. | | `error.code` | string | no | | | `error.message` | string | no | | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/sandbox/messages/2/status' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"status": "delivered"}' ``` ### Responses - **200** - The message, with its new status. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, or sandbox_only (a live key). - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - sandbox_not_sent - only sent outgoing messages get statuses. - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Sign staff in to the inbox `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/inbox/sign-in` Single sign-on into the PaalChat inbox for a staff member already signed in to your product. Send who they are and their role (`admin`, `agent` or `viewer`) - PaalChat records or updates the member - then redirect their browser to `url`. The link works once, within 5 minutes; ask for a new one each time. Pass `conversation_id` to open that conversation. PaalChat never holds staff passwords. Needs the inbox enabled for the business. Ability: `inbox.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `staff` | object | yes | | | `staff.external_id` | string | yes | Your id for the staff member. Max 191 characters | | `staff.name` | string | yes | Max 191 characters | | `staff.email` | string | null | no | Format email | | `staff.role` | string | yes | viewer reads; agent also replies, notes, tags and takes conversations; admin also assigns anyone. One of: `admin`, `agent`, `viewer` | | `conversation_id` | integer | null | no | Open this conversation after signing in. | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/inbox/sign-in' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "staff": { "external_id": "teacher-12", "name": "Kwame Owusu", "email": "owusu@presec.edu.gh", "role": "agent" }, "conversation_id": 7 }' ``` ### Responses - **201** - The one-time link. ```json { "data": { "url": "https://whatsapp.paaltech.org/inbox/sign-in/Xk3...64 chars", "expires_at": "2026-10-02T09:05:00+00:00", "member": { "external_id": "teacher-12", "name": "Kwame Owusu", "email": "owusu@presec.edu.gh", "role": "agent", "active": true, "last_seen_at": null } } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, or feature_disabled (the inbox is not enabled for the business). - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **409** - business_suspended. - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List inbox members `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/inbox/members` The business's staff who have signed in to the inbox, by name - with their role, whether they still have access, and when they were last seen. Ability: `inbox.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/inbox/members' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The members. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Remove inbox access `DELETE https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/inbox/members/{member_id}` A staff member left: their inbox access ends at once (an open inbox signs out on its next click) and their conversations become unassigned. Signing them in again restores access. Ability: `inbox.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `member_id` | string | yes | The staff member's external_id. | ### Request ```bash curl --request DELETE \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/inbox/members/member_id' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - Access removed. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Upload media `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/media` Uploads a file to send, as `multipart/form-data` with a `file` field (and an optional `filename`, shown for documents). The type is detected from the file itself: JPEG/PNG images (5 MB), MP4/3GP videos (16 MB), AAC/AMR/MP3/M4A/OGG audio (16 MB), and PDF, Word, Excel, PowerPoint or text documents (100 MB). Then send a message of that type with `media.id`. A file can be sent many times. Needs media enabled for the business. Example: `curl -F file=@receipt.pdf -H "Authorization: Bearer $TOKEN" .../businesses/presec/media`. Ability: `messages.send` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/media' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **201** - Stored, ready to send. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, or feature_disabled (media is not enabled for the business). ```json { "error": { "code": "feature_disabled", "message": "Media is not enabled for this business." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed, unsupported_media (WhatsApp cannot send this kind of file) or media_too_large. ```json { "error": { "code": "unsupported_media", "message": "WhatsApp cannot send text/html files." } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get media `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/media/{id}` A media file's details and a fresh signed `url` to its bytes (while `status` is `stored`). The link needs no token and expires after 15 minutes - fetch the media again for a new one; never store or share the link. Incoming attachments are `pending` until PaalChat has downloaded them from Meta (`media.updated` says when). Ability: `messages.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's media ID (`media_id` on a message, or `data.id` from Upload media). | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/media/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The media. ```json { "data": { "id": 12, "direction": "incoming", "status": "stored", "mime_type": "image/jpeg", "filename": null, "size": 48213, "sha256": "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4", "url": "https://whatsapp.paaltech.org/media/12?expires=1759242000&signature=3c1d...", "url_expires_at": "2026-10-02T09:15:00+00:00", "error": null, "created_at": "2026-10-02T08:59:58+00:00", "stored_at": "2026-10-02T09:00:01+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List contacts `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts` The business's contacts, newest first, 50 per page. Contacts are created automatically when someone messages the business or is messaged; you can also create them yourself. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | | `phone` | string | no | Exactly this number (digits, optional +). | | `external_id` | string | no | Your own id for the contact. | | `tag` | string | no | | | `search` | string | no | Part of the name. | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - A page of contacts. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Create or update contact `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts` Finds the contact by your `external_id`, else by `phone`, and updates it - or creates it. `custom_fields` must be fields defined for the business (see Define a contact field); `tags` replaces the contact's tags. Safe to repeat. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | null | no | Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `phone` | string | null | no | International number, digits with optional +. Needed when there is no external_id. | | `name` | string | null | no | Max 191 characters | | `email` | string | null | no | Format email | | `locale` | string | null | no | Max 16 characters | | `timezone` | string | null | no | IANA, e.g. Africa/Accra. | | `custom_fields` | object | no | Values for the business's fields; null clears one. | | `tags` | array of strings | no | Replaces the contact's tags. | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{ "external_id": "parent-77", "phone": "+233241234567", "name": "Ama Mensah", "custom_fields": {"class": "SHS 2"}, "tags": ["parent"] }' ``` ### Responses - **200** - Updated - an existing contact matched. - **201** - Created. ```json { "data": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567", "email": "ama@example.com", "locale": "en", "timezone": "Africa/Accra", "status": "active", "custom_fields": {"class": "SHS 2", "fee_balance": 450.5}, "tags": ["parent", "fees"], "identities": [ { "channel": "whatsapp", "address": "233241234567", "profile_name": "Ama Mensah", "last_seen_at": "2026-10-01T09:12:00+00:00" } ], "created_at": "2026-10-01T08:00:00+00:00", "updated_at": "2026-10-01T09:12:00+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get contact `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts/{id}` One contact with its identities (how it is reached), custom fields and tags. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's contact ID. | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The contact. ```json { "data": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567", "email": "ama@example.com", "locale": "en", "timezone": "Africa/Accra", "status": "active", "custom_fields": {"class": "SHS 2", "fee_balance": 450.5}, "tags": ["parent", "fees"], "identities": [ { "channel": "whatsapp", "address": "233241234567", "profile_name": "Ama Mensah", "last_seen_at": "2026-10-01T09:12:00+00:00" } ], "created_at": "2026-10-01T08:00:00+00:00", "updated_at": "2026-10-01T09:12:00+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Update contact `PATCH https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contacts/{id}` Change a contact's details, custom fields (`null` clears one) or tags (replaced). Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's contact ID. | ### Body | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | null | no | Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `phone` | string | null | no | International number, digits with optional +. Needed when there is no external_id. | | `name` | string | null | no | Max 191 characters | | `email` | string | null | no | Format email | | `locale` | string | null | no | Max 16 characters | | `timezone` | string | null | no | IANA, e.g. Africa/Accra. | | `custom_fields` | object | no | Values for the business's fields; null clears one. | | `tags` | array of strings | no | Replaces the contact's tags. | ### Request ```bash curl --request PATCH \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contacts/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"name": "Ama Mensah-Boateng", "custom_fields": {"fee_balance": 0}}' ``` ### Responses - **200** - The updated contact. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List contact fields `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contact-fields` The business's custom contact fields. Ability: `contacts.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contact-fields' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The fields. ```json { "data": [ { "key": "class", "label": "Class", "type": "choice", "choices": ["SHS 1", "SHS 2", "SHS 3"] }, { "key": "fee_balance", "label": "Fee balance (GHS)", "type": "number", "choices": null } ] } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Define a contact field `PUT https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/contact-fields/{key}` Create a custom contact field for the business, or change its label, type or choices. Ability: `contacts.write` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `key` | string | yes | Max 64 characters. Pattern `^[a-z][a-z0-9_]*$` | ### Body | Name | Type | Required | Description | |---|---|---|---| | `label` | string | yes | Max 191 characters | | `type` | string | yes | One of: `text`, `number`, `date`, `boolean`, `choice` | | `choices` | array of strings | null | no | Required for choice fields. | ### Request ```bash curl --request PUT \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/contact-fields/key' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"label": "Class", "type": "choice", "choices": ["SHS 1", "SHS 2", "SHS 3"]}' ``` ### Responses - **200** - Updated. - **201** - Created. ```json { "data": { "key": "class", "label": "Class", "type": "choice", "choices": ["SHS 1", "SHS 2", "SHS 3"] } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List conversations `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/conversations` The business's conversations, most recent activity first, 50 per page. A conversation is between one business number and one contact; messages join it as they come and go. Ability: `conversations.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | | `status` | string | no | One of: `open`, `pending`, `resolved`, `closed`, `snoozed` | | `contact_id` | integer | no | | | `unread` | boolean | no | Only conversations with unread messages. | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/conversations?status=APPROVED' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - A page of conversations. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Get conversation `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/conversations/{id}` One conversation with its contact, status, unread count and whether the 24-hour window is open. Ability: `conversations.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's conversation ID. | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/conversations/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The conversation. ```json { "data": { "id": 7, "contact": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567" }, "phone_number_id": "106540352242922", "status": "open", "priority": "normal", "unread_count": 1, "window_open": true, "last_message_at": "2026-10-01T09:12:00+00:00", "last_incoming_at": "2026-10-01T09:12:00+00:00", "snoozed_until": null, "assignee": null, "tags": ["fees"], "created_at": "2026-10-01T08:00:00+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Update conversation `PATCH https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/conversations/{id}` Work the conversation: change its `status` (snoozing needs `snoozed_until`), `priority` or `tags` (replaced), assign it to an inbox member (`assignee`: the staff member's `external_id`, or null to unassign), and send `read: true` when your staff have seen its messages. Changes are announced as `conversation.updated`. Ability: `conversations.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | PaalChat's conversation ID. | ### Body | Name | Type | Required | Description | |---|---|---|---| | `status` | string | no | open = needs the business; pending = waiting for the customer (a conversation the business started); an incoming message reopens pending, resolved and snoozed conversations; after closed, the next message starts a new conversation. One of: `open`, `pending`, `resolved`, `closed`, `snoozed` | | `snoozed_until` | string | null | no | Format date-time | | `priority` | string | no | One of: `low`, `normal`, `high`, `urgent` | | `tags` | array of strings | no | | | `read` | boolean | no | Always `1` | | `assignee` | string | null | no | An active inbox member's external_id (sign them in first), or null. | ### Request ```bash curl --request PATCH \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/conversations/2' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"status": "resolved", "tags": ["fees"], "read": true}' ``` ### Responses - **200** - The updated conversation. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed, or unknown_member (no active inbox member with that assignee). - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List conversation messages `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/conversations/{id}/messages` The conversation's messages, newest first, 50 per page, each with its `content`. Ability: `messages.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `page` | integer | no | Default `1` | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/conversations/2/messages' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - A page of messages. - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## List notes `GET https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/conversations/{id}/notes` The conversation's internal notes, newest first. Notes are never sent to the contact. Ability: `conversations.read` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Request ```bash curl --request GET \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/conversations/2/notes' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' ``` ### Responses - **200** - The notes. ```json { "data": [ { "id": 3, "body": "Parent promised to pay on Friday", "author": "Mrs Owusu (bursar)", "created_at": "2026-10-01T09:20:00+00:00" } ] } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Add note `POST https://whatsapp.paaltech.org/api/v1/businesses/{external_id}/conversations/{id}/notes` An internal note for the business's staff. Never sent to the contact; encrypted at rest. Ability: `conversations.manage` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `external_id` | string | yes | Your own ID for the business (Multi-SKUUL - the tenant ID). Max 191 characters. Pattern `^[A-Za-z0-9._:-]+$` | | `id` | integer | yes | | ### Body | Name | Type | Required | Description | |---|---|---|---| | `body` | string | yes | Max 4000 characters | | `author` | string | null | no | Who wrote it in your product. Max 191 characters | ### Request ```bash curl --request POST \ --url 'https://whatsapp.paaltech.org/api/v1/businesses/presec/conversations/2/notes' \ --header "Authorization: Bearer $PAALCHAT_TOKEN" \ --header 'Accept: application/json' \ --header 'Content-Type: application/json' \ --data '{"body": "Parent promised to pay on Friday", "author": "Mrs Owusu (bursar)"}' ``` ### Responses - **201** - Added. ```json { "data": { "id": 3, "body": "Parent promised to pay on Friday", "author": "Mrs Owusu (bursar)", "created_at": "2026-10-01T09:20:00+00:00" } } ``` - **401** - unauthenticated - missing, invalid, revoked or expired token ```json { "error": {"code": "unauthenticated", "message": "A valid product token is required."} } ``` - **403** - missing_ability, product_suspended or product_token_required ```json { "error": { "code": "missing_ability", "message": "This token does not have the ability this request needs." } } ``` - **404** - not_found - not yours, or does not exist ```json {"error": {"code": "not_found", "message": "Business not found."}} ``` - **422** - validation_failed - fields are invalid; see errors. ```json { "error": { "code": "validation_failed", "message": "The external id field format is invalid. (and 1 more error)" }, "errors": { "external_id": ["The external id field format is invalid."], "name": ["The name field is required."] } } ``` - **429** - rate_limited - over 300 requests/minute for this product ```json { "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the Retry-After header." } } ``` --- ## Callback: message.received `POST ` A customer sent the business a message. Sent once per WhatsApp message (`wamid`). `message` is Meta's message object, unchanged. PaalChat keeps it, encrypted, in the conversation history (365 days by default - see the FAQ). Also opens the 24-hour window for free-form replies to `from`. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.message_id` | integer | | | `data.wamid` | string | | | `data.contact_id` | integer | null | | | `data.conversation_id` | integer | null | | | `data.from` | string | | | `data.profile_name` | string | null | | | `data.phone_number_id` | string | null | | | `data.type` | string | | | `data.media_id` | integer | null | PaalChat's copy of the attachment (pending until media.updated); null without media, or when media is not enabled. | | `data.timestamp` | string | null | Unix seconds as a string (Meta's). | | `data.message` | object | Meta's message object, unchanged (text.body, button, interactive, image.id, context.id...). | ### Example ```json { "id": "7f0c2d9e-5a41-4f7b-9b0e-3c1d2e4f5a6b", "event": "message.received", "occurred_at": "2026-09-30T18:54:28+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "message_id": 57, "wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg", "from": "233241234567", "profile_name": "Ama Mensah", "phone_number_id": "106540352242922", "type": "text", "media_id": null, "timestamp": "1759240000", "message": { "from": "233241234567", "id": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAEhgg", "timestamp": "1759240000", "type": "text", "text": {"body": "Good morning. Is there school on Friday?"} } } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'message.received') { InboxMessage::firstOrCreate(['wamid' => $event['data']['wamid']], [ 'contact' => $event['data']['from'], 'body' => $event['data']['message']['text']['body'] ?? null, ]); } return response()->noContent(); } ``` --- ## Callback: message.status `POST ` An outgoing message moved to `sent`, `delivered`, `read`, `failed` or `unknown`. Carries your `reference`. Statuses only move forward - ignore updates that would move one back. `unknown` means Meta's answer to the send was lost (timeout or server error): it may or may not have reached the customer, and it is never sent again automatically. If Meta did send it, a later `sent`/`delivered`/`read` arrives for the same message. `pricing` is what Meta reports it charges for the message; Meta bills the business's WhatsApp Business Account directly, never PaalChat. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.message_id` | integer | | | `data.channel` | string | | | `data.reference` | string | null | | | `data.wamid` | string | null | The provider's message id (Meta's wamid; Arkesel's, Hubtel's or Resend's id for SMS and email). | | `data.contact_id` | integer | null | | | `data.conversation_id` | integer | null | | | `data.to` | string | | | `data.phone_number_id` | string | null | | | `data.status` | string | | | `data.previous_status` | string | Outgoing moves forward only (queued < sent < delivered < read); failed is final and never follows read. cancelled: a scheduled message withdrawn before it went. unknown follows queued when Meta's answer to the send was lost - it may still move to sent, delivered, read or failed if Meta reports the message. | | `data.error` | object | null | | | `data.error.code` | string | Meta's error code (e.g. 131026) or a platform code (connection_unavailable, no_message_id, circuit_open when Meta was failing for longer than the send's retries; for unknown: http_5xx or no_response). | | `data.error.message` | string | | | `data.pricing` | object | null | Meta's pricing for the message, from its status webhooks (null until Meta reports it, and for incoming messages). Meta bills the business's WhatsApp Business Account directly; PaalChat never charges for Meta messages. From 2026-10-01 replies inside the 24-hour window are billable too. | | `data.pricing.billable` | boolean | null | Whether Meta charges for this message. | | `data.pricing.category` | string | null | Meta's pricing category. | | `data.pricing.type` | string | null | regular (charged), free_customer_service or free_entry_point. | | `data.pricing.model` | string | null | Meta's pricing model: PMP (per message). | | `data.sent_at` | string | null | | | `data.delivered_at` | string | null | | | `data.read_at` | string | null | | | `data.failed_at` | string | null | | ### Example ```json { "id": "3b8e9f10-2c4d-4e6f-8a1b-2c3d4e5f6a7b", "event": "message.status", "occurred_at": "2026-09-30T18:54:33+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "message_id": 2, "reference": "skuul-msg-8812", "wamid": "wamid.HBgMMjMzMjQxMjM0NTY3FQIAERgSQ0Q", "to": "233241234567", "phone_number_id": "106540352242922", "status": "delivered", "previous_status": "sent", "error": null, "pricing": {"billable": true, "category": "utility", "type": "regular", "model": "PMP"}, "sent_at": "2026-09-30T18:54:31+00:00", "delivered_at": "2026-09-30T18:54:33+00:00", "read_at": null, "failed_at": null } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'message.status') { Notification::where('id', $event['data']['reference'])->first()?->advanceTo($event['data']['status'], $event['data']['error']); } return response()->noContent(); } ``` --- ## Callback: template.status `POST ` Meta reviewed or changed one of the business's templates (approved, rejected with a reason, paused...). ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.waba_id` | string | | | `data.name` | string | | | `data.language` | string | | | `data.status` | string | | | `data.previous_status` | string | null | | | `data.reason` | string | null | | ### Example ```json { "id": "9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "event": "template.status", "occurred_at": "2026-09-30T19:02:10+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "waba_id": "102290129340398", "name": "fees_reminder", "language": "en_US", "status": "REJECTED", "previous_status": "PENDING", "reason": "INCORRECT_CATEGORY" } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'template.status') { Cache::forget('paalchat:templates:'.$event['business']['external_id']); } return response()->noContent(); } ``` --- ## Callback: whatsapp.connection `POST ` Something changed about the business's WhatsApp connection. `event` is one of `connected`, `setup_failed`, `token_expiring` (now `degraded`), `recovered`, `paused`, `resumed`, `suspended`, `revoked`, `disconnected`, `released` (an operator freed the disconnected account; it no longer belongs to this business), or a Meta account or messaging-limit notice (e.g. `ACCOUNT_VIOLATION`, `UPGRADE`). `status` is the account's state afterwards (see ConnectionStatus). ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.waba_id` | string | | | `data.event` | string | connected, setup_failed, token_expiring, recovered, paused, resumed, suspended, revoked, disconnected, released, or a Meta account/limit event name (e.g. ACCOUNT_VIOLATION, UPGRADE). | | `data.status` | string | connected and degraded can send. pending = created or setup failed; connecting = signup in progress; degraded = working with a warning (token expiring, low quality); maintenance = paused by PaalChat operators (sends get 409 connection_paused); suspended = Meta disabled the account; revoked = the credential stopped working or access was removed at Meta; disconnected = disconnected on purpose. pending, revoked and disconnected need a new connect link. | ### Example ```json { "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "event": "whatsapp.connection", "occurred_at": "2026-09-30T18:40:02+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": {"waba_id": "102290129340398", "event": "connected", "status": "connected"} } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'whatsapp.connection') { Tenant::find($event['business']['external_id'])?->update(['whatsapp_status' => $event['data']['status']]); } return response()->noContent(); } ``` --- ## Callback: contact.created `POST ` A contact was created - by its first message in or out, or by you through the API. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.contact` | object | | | `data.contact.id` | integer | | | `data.contact.external_id` | string | null | Your own id for the person. | | `data.contact.name` | string | null | | | `data.contact.phone` | string | null | WhatsApp number, digits. | | `data.contact.email` | string | null | | | `data.contact.locale` | string | null | | | `data.contact.timezone` | string | null | | | `data.contact.status` | string | | | `data.contact.custom_fields` | object | The business's own fields (see List contact fields). | | `data.contact.preferences` | object | What the contact wants to receive - false turns it off; missing means on. | | `data.contact.preferences.channels` | object | | | `data.contact.preferences.topics` | object | fees, results, attendance, pta, marketing, system. | | `data.contact.tags` | array of strings | | | `data.contact.identities` | array of objects | | | `data.contact.identities[].channel` | string | | | `data.contact.identities[].address` | string | | | `data.contact.identities[].profile_name` | string | null | The name the person shows on that channel. | | `data.contact.identities[].last_seen_at` | string | null | | | `data.contact.created_at` | string | | | `data.contact.updated_at` | string | | ### Example ```json { "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "event": "contact.created", "occurred_at": "2026-10-01T09:12:01+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "contact": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567", "email": null, "locale": null, "timezone": null, "status": "active", "custom_fields": {"class": "SHS 2"}, "tags": ["parent"], "identities": [ { "channel": "whatsapp", "address": "233241234567", "profile_name": "Ama Mensah", "last_seen_at": "2026-10-01T09:12:00+00:00" } ], "created_at": "2026-10-01T08:00:00+00:00", "updated_at": "2026-10-01T09:12:00+00:00" } } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'contact.created') { Guardian::linkPaalChatContact($event['business']['external_id'], $event['data']['contact']['id'], $event['data']['contact']['phone']); } return response()->noContent(); } ``` --- ## Callback: contact.updated `POST ` A contact's details, custom fields or tags changed. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.contact` | object | | | `data.contact.id` | integer | | | `data.contact.external_id` | string | null | Your own id for the person. | | `data.contact.name` | string | null | | | `data.contact.phone` | string | null | WhatsApp number, digits. | | `data.contact.email` | string | null | | | `data.contact.locale` | string | null | | | `data.contact.timezone` | string | null | | | `data.contact.status` | string | | | `data.contact.custom_fields` | object | The business's own fields (see List contact fields). | | `data.contact.preferences` | object | What the contact wants to receive - false turns it off; missing means on. | | `data.contact.preferences.channels` | object | | | `data.contact.preferences.topics` | object | fees, results, attendance, pta, marketing, system. | | `data.contact.tags` | array of strings | | | `data.contact.identities` | array of objects | | | `data.contact.identities[].channel` | string | | | `data.contact.identities[].address` | string | | | `data.contact.identities[].profile_name` | string | null | The name the person shows on that channel. | | `data.contact.identities[].last_seen_at` | string | null | | | `data.contact.created_at` | string | | | `data.contact.updated_at` | string | | ### Example ```json { "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "event": "contact.updated", "occurred_at": "2026-10-01T09:12:01+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "contact": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567", "email": null, "locale": null, "timezone": null, "status": "active", "custom_fields": {"class": "SHS 2"}, "tags": ["parent"], "identities": [ { "channel": "whatsapp", "address": "233241234567", "profile_name": "Ama Mensah", "last_seen_at": "2026-10-01T09:12:00+00:00" } ], "created_at": "2026-10-01T08:00:00+00:00", "updated_at": "2026-10-01T09:12:00+00:00" } } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'contact.updated') { Guardian::syncFromPaalChat($event['data']['contact']); } return response()->noContent(); } ``` --- ## Callback: conversation.created `POST ` A conversation started - open when the contact wrote first, pending when the business did. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.change` | string | created, reopened, status_changed, priority_changed, assigned, unassigned, tag_added or tag_removed. | | `data.conversation` | object | | | `data.conversation.id` | integer | | | `data.conversation.contact` | object | | | `data.conversation.contact.id` | integer | | | `data.conversation.contact.external_id` | string | null | | | `data.conversation.contact.name` | string | null | | | `data.conversation.contact.phone` | string | null | | | `data.conversation.phone_number_id` | string | null | The business number it happens on. | | `data.conversation.status` | string | open = needs the business; pending = waiting for the customer (a conversation the business started); an incoming message reopens pending, resolved and snoozed conversations; after closed, the next message starts a new conversation. | | `data.conversation.priority` | string | | | `data.conversation.unread_count` | integer | | | `data.conversation.window_open` | boolean | Free-form replies are possible (the contact wrote in the last 24 hours). | | `data.conversation.last_message_at` | string | null | | | `data.conversation.last_incoming_at` | string | null | | | `data.conversation.snoozed_until` | string | null | | | `data.conversation.assignee` | object | null | The inbox member working it. | | `data.conversation.assignee.external_id` | string | | | `data.conversation.assignee.name` | string | | | `data.conversation.tags` | array of strings | | | `data.conversation.created_at` | string | | ### Example ```json { "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "event": "conversation.created", "occurred_at": "2026-10-01T09:12:01+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "change": "created", "conversation": { "id": 7, "contact": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567" }, "phone_number_id": "106540352242922", "status": "resolved", "priority": "normal", "unread_count": 0, "window_open": true, "last_message_at": "2026-10-01T09:12:00+00:00", "last_incoming_at": "2026-10-01T09:12:00+00:00", "snoozed_until": null, "assignee": null, "tags": ["fees"], "created_at": "2026-10-01T08:00:00+00:00" } } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'conversation.created') { InboxConversation::firstOrCreate(['paalchat_id' => $event['data']['conversation']['id']], ['status' => $event['data']['conversation']['status']]); } return response()->noContent(); } ``` --- ## Callback: conversation.updated `POST ` A conversation's status, priority, tags or assignee changed, or the contact reopened it (change says which). New messages alone arrive as message.received and message.status, not here. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.change` | string | created, reopened, status_changed, priority_changed, assigned, unassigned, tag_added or tag_removed. | | `data.conversation` | object | | | `data.conversation.id` | integer | | | `data.conversation.contact` | object | | | `data.conversation.contact.id` | integer | | | `data.conversation.contact.external_id` | string | null | | | `data.conversation.contact.name` | string | null | | | `data.conversation.contact.phone` | string | null | | | `data.conversation.phone_number_id` | string | null | The business number it happens on. | | `data.conversation.status` | string | open = needs the business; pending = waiting for the customer (a conversation the business started); an incoming message reopens pending, resolved and snoozed conversations; after closed, the next message starts a new conversation. | | `data.conversation.priority` | string | | | `data.conversation.unread_count` | integer | | | `data.conversation.window_open` | boolean | Free-form replies are possible (the contact wrote in the last 24 hours). | | `data.conversation.last_message_at` | string | null | | | `data.conversation.last_incoming_at` | string | null | | | `data.conversation.snoozed_until` | string | null | | | `data.conversation.assignee` | object | null | The inbox member working it. | | `data.conversation.assignee.external_id` | string | | | `data.conversation.assignee.name` | string | | | `data.conversation.tags` | array of strings | | | `data.conversation.created_at` | string | | ### Example ```json { "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", "event": "conversation.updated", "occurred_at": "2026-10-01T09:12:01+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "change": "status_changed", "conversation": { "id": 7, "contact": { "id": 41, "external_id": "parent-77", "name": "Ama Mensah", "phone": "233241234567" }, "phone_number_id": "106540352242922", "status": "resolved", "priority": "normal", "unread_count": 0, "window_open": true, "last_message_at": "2026-10-01T09:12:00+00:00", "last_incoming_at": "2026-10-01T09:12:00+00:00", "snoozed_until": null, "assignee": null, "tags": ["fees"], "created_at": "2026-10-01T08:00:00+00:00" } } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'conversation.updated') { InboxConversation::where('paalchat_id', $event['data']['conversation']['id'])->update(['status' => $event['data']['conversation']['status']]); } return response()->noContent(); } ``` --- ## Callback: media.updated `POST ` An incoming attachment is ready - PaalChat downloaded it from Meta and stored it (`status: stored`, with a 15-minute signed `url`) - or it could not be fetched (`status: failed`, with `error`). Follows the message's `message.received`, which carries its `media_id`. Only for businesses with media enabled. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.media` | object | | | `data.media.id` | integer | | | `data.media.direction` | string | incoming: a customer's attachment; outgoing: your upload. | | `data.media.status` | string | pending: being fetched from Meta; deleted: removed after the retention period. | | `data.media.mime_type` | string | null | | | `data.media.filename` | string | null | | | `data.media.size` | integer | null | Bytes. | | `data.media.sha256` | string | null | Hex SHA-256 of the file. | | `data.media.url` | string | null | Signed link to the bytes (stored only). No token needed; expires at url_expires_at. | | `data.media.url_expires_at` | string | null | | | `data.media.error` | string | null | Why the file could not be fetched. | | `data.media.created_at` | string | | | `data.media.stored_at` | string | null | | | `data.message_id` | integer | null | The incoming message the file came with. | | `data.conversation_id` | integer | null | | ### Example ```json { "id": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e", "event": "media.updated", "occurred_at": "2026-10-02T09:00:01+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "message_id": 58, "conversation_id": 7, "media": { "id": 12, "direction": "incoming", "status": "stored", "mime_type": "image/jpeg", "filename": null, "size": 48213, "sha256": "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4", "url": "https://whatsapp.paaltech.org/media/12?expires=1759242000&signature=3c1d...", "url_expires_at": "2026-10-02T09:15:00+00:00", "error": null, "created_at": "2026-10-02T08:59:58+00:00", "stored_at": "2026-10-02T09:00:01+00:00" } } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'media.updated') { if ($event['data']['media']['status'] === 'stored') { Attachment::fetchFromPaalChat($event['data']['message_id'], $event['data']['media']['url']); } } return response()->noContent(); } ``` --- ## Callback: contact.consent `POST ` A contact opted in or out of a category - by replying STOP or START (`source: keyword`), or through the API. After a STOP, business-initiated marketing and notification templates to them are refused; update your own records too. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.contact_id` | integer | | | `data.contact_external_id` | string | null | | | `data.phone` | string | null | | | `data.category` | string | | | `data.status` | string | | | `data.source` | string | keyword (the contact replied STOP or START), api, inbox, or the source you sent. | ### Example ```json { "id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d", "event": "contact.consent", "occurred_at": "2026-10-02T19:04:11+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "contact_id": 41, "contact_external_id": "parent-77", "phone": "233241234567", "category": "marketing", "status": "revoked", "source": "keyword" } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'contact.consent') { Guardian::where('phone', $event['data']['phone'])->update(['whatsapp_'.$event['data']['category'] => $event['data']['status'] === 'granted']); } return response()->noContent(); } ``` --- ## Callback: campaign.updated `POST ` A campaign was scheduled, started sending, paused, resumed, completed or cancelled. Final `counts` arrive with completed and cancelled; read results with Get campaign. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.campaign_id` | integer | | | `data.parent_id` | integer | null | | | `data.name` | string | | | `data.status` | string | | | `data.counts` | object | null | | | `data.started_at` | string | null | | | `data.completed_at` | string | null | | ### Example ```json { "id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "event": "campaign.updated", "occurred_at": "2026-10-06T08:41:10+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "campaign_id": 12, "parent_id": null, "name": "October fee reminders", "status": "completed", "counts": {"audience": 412, "queued": 398, "skipped": 14, "cancelled": 0}, "started_at": "2026-10-06T08:00:01+00:00", "completed_at": "2026-10-06T08:41:10+00:00" } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'campaign.updated') { Broadcast::where('paalchat_campaign_id', $event['data']['campaign_id'])->update(['status' => $event['data']['status']]); } return response()->noContent(); } ``` --- ## Callback: automation.action `POST ` An automation reached a `notify` step for a contact - your product's turn to act (create a follow-up, alert the bursar...). `data` is what you put on the step. ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.automation_id` | integer | | | `data.run_id` | integer | | | `data.contact_id` | integer | | | `data.contact_external_id` | string | null | | | `data.conversation_id` | integer | null | | | `data.data` | object | The notify step's own data. | ### Example ```json { "id": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b", "event": "automation.action", "occurred_at": "2026-11-02T08:00:04+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "automation_id": 3, "run_id": 881, "contact_id": 41, "contact_external_id": "parent-77", "conversation_id": 7, "data": {"reason": "fees_overdue"} } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'automation.action') { FollowUp::create(['paalchat_contact_id' => $event['data']['contact_id'], 'details' => $event['data']['data']]); } return response()->noContent(); } ``` --- ## Callback: notification.updated `POST ` A notification was delivered on one of its channels, or every channel failed (`attempts` says what happened on each). ### Headers | Name | Description | |---|---| | `X-PaalChat-Event` | | | `X-PaalChat-Delivery` | Unique per event (equals body `id`). De-duplicate on it. | | `X-PaalChat-Signature` | `t=,v1=.")>`. Reject if invalid or older than 300 seconds. | ### Body | Field | Type | Description | |---|---|---| | `id` | string | Equals X-PaalChat-Delivery. | | `sandbox` | boolean | true for sandbox businesses (test keys) - nothing reached WhatsApp. | | `event` | any | | | `occurred_at` | string | | | `business` | object | | | `business.external_id` | string | | | `data` | object | | | `data.notification_id` | integer | | | `data.reference` | string | null | | | `data.status` | string | | | `data.channel` | string | null | | | `data.attempts` | array of objects | | ### Example ```json { "id": "3f4a5b6c-7d8e-4f9a-0b1c-2d3e4f5a6b7c", "event": "notification.updated", "occurred_at": "2026-10-02T11:04:20+00:00", "business": {"external_id": "presec"}, "sandbox": false, "data": { "notification_id": 77, "reference": "closing-2026-10-02", "status": "delivered", "channel": "sms", "attempts": [ { "channel": "whatsapp", "outcome": "failed: 131026", "message_id": 901, "at": "2026-10-02T11:00:02+00:00" }, { "channel": "sms", "outcome": "sent", "message_id": 902, "at": "2026-10-02T11:00:03+00:00" } ] } } ``` ### Verify and handle (PHP) ```php // routes/api.php: Route::post('/paalchat/callback', PaalChatCallbackController::class); public function __invoke(Request $request) { $header = (string) $request->header('X-PaalChat-Signature'); $raw = $request->getContent(); if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $m) || abs(time() - (int) $m[1]) > 300 || ! hash_equals(hash_hmac('sha256', $m[1].'.'.$raw, config('services.paalchat.callback_secret')), $m[2])) { abort(401); } // Process each delivery once. if (! Cache::add('paalchat:'.$request->header('X-PaalChat-Delivery'), true, now()->addDays(2))) { return response()->noContent(); } $event = json_decode($raw, true); if ($event['event'] === 'notification.updated') { Alert::where('reference', $event['data']['reference'])->update(['status' => $event['data']['status'], 'channel' => $event['data']['channel']]); } return response()->noContent(); } ```