> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# WhatsApp

Jelliu agents answer and start conversations on WhatsApp Business. The channel runs on the WhatsApp Business Platform through Twilio, with one sender (a business phone number) per workspace. Everything a contact writes to that number is routed to one of your agents, and everything you send goes through the same compliance, opt-out and quota checks as the rest of the platform.

This page covers the API surface: senders, templates, sending, what happens to inbound messages, and the limits that apply.

## How it works

```mermaid
sequenceDiagram
    autonumber
    participant App as Your integration
    participant J as Jelliu API
    participant M as WhatsApp (Meta via Twilio)
    participant C as Contact

    App->>J: POST /api/whatsapp-senders (register number)
    J->>M: Create sender
    Note over J,M: Status polled about every 2 minutes<br />CREATING, VERIFYING, ONLINE
    App->>J: POST /api/whatsapp-templates, then /submit
    J->>M: Request template approval
    Note over J,M: draft, pending, approved or rejected
    App->>J: POST /api/whatsapp/send (templateId)
    J->>M: Template message
    M->>C: Delivered
    C->>M: Reply
    M->>J: Inbound webhook
    J->>J: Opt-out check, route to agent
    J->>M: Agent reply (freeform, window open)
```

Three rules shape every integration:

1. **Nothing is sent without an `ONLINE` sender.** Sends from a workspace whose number is still being approved, or is offline, are refused with an explanation.
2. **Meta's 24-hour customer-care window decides the message form.** Freeform text is allowed only if the contact wrote to your number in the last 24 hours. Outside that window only an approved template can be sent.
3. **An opt-out is final.** When a contact asks to stop, Jelliu suppresses the number and sends nothing more, not even a confirmation.

## Senders

A sender is the WhatsApp Business phone number your workspace messages from. Replies are sent from the line the contact last wrote to, when that line is still online; otherwise from your most recently updated online sender.

### Endpoints

| Method   | Path                                       | API key scope |
| -------- | ------------------------------------------ | ------------- |
| `GET`    | `/api/whatsapp-senders`                    | `read`        |
| `GET`    | `/api/whatsapp-senders/{senderId}`         | `read`        |
| `GET`    | `/api/whatsapp-senders/verticals`          | `read`        |
| `GET`    | `/api/whatsapp-senders/adoptable`          | `read`        |
| `POST`   | `/api/whatsapp-senders`                    | `full`        |
| `POST`   | `/api/whatsapp-senders/{senderId}/verify`  | `full`        |
| `PATCH`  | `/api/whatsapp-senders/{senderId}`         | `full`        |
| `POST`   | `/api/whatsapp-senders/{senderId}/refresh` | `full`        |
| `POST`   | `/api/whatsapp-senders/adopt`              | `full`        |
| `DELETE` | `/api/whatsapp-senders/{senderId}`         | `full`        |

Every response wraps the result in `data`. `DELETE` returns `204` with no body.

### Sender lifecycle

| Status      | Meaning                                                          |
| ----------- | ---------------------------------------------------------------- |
| `CREATING`  | Registration was accepted and is being set up.                   |
| `VERIFYING` | Waiting for the one-time code sent to the number, or for review. |
| `ONLINE`    | Can send and receive.                                            |
| `OFFLINE`   | Registered but not usable. `status_reason` says why.             |
| `FAILED`    | Registration did not complete.                                   |

Twilio does not push sender status changes, so Jelliu polls them about every two minutes and also re-checks senders that are already `ONLINE`, because Meta can take a number offline for quality reasons. Call `POST /api/whatsapp-senders/{senderId}/refresh` to re-read the status immediately.

`status_reason` is written for the person who has to fix it and may be in Spanish. For example, a number still registered on another WhatsApp account reports: `Este número sigue registrado en otra cuenta de WhatsApp. Elimina esa cuenta en el teléfono (Ajustes → Cuenta → Eliminar cuenta) o migra el número, espera unos minutos y vuelve a intentarlo.`

### The sender object

