# 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.
