Common workflows
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
| 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 |
reference |
strongly recommended | Your own ID for the message - see below |
phone_number_id |
only with several numbers | Which number to send from |
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."}}'
$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.
}
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 */ }
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:
{
"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.
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
callback with status: failed and Meta's error.
Tracking delivery
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 callbacks; use
GET .../messages/{id} 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.
Several numbers
If a business has more than one connected number, list them with
GET .../whatsapp/phones, let the customer pick a
default, and pass its phone_number_id on every send.