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

# Conversations

A **conversation** is a text thread between one of your contacts and your workspace on one channel: WhatsApp, email, web chat, Instagram or Messenger. Every message in it, from the contact, from the agent or from a person on your team, is stored as a **message** on that thread. When the agent that answered the thread finishes, Jelliu attaches its analysis to the conversation: a summary, an outcome, sentiment scores and the fields the agent was configured to collect.

With the Conversations API you can read your inbox, open a thread with its messages, start a new WhatsApp or email conversation, reply as a person, and mark threads where the agent asked for human help as handled.

Phone calls are not conversations. Voice calls have their own record, transcript and analysis under [Calls](/resources/calls).

## How it works

```mermaid
flowchart LR
    C[Contact writes on WhatsApp, email, web chat or social] --> T[Conversation for this contact and channel]
    T --> A{Agent can answer?}
    A -->|yes| R[Agent reply stored as a message]
    A -->|asks for help| H[needs_human_at set, workspace notified]
    R --> Q[Thread quiet for 3 minutes]
    Q --> S[Analysis sweep stores summary, outcome, sentiment]
    O[Your team via POST /messages] --> T
    N[POST /api/conversations] --> T
```

* **One thread per contact and channel.** Inbound messages and your own sends are matched to the same conversation by a channel-specific key, so a reply from the contact lands in the thread you started instead of opening a second one. A `closed` thread is **reopened** when a new message arrives on it, rather than duplicated.
* **Two statuses.** A conversation is `active` or `closed`. There is no separate "handed off" status: when the agent needs a person it raises a flag on the thread (see [Human handoff](#human-handoff)) and keeps answering.
* **Analysis arrives later.** The analysis is produced after the exchange ends. A background sweep runs every 90 seconds, picks up threads that have been quiet for at least 3 minutes, and stores the result. Until then every analysis field is `null` (or `{}` for the two result objects). Each new agent turn resets `analysis_fetched_at`, and the analysis is fetched again, covering the whole thread, once it goes quiet.

## Object

### Conversation

Returned by `GET /api/conversations` (one per item) and `GET /api/conversations/{conversationId}`. Both endpoints shape the conversation the same way: the stored columns plus a few fields joined from the contact, the agent and the latest message.

| Field                         | Type               | Nullable | Description                                                                                                                                        |
| ----------------------------- | ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                          | string (uuid)      | No       | Conversation ID.                                                                                                                                   |
| `tenant_id`                   | string (uuid)      | No       | Your workspace ID.                                                                                                                                 |
| `agent_id`                    | string (uuid)      | Yes      | Agent bound to the thread. `null` on a conversation you started through the API until an agent handles it.                                         |
| `contact_id`                  | string (uuid)      | Yes      | Contact on the other side. `null` only on anonymous or legacy threads.                                                                             |
| `channel`                     | string             | No       | `whatsapp`, `email`, `webchat`, `instagram` or `messenger`.                                                                                        |
| `external_id`                 | string             | Yes      | Internal key used to match inbound messages to this thread. Treat it as opaque.                                                                    |
| `status`                      | string             | No       | `active` or `closed`.                                                                                                                              |
| `metadata`                    | object             | Yes      | Free-form data recorded with the thread.                                                                                                           |
| `created_at`                  | string (date-time) | No       | When the thread was created.                                                                                                                       |
| `updated_at`                  | string (date-time) | No       | Last activity on the thread, such as a new agent turn, a reopen or close, or a handoff flag and its resolution. The list is ordered by this field. |
| `closed_at`                   | string (date-time) | Yes      | When the thread was closed. `null` while active.                                                                                                   |
| `last_inbound_at`             | string (date-time) | Yes      | When the contact last wrote. `null` if nothing has arrived from them.                                                                              |
| `needs_human_at`              | string (date-time) | Yes      | When the agent asked for a person. `null` if it never did.                                                                                         |
| `needs_human_reason`          | string             | Yes      | What the agent could not do, written for your team (up to 400 characters). **Never shown to the contact.**                                         |
| `needs_human_resolved_at`     | string (date-time) | Yes      | When a person marked the request handled. `null` while it is still waiting.                                                                        |
| `elevenlabs_conversation_id`  | string             | Yes      | ID of the latest agent session that answered the thread.                                                                                           |
| `summary`                     | string             | Yes      | Two or three sentences written by the model that handled the thread. `null` until analysed.                                                        |
| `outcome`                     | string             | Yes      | `success`, `failure` or `unknown`. `null` until analysed. This vocabulary differs from a call's `outcome`.                                         |
| `sentiment_score`             | number             | Yes      | -1 to 1. `null` means **not analysed**, not neutral.                                                                                               |
| `frustration_score`           | number             | Yes      | 0 to 1: how frustrated the contact got across the thread. `null` when not measured.                                                                |
| `sentiment_low`               | number             | Yes      | -1 to 1: the most negative single contact turn. `null` when not measured.                                                                          |
| `evaluation_criteria_results` | object             | No       | Per-criterion verdicts, keyed by criterion ID, each with `result` and `rationale`. `{}` until analysed.                                            |
| `data_collection_results`     | object             | No       | Fields the agent was configured to extract, keyed by field ID, each with `value` and `rationale`. `{}` until analysed.                             |
| `analysis_fetched_at`         | string (date-time) | Yes      | When the analysis was stored. `null` means not analysed yet.                                                                                       |
| `analysis_attempts`           | integer            | No       | Failed or not-ready fetch attempts. The sweep gives up after 5.                                                                                    |
| `cost_credits`                | integer            | Yes      | Provider credits the thread consumed. `null` when not reported.                                                                                    |
| `cost_usd`                    | string (decimal)   | Yes      | Cost in USD, serialized as a decimal string. `null` when not reported.                                                                             |
| `termination_reason`          | string             | Yes      | How the last agent session ended.                                                                                                                  |
| `main_language`               | string             | Yes      | Detected language code, lowercase.                                                                                                                 |
| `call_success_score`          | number             | Yes      | 0 to 100: the provider's confidence that the thread met its goal.                                                                                  |
| `tool_call_count`             | integer            | Yes      | Tool calls the agent made. `null` means never measured; `0` means measured with no tools used.                                                     |
| `mcp_call_count`              | integer            | Yes      | MCP tool calls. Same `null` rule.                                                                                                                  |
| `rag_lookup_count`            | integer            | Yes      | Knowledge-base lookups. Same `null` rule.                                                                                                          |
| `tool_result_bytes`           | string (integer)   | Yes      | Total characters returned by tools, serialized as a string. Same `null` rule.                                                                      |
| `contact_name`                | string             | Yes      | The contact's name.                                                                                                                                |
| `contact_phone`               | string             | Yes      | The contact's phone number. For email and web chat contacts without a real number, the contact's email instead.                                    |
| `contact_status`              | string             | Yes      | The contact's status, for example `converted` or `dnc`. `null` on anonymous threads.                                                               |
| `agent_name`                  | string             | Yes      | Name of the bound agent.                                                                                                                           |
| `last_message`                | string             | Yes      | Preview of the latest message, up to 160 characters.                                                                                               |
| `last_message_at`             | string (date-time) | Yes      | When the latest message was stored.                                                                                                                |
| `last_message_role`           | string             | Yes      | `user` or `agent`. `user` means the contact wrote last and nobody has answered.                                                                    |
| `qualification`               | object             | Yes      | Lead score derived from `data_collection_results` and `outcome`, the same scoring calls use. See below.                                            |

`qualification` has this shape:

| Field        | Type            | Description                                                           |
| ------------ | --------------- | --------------------------------------------------------------------- |
| `score`      | integer or null | 0 to 100, or `null` when there was nothing to score. Never a default. |
| `interest`   | string or null  | `high`, `medium` or `low`.                                            |
| `answered`   | boolean or null | Whether a real person engaged.                                        |
| `next_step`  | string or null  | The concrete commitment reached, if any.                              |
| `objections` | string\[]       | Objections recorded by the agent.                                     |
| `reasons`    | string\[]       | Which signals produced the score, in product language.                |

### Message

Returned in the `messages` array of `GET /api/conversations/{conversationId}`.

| Field             | Type               | Nullable | Description                                                                                                                                     |
| ----------------- | ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`              | string (uuid)      | No       | Message ID.                                                                                                                                     |
| `conversation_id` | string (uuid)      | No       | Parent conversation.                                                                                                                            |
| `tenant_id`       | string (uuid)      | No       | Your workspace ID.                                                                                                                              |
| `role`            | string             | No       | `user` for the contact, `agent` for everything sent from your side: the AI, a person on your team, or a recorded outbound email.                |
| `content_type`    | string             | No       | `text` by default; `image` or `file` when the contact sent an attachment.                                                                       |
| `content`         | string             | No       | Message text. A template send is recorded as `[template] <templateId>`.                                                                         |
| `audio_url`       | string             | Yes      | Audio URL for voice-note messages.                                                                                                              |
| `metadata`        | object             | Yes      | Provenance data. See the keys below.                                                                                                            |
| `created_at`      | string (date-time) | No       | When the message was stored.                                                                                                                    |
| `attachment`      | object             | Yes      | `{ filename, contentType, bytes }` when the contact attached a file; otherwise `null`. Only the description is kept, **never the file itself**. |

Keys you may find in `metadata`:

| Key                   | Meaning                                                                                                                                                                                                                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source`              | Who wrote an agent row: `ai_reply` (generated by the agent), `canned_reply` (a fixed notice, for example when the agent is paused), `reply_override`, or `outbound_email` (an email sent through `POST /api/email/send`, recorded in the contact's email thread). |
| `author`              | On `outbound_email` rows: `human` or `agent_tool`.                                                                                                                                                                                                                |
| `canned`              | Which fixed notice a `canned_reply` carries: `budget`, `paused`, `suppressed` or `unattended`.                                                                                                                                                                    |
| `manual`, `outbound`  | `true` on messages a person sent through the API or the dashboard.                                                                                                                                                                                                |
| `provider_message_id` | The messaging provider's ID for a WhatsApp send.                                                                                                                                                                                                                  |
| `template_id`         | The WhatsApp template that was sent.                                                                                                                                                                                                                              |
| `subject`             | Subject line of an email you started.                                                                                                                                                                                                                             |
| `tokens_used`         | Tokens the AI reply consumed.                                                                                                                                                                                                                                     |

Treat `metadata` as informational: read the keys you need and ignore the rest.

## Human handoff

An agent working a text channel can decide that a person has to step in: a refund it is not allowed to approve, a request for a manager, a question about something it cannot see. When that happens:

1. The conversation gets `needs_human_at` and `needs_human_reason`, and your workspace receives an in-app notification.
2. **The contact is told nothing.** The thread is not closed or paused, and the agent keeps answering normally.
3. Raising the flag again while it is still waiting does nothing, so `needs_human_at` keeps meaning "waiting since".
4. A person reads the thread, optionally replies with `POST /api/conversations/{conversationId}/messages`, and marks it handled with `POST /api/conversations/{conversationId}/needs-human/resolve`. Resolving sends nothing to the contact.

The flag is closed, not erased: `needs_human_at` and `needs_human_reason` remain and `needs_human_resolved_at` is set. If the agent raises it again later, a new episode starts.

Every list response carries `needsHumanWaiting`, the number of threads waiting across the whole workspace, independent of the page you asked for.

Poll `GET /api/conversations?needsHuman=waiting` to build a work queue. It returns only unresolved requests, **oldest wait first**, so the person who has waited longest is at the top.

## Common tasks

### Read the inbox

#### List active conversations

`GET /api/conversations` returns **active** conversations only, most recently active first.

**`page`** `integer` — default: 1

1-based page number.

---

**`limit`** `integer` — default: 50

Items per page, 1 to 100. Larger values are capped at 100.

---

**`needsHuman`** `string`

Pass `waiting` to return only threads whose handoff request is unresolved, ordered by `needs_human_at` ascending. Any other value is ignored.

---

**`curl`**

```bash title="curl"
curl -sS "https://api.jelliu.co/api/conversations?page=1&limit=50" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const url = new URL('https://api.jelliu.co/api/conversations');
url.searchParams.set('page', '1');
url.searchParams.set('limit', '50');

const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);

const { conversations, total, needsHumanWaiting } = body.data;
console.log(`${total} active, ${needsHumanWaiting} waiting for a person`);
```

**`Python`**

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

res = requests.get(
    "https://api.jelliu.co/api/conversations",
    params={"page": 1, "limit": 50},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}")

data = body["data"]
print(data["total"], "active,", data["needsHumanWaiting"], "waiting for a person")
```

**`200 OK`**

```json title="200 OK"
{
  "data": {
    "conversations": [
      {
        "id": "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36",
        "tenant_id": "b2e4c6a8-1d3f-4a5b-8c7d-9e0f1a2b3c4d",
        "agent_id": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
        "contact_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "channel": "whatsapp",
        "external_id": "wa_0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90_+573001234567",
        "status": "active",
        "metadata": null,
        "created_at": "2026-09-14T13:02:11.482Z",
        "updated_at": "2026-09-14T15:40:52.117Z",
        "closed_at": null,
        "last_inbound_at": "2026-09-14T15:40:31.905Z",
        "needs_human_at": null,
        "needs_human_reason": null,
        "needs_human_resolved_at": null,
        "elevenlabs_conversation_id": "conv_4201k52xq7d9f0a8b3c6e5g1h2j4",
        "summary": null,
        "outcome": null,
        "sentiment_score": null,
        "frustration_score": null,
        "sentiment_low": null,
        "evaluation_criteria_results": {},
        "data_collection_results": {},
        "analysis_fetched_at": null,
        "analysis_attempts": 0,
        "cost_credits": null,
        "cost_usd": null,
        "termination_reason": null,
        "main_language": null,
        "call_success_score": null,
        "tool_call_count": null,
        "mcp_call_count": null,
        "rag_lookup_count": null,
        "tool_result_bytes": null,
        "contact_name": "Ana Gómez",
        "contact_status": "pending",
        "agent_name": "Asesora comercial",
        "last_message_at": "2026-09-14T15:40:52.101Z",
        "last_message_role": "agent",
        "qualification": {
          "score": null,
          "interest": null,
          "answered": null,
          "next_step": null,
          "objections": [],
          "reasons": ["..."]
        },
        "contact_phone": "+573001234567",
        "last_message": "Claro, te comparto los horarios disponibles para esta semana."
      }
    ],
    "total": 128,
    "page": 1,
    "limit": 50,
    "needsHumanWaiting": 2
  }
}
```

`total` counts active conversations matching the filter, so you can compute the number of pages.

#### Open a conversation and its messages

`GET /api/conversations/{conversationId}` returns the conversation, in any status, with its messages in chronological order.

**`curl`**

```bash title="curl"
curl -sS "https://api.jelliu.co/api/conversations/3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const conversationId = '3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36';
const res = await fetch(`https://api.jelliu.co/api/conversations/${conversationId}`, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);

for (const m of body.data.messages) {
  console.log(m.created_at, m.role, m.content);
}
```

**`Python`**

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

conversation_id = "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36"
res = requests.get(
    f"https://api.jelliu.co/api/conversations/{conversation_id}",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}")

for m in body["data"]["messages"]:
    print(m["created_at"], m["role"], m["content"])
```

**`200 OK (abridged)`**

```json title="200 OK (abridged)"
{
  "data": {
    "id": "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36",
    "channel": "whatsapp",
    "status": "active",
    "contact_name": "Ana Gómez",
    "summary": "La clienta ya compró el plan anual y pidió ayuda para cambiar el método de pago.",
    "outcome": "success",
    "sentiment_score": 0.4,
    "frustration_score": 0.1,
    "sentiment_low": -0.2,
    "data_collection_results": {
      "tipo_visitante": { "value": "cliente", "rationale": "Mencionó su suscripción activa." }
    },
    "analysis_fetched_at": "2026-09-14T15:47:03.552Z",
    "qualification": {
      "score": null,
      "interest": null,
      "answered": null,
      "next_step": null,
      "objections": [],
      "reasons": ["Es un cliente actual, no un lead: no se califica."]
    },
    "messages": [
      {
        "id": "c7d8e9f0-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
        "conversation_id": "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36",
        "tenant_id": "b2e4c6a8-1d3f-4a5b-8c7d-9e0f1a2b3c4d",
        "role": "user",
        "content_type": "image",
        "content": "Este es el comprobante",
        "audio_url": null,
        "metadata": {
          "attachment": { "filename": "comprobante.jpg", "content_type": "image/jpeg", "bytes": 184233 },
          "inbound_message_id": "SM8f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c"
        },
        "created_at": "2026-09-14T15:40:31.905Z",
        "attachment": { "filename": "comprobante.jpg", "contentType": "image/jpeg", "bytes": 184233 }
      },
      {
        "id": "d8e9f0a1-2b3c-4d4e-9f5a-6b7c8d9e0f1a",
        "conversation_id": "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36",
        "tenant_id": "b2e4c6a8-1d3f-4a5b-8c7d-9e0f1a2b3c4d",
        "role": "agent",
        "content_type": "text",
        "content": "Gracias, Ana. Ya lo recibí y lo reviso enseguida.",
        "audio_url": null,
        "metadata": { "source": "ai_reply", "tokens_used": 412 },
        "created_at": "2026-09-14T15:40:52.101Z",
        "attachment": null
      }
    ]
  }
}
```

The detail endpoint returns at most the **first 50 messages** of a thread, oldest first. On a longer thread the most recent messages are not included; use `last_message`, `last_message_at` and `last_message_role` for the latest activity.

### Start a conversation

`POST /api/conversations` sends a first message to a phone number on WhatsApp or an email address, and creates or reuses the thread for it.

**`recipient`** `string` — required

1 to 254 characters. For `whatsapp`, a strict E.164 number such as `+573001234567`. For `email`, an email address.

---

**`channel`** `string` — required

`whatsapp` or `email`. Web chat, Instagram and Messenger conversations cannot be started from the API.

---

**`message`** `string`

1 to 4096 characters. Required unless you send a WhatsApp `templateId`.

---

**`subject`** `string`

1 to 998 characters. **Required** when `channel` is `email`.

---

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

WhatsApp only: an approved template from your workspace. Use it to reach someone who has not written to you in the last 24 hours.

---

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

Values for the template placeholders, as an ordered array or an object of string values.

---

What happens:

1. Jelliu looks for an existing contact in your workspace: by WhatsApp number for `whatsapp`, by email (case-insensitive) for `email`. A contact that already belongs to a campaign is reused and **not** moved.
2. If none exists, a contact is created in a workspace campaign named **Manual Conversations**, created on first use and bound to your oldest agent. A workspace with no agents gets `422`.
3. The message is sent through the channel, with all of its checks: opt-out, suppression list, the WhatsApp 24-hour window and allowed hours, and the daily send cap.
4. Only after a successful send is the thread created (or reopened) and the message stored with `role: "agent"`.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/conversations" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "whatsapp",
    "recipient": "+573001234567",
    "templateId": "5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
    "templateVariables": ["Ana", "martes 16 de septiembre"]
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/conversations', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    channel: 'email',
    recipient: 'ana.gomez@example.com',
    subject: 'Tu cotización',
    message: 'Hola Ana, te comparto la cotización que pediste. ¿La revisamos juntos esta semana?',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);

console.log(body.data.conversationId);
```

**`Python`**

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

res = requests.post(
    "https://api.jelliu.co/api/conversations",
    json={
        "channel": "whatsapp",
        "recipient": "+573001234567",
        "templateId": "5e8f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
        "templateVariables": ["Ana", "martes 16 de septiembre"],
    },
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")

print(body["data"]["conversationId"])
```

**`201 Created`**

```json title="201 Created"
{
  "data": {
    "conversationId": "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36",
    "contactId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "channel": "whatsapp",
    "externalMessageId": "SM2d4f6a8c0e1b3d5f7a9c1e3b5d7f9a1c"
  }
}
```

`externalMessageId` is the provider's message ID for WhatsApp and `null` for email.

A freeform WhatsApp `message` is only accepted when the contact wrote to you within the last 24 hours. Outside that window the request fails with `422 OUTSIDE_24H_WINDOW`: send an approved `templateId` instead.

### Reply to a conversation

`POST /api/conversations/{conversationId}/messages` sends a reply written by a person on your team. **The agent is not invoked**: only your text is delivered and stored.

**`message`** `string`

1 to 4096 characters. Required unless you send a `templateId`.

---

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

WhatsApp conversations only. Rejected with `400 VALIDATION_FAILED` on any other channel.

---

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

Values for the template placeholders.

---

Delivery depends on the thread's channel:

| Channel                  | Delivery                                                                                                                                                    |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `whatsapp`               | Sent through your WhatsApp sender, with the same checks as starting a conversation.                                                                         |
| `email`                  | Sent from your connected mailbox. The subject is taken from the thread and prefixed with `Re:` once; with no subject on record it is `Re: su conversación`. |
| `webchat`                | Nothing to call: the message is stored and pushed to the visitor's open widget, which also picks it up on its next poll.                                    |
| `instagram`, `messenger` | Not available yet: `422 VALIDATION_FAILED`.                                                                                                                 |

The provider is called **before** the message is stored, so a rejected send never leaves a message in the thread that the contact did not receive.

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/conversations/3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36/messages" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Hola Ana, soy Laura del equipo de soporte. Ya actualicé tu método de pago." }'
```

**`Node.js`**

```javascript title="Node.js"
const conversationId = '3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36';
const res = await fetch(`https://api.jelliu.co/api/conversations/${conversationId}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    message: 'Hola Ana, soy Laura del equipo de soporte. Ya actualicé tu método de pago.',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);

console.log(body.data.messageId);
```

**`Python`**

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

conversation_id = "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36"
res = requests.post(
    f"https://api.jelliu.co/api/conversations/{conversation_id}/messages",
    json={"message": "Hola Ana, soy Laura del equipo de soporte. Ya actualicé tu método de pago."},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")

print(body["data"]["messageId"])
```

**`201 Created`**

```json title="201 Created"
{
  "data": {
    "messageId": "e9f0a1b2-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
    "conversationId": "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36"
  }
}
```

A reply that fails with `5xx` after reaching the provider may still have been delivered. Before retrying, re-read the conversation and check whether your message is there, so the contact is not messaged twice.

### Resolve a handoff request

#### Find threads waiting for a person

```bash
curl -sS "https://api.jelliu.co/api/conversations?needsHuman=waiting" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

#### Mark one as handled

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/conversations/3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36/needs-human/resolve" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const conversationId = '3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36';
const res = await fetch(
  `https://api.jelliu.co/api/conversations/${conversationId}/needs-human/resolve`,
  { method: 'POST', headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` } },
);
const body = await res.json();
if (res.status === 404) {
  console.log('Nothing waiting on this conversation');
} else if (!res.ok) {
  throw new Error(`${res.status} ${body.error?.code}`);
}
```

**`Python`**

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

conversation_id = "3f6a2c1e-8b4d-4f7a-9e2b-5c8d1a0f7e36"
res = requests.post(
    f"https://api.jelliu.co/api/conversations/{conversation_id}/needs-human/resolve",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
if res.status_code == 404:
    print("Nothing waiting on this conversation")
else:
    res.raise_for_status()
```

**`200 OK`**

```json title="200 OK"
{ "data": { "resolved": true } }
```

The endpoint answers `404 NOT_FOUND` both for a conversation that does not exist and for one that is not waiting (never flagged, or already resolved):

**`404 Not Found`**

```json title="404 Not Found"
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Esta conversación no está esperando a nadie."
  }
}
```

### Talk to an agent over web chat

`POST /api/webchat/message` sends a message **as a visitor** and returns the agent's reply in the same response. Use it for a custom chat interface on your own backend, or to test an agent. To embed chat on a website, use the [web chat widget](/widget) instead.

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

1 to 5000 characters.

---

**`visitorId`** `string`

1 to 100 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`. Identifies the visitor's thread. Required unless you pass `conversationId`.

---

**`conversationId`** `string (uuid)`

Continue an existing conversation. Required unless you pass `visitorId`.

---

**`agentId`** `string (uuid)`

The agent that answers. It must belong to your workspace, otherwise `403 FORBIDDEN`.

---

**`curl`**

```bash title="curl"
curl -sS -X POST "https://api.jelliu.co/api/webchat/message" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    "visitorId": "visitor_8f3a2b1c",
    "message": "Hola, ¿qué planes tienen?"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/webchat/message', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    agentId: '7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4',
    visitorId: 'visitor_8f3a2b1c',
    message: 'Hola, ¿qué planes tienen?',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`);

console.log(body.data.reply);
```

**`Python`**

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

res = requests.post(
    "https://api.jelliu.co/api/webchat/message",
    json={
        "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
        "visitorId": "visitor_8f3a2b1c",
        "message": "Hola, ¿qué planes tienen?",
    },
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=60,
)
body = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {body['error']['code']}")

print(body["data"]["reply"])
```

**`200 OK`**

```json title="200 OK"
{
  "data": {
    "conversationId": "4a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
    "reply": "¡Hola! Con gusto te cuento. ¿Para cuántas personas de tu equipo lo necesitas?"
  }
}
```

Two related endpoints work on the same threads: `GET /api/webchat/{conversationId}/history` returns `{ id, role, content, contentType, createdAt }` for each message (the same first-50 cap applies), and `POST /api/webchat/{conversationId}/close` closes the conversation and returns `{ "data": { "closed": true } }`.

## Agent replies and silence

A few rules decide whether the agent answers at all. They apply to every text channel.

* **Reply budget.** The agent sends at most **10 AI replies per conversation in any 24 hours**. When the budget is spent, the contact receives one fixed closing notice (stored with `metadata.source: "canned_reply"` and `canned: "budget"`), the thread is flagged for a person and closed. Replies from your team, fixed notices and recorded outbound emails do not count toward the budget.
* **Paused or deleted agent.** A paused agent answers with a fixed notice and the thread stays open, so it resumes where it left off. A thread whose agent was deleted is answered with a notice and closed; no other agent takes over.
* **Opt-out means total silence.** A WhatsApp message that is exactly an opt-out keyword (`STOP`, `STOPALL`, `STOP ALL`, `CANCEL`, `UNSUBSCRIBE`, `QUIT`, `END`, `BAJA`, `CANCELAR`, `DARME DE BAJA`, `DAR DE BAJA`, `NO MOLESTAR`, case-insensitive, trailing punctuation ignored), or a clear request not to be contacted, marks every matching contact as `dnc`, adds the number to your suppression list and sends **nothing back, not even a confirmation**. From then on, API sends to that contact are refused.

## Errors

Validation failures return `400 VALIDATION_FAILED` with a field map in `details` (see [Errors](/errors#validation-errors)). Refusals from the messaging channel use the standard envelope with the codes below. Note that some of them use `423`.

| Code                     | Status                  | When                                                                                                                                                                                                                                                                                                     |
| ------------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_FAILED`      | 400                     | Invalid body or a `conversationId` that is not a UUID (`Invalid conversationId — must be a UUID`); missing `subject` on email; a recipient that is not E.164 (`Phone number must be E.164 format (e.g. +14155551234)`) or not an email (`Invalid email address`); `templateId` on a non-WhatsApp thread. |
| `VALIDATION_FAILED`      | 422                     | Starting a conversation in a workspace with no agents; replying to a thread with no contact; replying on `instagram` or `messenger`.                                                                                                                                                                     |
| `CONVERSATION_NOT_FOUND` | 404                     | The conversation does not exist in your workspace (get and reply).                                                                                                                                                                                                                                       |
| `NOT_FOUND`              | 404                     | Resolving a handoff on a conversation that is not waiting, or does not exist.                                                                                                                                                                                                                            |
| `FORBIDDEN`              | 403                     | `POST /api/webchat/message` with an `agentId` outside your workspace, or a key without the `write` scope on a `POST`.                                                                                                                                                                                    |
| `CONTACT_OPTED_OUT`      | 403                     | WhatsApp: the contact opted out or is marked `dnc`.                                                                                                                                                                                                                                                      |
| `NO_WHATSAPP_NUMBER`     | 400                     | WhatsApp: the contact has no WhatsApp number.                                                                                                                                                                                                                                                            |
| `INVALID_RECIPIENT`      | 400                     | WhatsApp: the provider rejected the destination number.                                                                                                                                                                                                                                                  |
| `TEMPLATE_NOT_FOUND`     | 404                     | WhatsApp: `templateId` is not a template of your workspace.                                                                                                                                                                                                                                              |
| `OUTSIDE_24H_WINDOW`     | 422                     | WhatsApp: freeform text to a contact who has not written in the last 24 hours. Send a template.                                                                                                                                                                                                          |
| `TEMPLATE_NOT_APPROVED`  | 422                     | WhatsApp: the template is not approved yet.                                                                                                                                                                                                                                                              |
| `SENDER_NOT_REGISTERED`  | 409 or 422              | WhatsApp: your sender number is still being approved or is offline (`409`), or no WhatsApp sender is set up (`422`).                                                                                                                                                                                     |
| `COMPLIANCE_BLOCKED`     | 423                     | WhatsApp: blocked by your compliance rules, such as the suppression list, a blocked prefix or allowed hours.                                                                                                                                                                                             |
| `DAILY_CAP_REACHED`      | 429                     | WhatsApp: the workspace's daily send cap is used up.                                                                                                                                                                                                                                                     |
| `RATE_LIMITED`           | 429                     | WhatsApp: the messaging provider throttled the send. Retry shortly.                                                                                                                                                                                                                                      |
| `WHATSAPP_SEND_FAILED`   | 500 or 502              | WhatsApp: an unexpected provider failure.                                                                                                                                                                                                                                                                |
| `EMAIL_SEND_FAILED`      | 400, 403, 409, 423, 429 | Email, told apart by status: `400` the contact has no email address; `403` the contact opted out; `409` no connected mailbox, or the mailbox connection is not working; `423` blocked by compliance rules; `429` daily email cap reached.                                                                |
| `RATE_LIMIT_EXCEEDED`    | 429                     | A request rate limit. See [Limits](#limits).                                                                                                                                                                                                                                                             |

A provider refusal looks like this:

**`422 Unprocessable Entity`**

```json title="422 Unprocessable Entity"
{
  "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."
  }
}
```

## Limits

| Endpoint                                                       | Scope   | Rate limit                                                               |
| -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
| `GET /api/conversations`                                       | `read`  | General API limit, by plan.                                              |
| `GET /api/conversations/{conversationId}`                      | `read`  | General API limit.                                                       |
| `POST /api/conversations`                                      | `write` | General API limit **and** the shared 10 per minute budget for mutations. |
| `POST /api/conversations/{conversationId}/messages`            | `write` | General API limit.                                                       |
| `POST /api/conversations/{conversationId}/needs-human/resolve` | `write` | General API limit.                                                       |
| `POST /api/webchat/message`                                    | `write` | General API limit **and** 20 per minute per client IP.                   |
| `GET /api/webchat/{conversationId}/history`                    | `read`  | General API limit.                                                       |
| `POST /api/webchat/{conversationId}/close`                     | `write` | General API limit.                                                       |

See [Rate limits](/rate-limits) for the per-plan general limit and [Authentication](/authentication) for scopes. None of these endpoints requires a `full` key.

Other limits:

* **Daily send caps.** Each workspace can send up to **1,000 WhatsApp messages** and **1,000 emails** per day by default. A failed send does not consume the cap. Contact support to raise it.
* **Plan allowance.** AI replies on text channels, web chat included, count toward your plan's monthly included chat messages.
* **Reply budget.** 10 AI replies per conversation per 24 hours, as described in [Agent replies and silence](#agent-replies-and-silence).
* **Freshness.** List and detail responses are cached for up to 30 seconds. A reply sent through the API clears the cache for its conversation and your inbox, so an immediate re-read includes it.

## Webhooks

There are no dedicated conversation events. Related events from the [catalog](/webhooks#event-catalog):

| Event                                   | Relation to conversations                                                                                   |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `crm_sync.completed`, `crm_sync.failed` | An interaction was written, or failed to write, to a connected CRM. Carries `conversationId` and `channel`. |
| `agent.action_recorded`                 | The agent invoked a tool. Carries `conversation_id` when it happened in a conversation.                     |

To react to new messages or handoff requests, poll `GET /api/conversations` (for example with `needsHuman=waiting`) and compare `updated_at` and `last_message_at`.

## Related

#### [Contacts](/resources/contacts)

The people behind each thread, opt-outs and suppression.

#### [Agents](/resources/agents)

Configure the agent that answers, its channels and what it collects.

#### [Calls](/resources/calls)

Voice calls, their transcripts and analysis.

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

Proactive outreach on WhatsApp, email and voice.

#### [Web chat widget](/widget)

Put an agent on your website.

#### [API reference](/api-reference)

Every endpoint, parameter and response.