| Field                      | Type           | Description                                               |
| -------------------------- | -------------- | --------------------------------------------------------- |
| `id`                       | uuid           | Sender ID used in the paths above.                        |
| `phone_number`             | string         | E.164 number, for example `+573001234567`.                |
| `status`                   | string         | One of the lifecycle statuses.                            |
| `status_reason`            | string or null | Why the sender is not online, when known.                 |
| `display_name`             | string or null | Business name shown in the chat header.                   |
| `about_text`               | string or null | Profile "about" text.                                     |
| `waba_id`                  | string         | The WhatsApp Business Account the sender belongs to.      |
| `twilio_sender_sid`        | string or null | Provider identifier, useful for support.                  |
| `profile`, `configuration` | object or null | Business profile and provider configuration as last read. |
| `created_at`, `updated_at` | ISO 8601       | Timestamps.                                               |

### Register a number

**`phoneNumber`** `string` — required

E.164 format: `+`, a country digit 1 to 9, then up to 14 more digits.

---

**`verificationMethod`** `string`

`sms` or `voice`: how the one-time code is delivered to the number.

---

**`wabaId`** `string`

Your own WhatsApp Business Account ID, up to 64 characters. Omit it to register under the account connected to the platform.

---

**`phoneNumberId`** `string`

Meta's ID for the number inside that account, up to 64 characters. Recorded for support only.

---

**`displayName`** `string`

1 to 120 characters.

---

**`aboutText`** `string`

Up to 139 characters.

---

**`website`** `string`

A URL, up to 256 characters.

---

**`email`** `string`

An email address, up to 128 characters.

---

**`address`** `string`

Up to 256 characters.

---

**`description`** `string`

Up to 256 characters.

---

**`vertical`** `string`

One of Meta's fixed business categories. `GET /api/whatsapp-senders/verticals` returns the list: `Automotive`, `Beauty, Spa and Salon`, `Clothing and Apparel`, `Education`, `Entertainment`, `Event Planning and Service`, `Finance and Banking`, `Food and Grocery`, `Public Service`, `Hotel and Lodging`, `Medical and Health`, `Non-profit`, `Professional Services`, `Shopping and Retail`, `Travel and Transportation`, `Restaurant`, `Other`.

---

**`logoUrl`** `string`

A URL, up to 512 characters.

---

#### Create the sender

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/whatsapp-senders" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+573001234567",
    "verificationMethod": "sms",
    "displayName": "Clínica Norte",
    "vertical": "Medical and Health",
    "website": "https://clinicanorte.example.com"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/whatsapp-senders', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    phoneNumber: '+573001234567',
    verificationMethod: 'sms',
    displayName: 'Clínica Norte',
    vertical: 'Medical and Health',
    website: 'https://clinicanorte.example.com',
  }),
});
const { data: sender } = await res.json();
console.log(sender.id, sender.status);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/whatsapp-senders",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "phoneNumber": "+573001234567",
        "verificationMethod": "sms",
        "displayName": "Clínica Norte",
        "vertical": "Medical and Health",
        "website": "https://clinicanorte.example.com",
    },
    timeout=30,
)
res.raise_for_status()
sender = res.json()["data"]
print(sender["id"], sender["status"])
```

The response is `201` with the sender, typically in `CREATING` or `VERIFYING`.

#### Submit the one-time code

When the code arrives on the number, send it as `code` (3 to 10 digits):

```bash
curl -sS -X POST "https://api.jelliu.co/api/whatsapp-senders/$SENDER_ID/verify" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "482913" }'
```

#### Wait for ONLINE

Poll `GET /api/whatsapp-senders/{senderId}` (or call `/refresh`) until `status` is `ONLINE`. Inbound messages are routed to your workspace only once the sender is online.

Do not delete an `ONLINE` sender to "test reconnecting". Removing it at the provider does not remove the number from the WhatsApp Business Account, and registering it again can be blocked by Meta, for example when two-step verification is enabled on the number in WhatsApp Manager.

### Claim an existing sender

If a number is already registered as a WhatsApp sender with the provider, `POST /api/whatsapp-senders/adopt` with `{ "phoneNumber": "+573001234567" }` binds it to your workspace. `GET /api/whatsapp-senders/adoptable` lists candidates. The number must already be an active number of your workspace; otherwise the claim is refused with `403`: `+573001234567 is not one of this account's numbers, so it cannot be claimed as a WhatsApp sender.`

### Update and remove

`PATCH /api/whatsapp-senders/{senderId}` accepts any of the profile fields above (at least one). It fails with `400` while the sender is not yet registered with the provider.

`DELETE /api/whatsapp-senders/{senderId}` removes the sender at the provider first. If the provider refuses, nothing is changed and you can retry.

## Templates

A template is a message Meta has approved in advance. It is the only way to write to someone who has not messaged your number in the last 24 hours, and it also works inside the window.

### Endpoints

| Method   | Path                                             | API key scope |
| -------- | ------------------------------------------------ | ------------- |
| `GET`    | `/api/whatsapp-templates`                        | `read`        |
| `GET`    | `/api/whatsapp-templates/{templateId}`           | `read`        |
| `GET`    | `/api/whatsapp-templates/{templateId}/preview`   | `read`        |
| `POST`   | `/api/whatsapp-templates/{templateId}/preview`   | `write`       |
| `POST`   | `/api/whatsapp-templates`                        | `full`        |
| `PATCH`  | `/api/whatsapp-templates/{templateId}`           | `full`        |
| `POST`   | `/api/whatsapp-templates/{templateId}/submit`    | `full`        |
| `POST`   | `/api/whatsapp-templates/{templateId}/refresh`   | `full`        |
| `POST`   | `/api/whatsapp-templates/{templateId}/test-send` | `full`        |
| `DELETE` | `/api/whatsapp-templates/{templateId}`           | `full`        |
| `GET`    | `/api/whatsapp-templates/adoptable`              | `read`        |
| `POST`   | `/api/whatsapp-templates/adopt`                  | `full`        |
| `POST`   | `/api/whatsapp-templates/meta/list`              | `full`        |
| `POST`   | `/api/whatsapp-templates/meta/import`            | `full`        |

### Categories

| Category         | Use it for                                                            |
| ---------------- | --------------------------------------------------------------------- |
| `UTILITY`        | Default. Transactional follow-ups: confirmations, reminders, updates. |
| `MARKETING`      | Promotions and re-engagement.                                         |
| `AUTHENTICATION` | One-time codes.                                                       |

### Approval states

| Status     | Meaning                                                         | Editable | Sendable |
| ---------- | --------------------------------------------------------------- | -------- | -------- |
| `draft`    | Created, not submitted.                                         | Yes      | No       |
| `pending`  | Submitted and waiting for Meta.                                 | No       | No       |
| `approved` | Approved by Meta.                                               | No       | Yes      |
| `rejected` | Rejected. `rejection_reason` explains why, when Meta gives one. | Yes      | No       |
| `paused`   | Paused by Meta, usually for quality.                            | No       | No       |
| `disabled` | Disabled by Meta.                                               | No       | No       |

Approval results are polled from the provider about every two minutes, including for templates already approved, because Meta can pause or disable them later. `POST /api/whatsapp-templates/{templateId}/refresh` re-reads the state on demand.

### The template object

| Field                         | Type             | Description                                                                                                                   |
| ----------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id`                          | uuid             | Template ID, used as `templateId` when sending.                                                                               |
| `name`                        | string           | Immutable identifier: lowercase letters, digits and underscores, up to 120 characters. Unique per language in your workspace. |
| `friendly_name`               | string or null   | Display label you can rename.                                                                                                 |
| `language`                    | string           | Language tag, for example `es`, `en` or `pt-BR`.                                                                              |
| `category`                    | string           | `UTILITY`, `MARKETING` or `AUTHENTICATION`.                                                                                   |
| `body_text`                   | string           | The message body, with numbered placeholders.                                                                                 |
| `variables`                   | string\[]        | One label per placeholder, in order.                                                                                          |
| `status`                      | string           | Approval state.                                                                                                               |
| `rejection_reason`            | string or null   | Meta's reason, when rejected.                                                                                                 |
| `twilio_content_sid`          | string or null   | Provider content ID, set when submitted.                                                                                      |
| `submitted_at`, `approved_at` | ISO 8601 or null | When it was submitted and approved.                                                                                           |
| `created_at`, `updated_at`    | ISO 8601         | Timestamps.                                                                                                                   |

### Variables

Placeholders are written `{{1}}`, `{{2}}` and so on. `variables` declares one label per placeholder, so a body with two placeholders needs exactly two labels. Before a template is created, edited or submitted, Jelliu checks the body against the rules Meta rejects on, so you find out in seconds rather than after days of review:

* numbering starts at `{{1}}` and has no gaps;
* the body is not only a placeholder;
* two placeholders are never adjacent (`{{1}} {{2}}`);
* the body does not start or end with a placeholder;
* the number of labels matches the number of placeholders.

A body that breaks a rule is refused with `400` and code `VALIDATION_FAILED`, and the message names the rule, in Spanish. For example:

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "WhatsApp rechazaria esta plantilla: El mensaje no puede TERMINAR con una variable: pon texto después de la última variable."
  }
}
```

**`name`** `string` — required

Matches `^[a-z0-9_]{1,120}$`.

---

**`bodyText`** `string` — required

1 to 1024 characters.

---

**`language`** `string` — default: es

2 to 16 characters.

---

**`category`** `string` — default: UTILITY

`AUTHENTICATION`, `MARKETING` or `UTILITY`.

---

**`variables`** `string[]` — default: \[]

Up to 20 labels, each 1 to 50 characters.

---

**`friendlyName`** `string`

Up to 200 characters.

---

### Create, submit and preview a template

#### Create a draft

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/whatsapp-templates" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "recordatorio_cita",
    "friendlyName": "Recordatorio de cita",
    "language": "es",
    "category": "UTILITY",
    "bodyText": "Hola {{1}}, te recordamos tu cita del {{2}} en Clínica Norte. Responde a este mensaje si necesitas cambiarla.",
    "variables": ["nombre", "fecha"]
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/whatsapp-templates', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'recordatorio_cita',
    friendlyName: 'Recordatorio de cita',
    language: 'es',
    category: 'UTILITY',
    bodyText:
      'Hola {{1}}, te recordamos tu cita del {{2}} en Clínica Norte. Responde a este mensaje si necesitas cambiarla.',
    variables: ['nombre', 'fecha'],
  }),
});
const { data: template } = await res.json();
console.log(template.id, template.status); // "draft"
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/whatsapp-templates",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "name": "recordatorio_cita",
        "friendlyName": "Recordatorio de cita",
        "language": "es",
        "category": "UTILITY",
        "bodyText": "Hola {{1}}, te recordamos tu cita del {{2}} en Clínica Norte. "
                    "Responde a este mensaje si necesitas cambiarla.",
        "variables": ["nombre", "fecha"],
    },
    timeout=30,
)
res.raise_for_status()
template = res.json()["data"]
print(template["id"], template["status"])  # draft
```

#### Preview it

Preview renders the body with sample values. It never contacts the provider and costs nothing. Missing values appear as `[label]`, and `warnings` lists anything Meta would reject.

```bash
curl -sS -X POST "https://api.jelliu.co/api/whatsapp-templates/$TEMPLATE_ID/preview" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "variables": ["Ana", "viernes 19 a las 10:00"] }'
```

```json
{
  "data": {
    "templateId": "3f1c9a52-7b0e-4d3a-9c21-5e8f7a6b4d10",
    "name": "recordatorio_cita",
    "language": "es",
    "bodyText": "Hola {{1}}, te recordamos tu cita del {{2}} en Clínica Norte. Responde a este mensaje si necesitas cambiarla.",
    "rendered": "Hola Ana, te recordamos tu cita del viernes 19 a las 10:00 en Clínica Norte. Responde a este mensaje si necesitas cambiarla.",
    "variableCount": 2,
    "resolvedValues": { "1": "Ana", "2": "viernes 19 a las 10:00" },
    "warnings": []
  }
}
```

`variables` accepts an array (index 0 fills `{{1}}`) or an object keyed by position (`{"1": "Ana"}`), with up to 20 values of 400 characters each. With `GET`, pass them as the `variables` query parameter.

#### Submit for approval

```bash
curl -sS -X POST "https://api.jelliu.co/api/whatsapp-templates/$TEMPLATE_ID/submit" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

The template moves to `pending`. Only `draft` and `rejected` templates can be submitted or edited; anything else returns `400`: `Only draft or rejected templates can be edited. Submitted templates are managed by Meta.`

#### Wait for approved

Poll `GET /api/whatsapp-templates/{templateId}` until `status` is `approved`. Meta's review can take from minutes to days.

### Test sends

`POST /api/whatsapp-templates/{templateId}/test-send` sends an approved template to any E.164 number so you can see it on a real phone, without creating a contact:

```json
{ "toNumber": "+573001112233", "variables": ["Ana", "viernes 19 a las 10:00"] }
```

It returns `202` with `messageSid`, `status`, `to`, `from` and `rendered`.

A test send is a real, billed WhatsApp message: there is no simulation mode. Test sends are capped at **10 per workspace** and **3 per template** per UTC day, and they also count against the workspace's daily WhatsApp cap. Units are refunded when the provider rejects the send.

### Deleting templates

`DELETE /api/whatsapp-templates/{templateId}` removes the template at the provider and in Jelliu. A deleted template's name stays reserved for that language: creating another template with the same `name` and `language` fails with `409`.

## Sending messages

Two endpoints send to a contact. Both take a `contactId` (the contact must have a WhatsApp number and belong to your workspace), both accept a `write` key, and both run the same checks.

| Endpoint                   | You choose                         | Use it when                                                  |
| -------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `POST /api/whatsapp/send`  | Freeform `message` or `templateId` | You know which side of the 24-hour window the contact is on. |
| `POST /api/whatsapp/reach` | Only the text                      | You don't know. Jelliu picks the form for you.               |

### POST /api/whatsapp/send

**`contactId`** `string (uuid)` — required

The contact to message.

---

**`message`** `string`

Freeform text, 1 to 4096 characters. Allowed only inside the 24-hour window.

---

**`templateId`** `string (uuid)`

An approved template. Works inside and outside the window. When both are set, the template wins.

---

**`templateVariables`** `string[] | object`

Values for the template's placeholders: an ordered array, or an object keyed by position such as `{"1": "Ana"}`.

---

Provide `message` or `templateId`. Neither returns `400`: `Provide either a message or a templateId.`

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/whatsapp/send" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "templateId": "3f1c9a52-7b0e-4d3a-9c21-5e8f7a6b4d10",
    "templateVariables": ["Ana", "viernes 19 a las 10:00"]
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/whatsapp/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    contactId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
    templateId: '3f1c9a52-7b0e-4d3a-9c21-5e8f7a6b4d10',
    templateVariables: ['Ana', 'viernes 19 a las 10:00'],
  }),
});

const body = await res.json();
if (!res.ok) {
  // For example OUTSIDE_24H_WINDOW, TEMPLATE_NOT_APPROVED, DAILY_CAP_REACHED
  throw new Error(`${body.error.code}: ${body.error.message}`);
}
console.log(body.data.messageSid, body.data.status);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/whatsapp/send",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "templateId": "3f1c9a52-7b0e-4d3a-9c21-5e8f7a6b4d10",
        "templateVariables": ["Ana", "viernes 19 a las 10:00"],
    },
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"]["messageSid"], body["data"]["status"])
```

**`200 OK`**

```json title="200 OK"
{
  "data": {
    "messageSid": "SM2a4c6e8f0a1b3c5d7e9f1a2b3c4d5e6f",
    "status": "queued"
  }
}
```

### POST /api/whatsapp/reach

**`contactId`** `string (uuid)` — required

The contact to message.

---

**`message`** `string` — required

1 to 4096 characters. Sent as written when the window is open.

---

**`language`** `string` — default: es

2 to 10 characters. The template language to fall back on when the window is closed.

---

* **Window open:** `message` is sent as freeform text and `data.form` is `freeform`.
* **Window closed:** your newest approved template in `language` is sent instead and `data.form` is `template`. Your text is **not** delivered; the template opens the conversation, and once the contact replies the window is open again.
* **Window closed and no approved template in that language:** nothing is sent, and the call fails with `422 NO_TEMPLATE_AVAILABLE`.

**`200 OK`**

```json title="200 OK"
{
  "data": {
    "messageSid": "SM9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d",
    "status": "queued",
    "form": "template",
    "note": "WhatsApp only allows an approved template until the person writes to the line first, so an opening message was sent — NOT the material. Tell the person exactly that: you just wrote to them on WhatsApp, and if they reply there you will send the whole thing. Do not claim the information itself has been sent."
  }
}
```

`note` is written for an AI agent to relay to the contact. Branch on `form` in your own code.

### Checks before every send

Both endpoints run these checks in order. A failure stops the send before any quota is used.

1. The contact exists in your workspace and has a WhatsApp number.
2. **Compliance.** The number is checked against your [compliance rules](/platform/compliance): allowed contact hours for the contact's country, blocked prefixes, the suppression list, do-not-call status, and the daily and total attempt limits.
3. **Opt-out.** A contact with status `dnc` or an opt-out date cannot be messaged.
4. **Message form.** A template must exist, be `approved` and be ready at the provider; freeform requires the 24-hour window.
5. **Daily cap.** One unit of the workspace's daily WhatsApp cap is consumed. It is refunded if the send then fails.
6. **Sender.** An `ONLINE` sender must exist.

Unlike email, WhatsApp sends respect the allowed contact hours of your compliance configuration. By default that is 08:00 to 20:00 in the contact's country (for Colombia, `America/Bogota`). If your workspace has a timezone set, the hours are evaluated in that timezone instead. A send outside the window fails with `423 COMPLIANCE_BLOCKED`, for example `Outside allowed call hours (08:00-20:00 America/Bogota)`.

The attempt limits count **calls** to the contact. A contact who already received the maximum number of calls today (3 by default) cannot be messaged on WhatsApp until the next day either.

### WhatsApp campaigns

A campaign with `channel` set to `whatsapp` writes first to every contact with the campaign's approved template. Set `whatsappTemplateId`, `whatsappTemplateVariables` (literal values) and `whatsappTemplateVariableMap` (per-contact values such as `{"1": "contact.first_name"}`) on the campaign. Activation is refused if the template is not approved or a placeholder has no value source. Sends are paced at roughly one every 1.5 seconds per worker, and the last 20% of the daily cap (at least 25 messages) is reserved for replies so a campaign cannot leave your agent unable to answer. See [Campaigns](/resources/campaigns).

## Inbound messages

When a contact writes to one of your numbers, Jelliu:

1. **Routes by the receiving number.** The number must belong to exactly one workspace with an `ONLINE` sender or an active phone number. Messages to numbers that are unknown, or claimed by more than one workspace, are dropped.
2. **Checks for an opt-out** before anything else (see below).
3. **Finds the contact.** It matches the sender's number against contacts' WhatsApp and phone numbers, preferring a contact in an active campaign, then a paused one, then a completed one. People who are not in any campaign are welcome: Jelliu creates a contact for them, unless the number is on the suppression list.
4. **Picks the agent.** The campaign's agent if it serves WhatsApp; otherwise the agent bound to the receiving number; otherwise the workspace's oldest agent that serves WhatsApp.
5. **Opens the 24-hour window** for that contact.
6. **Generates and sends the reply** in the background. If the agent has a response delay or human pacing configured, the reply waits, and several messages sent in a row are answered together.

Voice notes are transcribed and answered; the reply to a voice note includes a spoken version when it is short enough (600 characters or less), and the text is always sent too. Photos and PDFs sent with or without a caption reach the agent together with the caption.

Inbound conversations and their messages are available through [Conversations](/resources/conversations).

AI replies on WhatsApp draw from your plan's monthly allowance of AI messages. When it is used up, the agent stops replying and the workspace receives an in-app notification. See [Limits](#limits).

## Opt-out

A message is treated as an opt-out when the **whole message** is one of these keywords (case, extra spaces and trailing punctuation are ignored):

`stop`, `stopall`, `stop all`, `cancel`, `unsubscribe`, `quit`, `end`, `baja`, `cancelar`, `darme de baja`, `dar de baja`, `no molestar`

Longer phrasings such as "no me vuelvas a escribir" are also detected. Keywords inside a sentence are not: "quiero cancelar mi cita" is a request to reschedule, and the agent answers it. Voice notes are checked after transcription.

When an opt-out is detected, Jelliu:

* sets every contact in your workspace with that number to `dnc` and records the date and the matched keyword;
* adds the number to the workspace's suppression list, which survives deleting and re-importing the contact;
* **sends nothing back**, not even a confirmation.

From then on, every send to that number fails with `403 CONTACT_OPTED_OUT` or `423 COMPLIANCE_BLOCKED`, and inbound messages from it are not answered. See [Compliance](/platform/compliance#suppression-list) to review or lift a suppression.

## Delivery statuses

Jelliu subscribes to the provider's delivery callbacks for every message it sends and records the latest status per message. Statuses only move forward: a late `failed` never overwrites a message already recorded as `delivered` or `read`.

| Status                            | Meaning                  |
| --------------------------------- | ------------------------ |
| `queued`, `accepted`, `scheduled` | Accepted for sending.    |
| `sending`, `sent`                 | Handed to WhatsApp.      |
| `delivered`                       | Delivered to the device. |
| `read`                            | Read by the contact.     |
| `failed`, `undelivered`           | Could not be delivered.  |

The `status` in a send response is the status at the moment of sending, usually `queued`.

Per-message delivery statuses are not currently exposed through the API or outbound webhooks.

## Errors

Send errors from `/api/whatsapp/send` and `/api/whatsapp/reach` use the standard envelope with a WhatsApp-specific `code`:

```json
{
  "error": {
    "code": "OUTSIDE_24H_WINDOW",
    "message": "Outside the 24-hour window: this contact has not messaged you in the last 24h, so Meta requires an approved template to reach them. Pick a template to send."
  }
}
```

| Status | Code                           | Cause and fix                                                                                                                   |
| ------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `VALIDATION_FAILED`            | The body failed validation. See `details`.                                                                                      |
| `400`  | `MESSAGE_OR_TEMPLATE_REQUIRED` | Neither a non-empty `message` nor a `templateId` was sent.                                                                      |
| `400`  | `NO_WHATSAPP_NUMBER`           | `Contact has no WhatsApp number`. Add one to the contact.                                                                       |
| `400`  | `INVALID_RECIPIENT`            | The contact's number is not a valid WhatsApp number.                                                                            |
| `403`  | `CONTACT_OPTED_OUT`            | `Contact has opted out — cannot send WhatsApp message`.                                                                         |
| `404`  | `TEMPLATE_NOT_FOUND`           | `Template not found` in this workspace.                                                                                         |
| `409`  | `SENDER_NOT_REGISTERED`        | Your sender exists but is still being approved or is offline. The message names the number and its status.                      |
| `422`  | `SENDER_NOT_REGISTERED`        | `No WhatsApp sender is set up for this account yet. Register your number in Settings -> WhatsApp before sending.`               |
| `422`  | `OUTSIDE_24H_WINDOW`           | Freeform text outside the window. Send an approved template, or use `/reach`.                                                   |
| `422`  | `TEMPLATE_NOT_APPROVED`        | The template is not `approved` yet. The message includes its current status.                                                    |
| `422`  | `NO_TEMPLATE_AVAILABLE`        | `/reach` only: the window is closed and there is no approved template in `language`.                                            |
| `423`  | `COMPLIANCE_BLOCKED`           | Blocked by compliance: outside allowed hours, suppressed, do-not-call, blocked prefix or attempt limit. The message says which. |
| `429`  | `DAILY_CAP_REACHED`            | `Daily WhatsApp send cap reached for this tenant (1000/day). Try again tomorrow or contact support to raise the limit.`         |
| `429`  | `RATE_LIMITED`                 | Throttled by WhatsApp. Retry shortly.                                                                                           |
| `429`  | `RATE_LIMIT_EXCEEDED`          | Jelliu's per-minute rate limit. Honor `Retry-After`.                                                                            |
| `502`  | `WHATSAPP_SEND_FAILED`         | Any other provider error.                                                                                                       |

Sender and template endpoints return the generic codes from [Errors](/errors): `VALIDATION_FAILED` (`400`, or `409` for duplicates such as `+573001234567 is already registered as a WhatsApp sender.`), `FORBIDDEN`, `NOT_FOUND` and `RATE_LIMIT_EXCEEDED`. A test send over its daily cap returns `429 RATE_LIMIT_EXCEEDED` with a Spanish message such as `Ya enviaste 3 pruebas de esta plantilla hoy. Cada prueba es un mensaje real que WhatsApp factura; intentalo manana.`

## Limits

| Limit                                                                 | Value                                                                                                                                                             |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Requests to `/api/whatsapp/*`                                         | 20 per minute per workspace, and fails closed. See [Rate limits](/rate-limits).                                                                                   |
| `POST /api/whatsapp/send`, `/reach` and sender and template mutations | Also count against the shared 10 per minute budget for configuration mutations.                                                                                   |
| Daily WhatsApp sends                                                  | 1,000 per workspace per UTC day by default; 25,000 on plans with unlimited monthly AI messages. Includes agent replies, API sends, campaign sends and test sends. |
| Reply reserve                                                         | Campaign sends stop at 80% of the daily cap (keeping at least 25 messages for replies).                                                                           |
| Template test sends                                                   | 10 per workspace and 3 per template per UTC day.                                                                                                                  |
| Freeform message length                                               | 4,096 characters.                                                                                                                                                 |
| Template body                                                         | 1,024 characters, up to 20 variables.                                                                                                                             |

### Monthly AI message allowance

AI replies on WhatsApp, email, web chat, Instagram and Messenger share one monthly allowance per plan:

| Plan       | AI messages per month |
| ---------- | --------------------- |
| Starter    | 1,200                 |
| Growth     | 4,000                 |
| Business   | 9,000                 |
| Enterprise | Unlimited             |

When the allowance is used up, or the workspace has no active plan, AI replies stop with `403 BILLING_ERROR`, for example `Llegaste al límite mensual de mensajes de IA (1200 mensajes del plan starter). Sube de plan en Ajustes → Plan.` Messages you send yourself through `/api/whatsapp/send` are limited by the daily cap, not by this allowance. See [Billing and usage](/platform/billing-and-usage).

### Scopes at a glance

| Operation                                                                            | Minimum key scope |
| ------------------------------------------------------------------------------------ | ----------------- |
| Read senders and templates, preview with `GET`                                       | `read`            |
| Send and reach contacts, preview with `POST`                                         | `write`           |
| Register, verify, update or delete senders; create, submit, test or delete templates | `full`            |

## Related

#### [Conversations](/resources/conversations)

Read WhatsApp threads and messages.

#### [Campaigns](/resources/campaigns)

Write first to a list of contacts with an approved template.

#### [Compliance](/platform/compliance)

Allowed hours, suppression lists and opt-out semantics.

#### [Email](/channels/email)

The other outbound text channel.