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

# Web chat

The [web chat widget](/widget) is one client of a public runtime API. You can call the same API yourself to build a chat interface that matches your product, run a chat inside a mobile app, or relay messages from your own backend.

Jelliu exposes web chat through two surfaces:

| Surface                | Base path        | Credential                                    | Called from                                    |
| ---------------------- | ---------------- | --------------------------------------------- | ---------------------------------------------- |
| **Widget runtime API** | `/widget/*`      | Widget ID (public) or widget API key (secret) | A browser on an allowed origin, or your server |
| **Web chat API**       | `/api/webchat/*` | Workspace API key (`jl_...`)                  | Your server only                               |

Both run the message through the agent you choose, store the conversation in your workspace, and return the agent's reply in the same response. Conversations from either surface appear in the dashboard and in the [Conversations API](/resources/conversations) with the channel `webchat`.

Building a chat for anonymous website visitors? Use the widget runtime API. It has per-visitor sessions, origin checks, operator replies over WebSocket, attachments and voice. Use `/api/webchat` when a trusted backend already knows who the user is and only needs text in and text out.

## How it works

```mermaid
sequenceDiagram
    participant V as Visitor browser
    participant J as api.jelliu.co
    participant A as Agent
    participant O as Operator (dashboard)

    V->>J: POST /widget/init (visitor_id)
    J-->>V: 201 session_id, resumed, greeting, branding
    V->>J: POST /widget/message (session_id, message)
    J->>A: Run the widget's agent
    A-->>J: Reply
    J-->>V: 200 conversation_id, message_id, reply
    V->>J: WebSocket /widget/ws (wid, session_id, parent_origin)
    J-->>V: hello
    O->>J: Reply from the dashboard
    J-->>V: message event (over WebSocket, or on the next /widget/poll)
```

1. **Init** creates a session for a visitor, or resumes the visitor's last session.
2. **Message** sends the visitor's text (and optionally a file) and returns the agent's reply synchronously. The first message creates the conversation.
3. **Realtime**: once the conversation exists, open a WebSocket to receive replies that do not come back inline, such as a human operator answering from the dashboard. Poll as a fallback.
4. **History** restores the transcript when a returning visitor resumes a session.
5. **Voice session** returns short-lived credentials to talk to the same agent by voice from the browser.

## Credentials and headers

Every `/widget/*` request (except the WebSocket, which uses query parameters) carries a credential and the embedding page's origin.

| Header                   | Required     | Value                                                                                                                                                                  |
| ------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `x-widget-id`            | One of these | The widget's UUID. **Public.** This is what a browser should send. You can also pass it as the `wid` query parameter.                                                  |
| `x-widget-api-key`       | One of these | The widget's secret key, returned once as `plaintext_api_key` when the widget is created. **Server-side only.** Header only: it is never accepted in the query string. |
| `x-widget-parent-origin` | Yes          | The origin of the page the chat runs on, for example `https://www.example.com`. It must match the widget's `allowed_origins`.                                          |

If both credentials are sent, `x-widget-api-key` is used. A value shaped like a UUID is treated as a widget ID; anything else is treated as an API key.

Before a request reaches the endpoint, Jelliu checks, in order: the credential exists and the widget is active, the workspace is active, the widget has at least one allowed origin, `x-widget-parent-origin` is present and allowed, and the per-widget and per-workspace rate limits have room. See [Allowed origins](/widget#allowed-origins) for the matching rules (scheme and port exact, one leading `www.` ignored, no wildcards).

`x-widget-parent-origin` is a declaration, not proof. A browser cannot forge it on behalf of a real page, but any script outside a browser can send whatever it likes. The widget ID is public by design, so treat the origin check as protection against other websites embedding your chat, not as authentication. Rate limits and the daily message cap exist for the same reason.

CORS on `/widget/*` accepts any origin and does not send credentials, so browser `fetch` calls from your pages work without extra configuration. Use `credentials: 'omit'`.

## Sessions and visitor identity

A **session** belongs to one widget and one `visitor_id`. The `visitor_id` is a string you choose; the standard widget generates one and keeps it in the visitor's `localStorage`.

* **Resuming.** `POST /widget/init` looks for the most recent session of the same widget and `visitor_id` that was active in the last **30 days**. If it finds one, it returns that session with `resumed: true` instead of creating a new one. Sending a message or merging metadata refreshes the session's activity time.
* **Conversation.** A session has no conversation until the first `POST /widget/message`. From then on, every message of the session goes to the same conversation.
* **Metadata.** `metadata` sent to init is stored on the session. On a resumed session, new keys are merged into the existing metadata; nothing is removed.

Anyone who knows a `visitor_id` can resume that visitor's session and read its history. Generate it with a cryptographically random value, such as `crypto.randomUUID()`, and never derive it from an email address, a user ID or anything else guessable.

### Linking a visitor to a contact

If the session metadata contains `email`, the first message links the conversation to a contact in your workspace:

* An existing contact with the same email (compared case-insensitively) is reused. If it has no name and `metadata.name` is set, the name is filled in.
* Otherwise a new contact is created in the workspace's manual conversations campaign.

The email is taken as given. Only send `email` and `name` for visitors your site has already authenticated, and do not rely on them to authorize anything.

The standard embed does this automatically on Shopify storefronts, where it forwards the signed-in customer's email, name and ID and the shop domain.

## Initialize a session

`POST /widget/init`

**`visitor_id`** `string` — required

Your stable, random identifier for this visitor. At least 1 character.

---

**`metadata`** `object`

Free-form key/value data stored on the session. Recognized keys: `email` and `name` (see [Linking a visitor to a contact](#linking-a-visitor-to-a-contact)).

---

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/widget/init" \
  -H "x-widget-id: $JELLIU_WIDGET_ID" \
  -H "x-widget-parent-origin: https://www.example.com" \
  -H "Content-Type: application/json" \
  -d '{
    "visitor_id": "4f7c2a9e-1b3d-4e8f-9a6c-5d2e1f0b7a34",
    "metadata": { "email": "ana@example.com", "name": "Ana Torres" }
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/widget/init', {
  method: 'POST',
  headers: {
    'x-widget-api-key': process.env.JELLIU_WIDGET_API_KEY,
    'x-widget-parent-origin': 'https://www.example.com',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    visitor_id: '4f7c2a9e-1b3d-4e8f-9a6c-5d2e1f0b7a34',
    metadata: { email: 'ana@example.com', name: 'Ana Torres' },
  }),
});
const { data } = await res.json();
console.log(res.status, data.session_id, data.resumed);
```

**`Python`**

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

res = requests.post(
    "https://api.jelliu.co/widget/init",
    headers={
        "x-widget-api-key": os.environ["JELLIU_WIDGET_API_KEY"],
        "x-widget-parent-origin": "https://www.example.com",
    },
    json={
        "visitor_id": "4f7c2a9e-1b3d-4e8f-9a6c-5d2e1f0b7a34",
        "metadata": {"email": "ana@example.com", "name": "Ana Torres"},
    },
    timeout=30,
)
data = res.json()["data"]
print(res.status_code, data["session_id"], data["resumed"])
```

Response `201`:

```json
{
  "data": {
    "session_id": "b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e",
    "resumed": false,
    "greeting_message": "¡Hola! ¿En qué te puedo ayudar?",
    "branding": {
      "primary_color": "#2563EB",
      "position": "bottom-right",
      "title": "Chat with us"
    },
    "voice_enabled": true
  }
}
```

| Field              | Type           | Description                                                                                                               |
| ------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `session_id`       | uuid           | Send it with every later call.                                                                                            |
| `resumed`          | boolean        | `true` when an existing session was returned. Load the [history](#restore-history) instead of greeting the visitor again. |
| `greeting_message` | string or null | The widget's greeting.                                                                                                    |
| `branding`         | object         | The widget's branding, including `quick_replies`, `agent_names`, `agent_avatars` and `i18n` when set.                     |
| `voice_enabled`    | boolean        | Whether [voice sessions](#voice-in-the-browser) are available.                                                            |

## Send a message

`POST /widget/message`

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

The session from init.

---

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

The visitor's text, 1 to 4000 characters.

---

**`agent_name`** `string`

Display name you show for the agent in your UI (1 to 40 characters). The reply is signed with this name, and it is remembered on the session.

---

**`lang`** `string`

The visitor's UI language as a primary language subtag, such as `es` or `en` (2 to 10 characters). Remembered on the session.

---

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/widget/message" \
  -H "x-widget-id: $JELLIU_WIDGET_ID" \
  -H "x-widget-parent-origin: https://www.example.com" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e",
    "message": "¿Tienen citas disponibles mañana?",
    "lang": "es"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/widget/message', {
  method: 'POST',
  headers: {
    'x-widget-api-key': process.env.JELLIU_WIDGET_API_KEY,
    'x-widget-parent-origin': 'https://www.example.com',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    session_id: 'b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e',
    message: '¿Tienen citas disponibles mañana?',
    lang: 'es',
  }),
});
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/widget/message",
    headers={
        "x-widget-api-key": os.environ["JELLIU_WIDGET_API_KEY"],
        "x-widget-parent-origin": "https://www.example.com",
    },
    json={
        "session_id": "b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e",
        "message": "¿Tienen citas disponibles mañana?",
        "lang": "es",
    },
    timeout=60,
)
res.raise_for_status()
print(res.json()["data"]["reply"])
```

Response `200`:

```json
{
  "data": {
    "conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
    "message_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
    "reply": "Sí, mañana tenemos espacio a las 10:00 y a las 15:30. ¿Cuál prefieres?"
  }
}
```

| Field             | Description                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `conversation_id` | The conversation this session is bound to. Use it with the [Conversations API](/resources/conversations).                |
| `message_id`      | ID of the stored reply. The same reply is also published on the realtime channel, so keep this ID to skip the duplicate. |
| `reply`           | The agent's answer, as plain text.                                                                                       |

The call waits for the agent to answer, so allow a generous client timeout.

Message text is treated as plain text. In JSON requests, as with other request bodies sent to Jelliu, HTML tags are stripped before the message is stored, and a `<` that is not closed by `>` removes the rest of the string. If visitors may type comparisons such as `a < b`, replace `<` with a lookalike or a word before sending.

### Attachments

Send an image or a PDF with the same endpoint as `multipart/form-data`: the fields `session_id`, `message` (and optionally `agent_name`, `lang`) plus one file in the `file` field.

| Rule              | Value                                                            |
| ----------------- | ---------------------------------------------------------------- |
| Files per message | 1                                                                |
| Maximum size      | 10 MB                                                            |
| Accepted types    | PNG, JPEG, WEBP, GIF, HEIC/HEIF, AVIF, TIFF and PDF              |
| Checked against   | The file's actual bytes, not only its declared type or extension |

HEIC photos are converted to JPEG, rotated photos are turned upright and very large images are scaled down before the agent sees them.

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/widget/message" \
  -H "x-widget-id: $JELLIU_WIDGET_ID" \
  -H "x-widget-parent-origin: https://www.example.com" \
  -F "session_id=b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e" \
  -F "message=Adjunto la factura" \
  -F "file=@factura.pdf;type=application/pdf"
```

**`Node.js`**

```javascript title="Node.js"
import { readFile } from 'node:fs/promises';

const form = new FormData();
form.append('session_id', 'b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e');
form.append('message', 'Adjunto la factura');
form.append(
  'file',
  new Blob([await readFile('factura.pdf')], { type: 'application/pdf' }),
  'factura.pdf',
);

// Do not set Content-Type yourself: fetch adds the multipart boundary.
const res = await fetch('https://api.jelliu.co/widget/message', {
  method: 'POST',
  headers: {
    'x-widget-api-key': process.env.JELLIU_WIDGET_API_KEY,
    'x-widget-parent-origin': 'https://www.example.com',
  },
  body: form,
});
console.log(res.status, await res.json());
```

**`Python`**

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

with open("factura.pdf", "rb") as f:
    res = requests.post(
        "https://api.jelliu.co/widget/message",
        headers={
            "x-widget-api-key": os.environ["JELLIU_WIDGET_API_KEY"],
            "x-widget-parent-origin": "https://www.example.com",
        },
        data={
            "session_id": "b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e",
            "message": "Adjunto la factura",
        },
        files={"file": ("factura.pdf", f, "application/pdf")},
        timeout=60,
    )
print(res.status_code, res.json())
```

A rejected file returns `413` (too large) or `415` (any other reason) with code `ATTACHMENT_REJECTED`, a machine-readable `reason` and a sentence you can show to the visitor:

```json
{
  "error": {
    "code": "ATTACHMENT_REJECTED",
    "reason": "unsupported_type",
    "message": "You can attach an image (PNG, JPG, WEBP, HEIC, AVIF, TIFF) or a PDF."
  }
}
```

| `reason`           | Status | Meaning                                                                  |
| ------------------ | ------ | ------------------------------------------------------------------------ |
| `too_large`        | 413    | Over 10 MB.                                                              |
| `empty`            | 415    | The file has no content.                                                 |
| `unsupported_type` | 415    | Not an accepted type.                                                    |
| `content_mismatch` | 415    | The bytes do not match the declared type, for example a renamed archive. |
| `needs_conversion` | 415    | A real image in a format that cannot be processed. Ask for JPG or PNG.   |

## Restore history

`GET /widget/history?session_id=...`

Returns the **last 50 messages** of the session, visitor and agent turns, oldest first. Call it after init returns `resumed: true`. A session without a conversation yet returns an empty list. The session must belong to the widget you authenticate as, otherwise the response is `404`.

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/widget/history?session_id=b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e" \
  -H "x-widget-id: $JELLIU_WIDGET_ID" \
  -H "x-widget-parent-origin: https://www.example.com"
```

**`Node.js`**

```javascript title="Node.js"
const url = new URL('https://api.jelliu.co/widget/history');
url.searchParams.set('session_id', 'b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e');

const res = await fetch(url, {
  headers: {
    'x-widget-api-key': process.env.JELLIU_WIDGET_API_KEY,
    'x-widget-parent-origin': 'https://www.example.com',
  },
});
const { data } = await res.json();
for (const m of data.messages) console.log(m.role, m.content);
```

**`Python`**

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

res = requests.get(
    "https://api.jelliu.co/widget/history",
    params={"session_id": "b2e4c6a8-0d1f-4b3a-9c5e-7f8a9b0c1d2e"},
    headers={
        "x-widget-api-key": os.environ["JELLIU_WIDGET_API_KEY"],
        "x-widget-parent-origin": "https://www.example.com",
    },
    timeout=30,
)
for m in res.json()["data"]["messages"]:
    print(m["role"], m["content"])
```

```json
{
  "data": {
    "messages": [
      { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "role": "user", "content": "¿Tienen citas disponibles mañana?", "created_at": "2026-09-14T15:40:02.114Z" },
      { "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "role": "agent", "content": "Sí, mañana tenemos espacio a las 10:00 y a las 15:30. ¿Cuál prefieres?", "created_at": "2026-09-14T15:40:04.870Z" }
    ]
  }
}
```

`role` is `user` for the visitor and `agent` for both the AI and human operators.

## Receive replies in real time

Replies to `/widget/message` come back inline. Other replies, above all a person answering from the dashboard, arrive through the realtime channel. Use the WebSocket and keep polling as a safety net, exactly as the standard widget does.

### WebSocket

```text
wss://api.jelliu.co/widget/ws?wid=WIDGET_ID&session_id=SESSION_ID&parent_origin=https%3A%2F%2Fwww.example.com
```

| Query parameter | Required | Value                                                                                                                                                                 |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wid`           | Yes      | The widget ID (UUID). The API key is not accepted here.                                                                                                               |
| `session_id`    | Yes      | The session from init.                                                                                                                                                |
| `parent_origin` | Yes      | URL-encoded origin of the embedding page, checked against `allowed_origins`. Browsers cannot set custom headers on a WebSocket handshake, so it travels in the query. |

Rules of the connection:

* **Open it after the first message.** The session must already have a conversation; before that the handshake is refused with `401`.
* **Receive only.** Messages you send over the socket are ignored. Visitor messages always go through `POST /widget/message`.
* **Limits.** Up to 60 connection attempts per minute per IP. Up to 4 open sockets per session: a fifth closes the oldest with code `1008`.
* **Heartbeat.** The server pings every 30 seconds and drops sockets that stop answering. Browsers answer pings automatically.
* **Deploys.** On shutdown the server closes sockets with code `1001`. Reconnect with backoff.

Events are JSON text frames:

```json
{ "type": "hello", "conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a" }
```

```json
{
  "type": "message",
  "message": {
    "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817",
    "role": "agent",
    "content": "Hola Ana, soy Laura del equipo. Ya revisé tu factura.",
    "created_at": "2026-09-14T15:44:10.502Z"
  },
  "conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
  "at": "2026-09-14T15:44:10.611Z"
}
```

A `message` event is published both for AI replies and for operator replies, so a second tab of the same session stays in sync. Deduplicate by `message.id` against the `message_id` you already rendered from `/widget/message`.

### Polling fallback

`GET /widget/poll?session_id=...&since=...`

Returns up to 50 `agent` messages created at or after `since` (an ISO 8601 timestamp), oldest first, plus the server time `now`. Visitor messages are not included.

```json
{
  "data": {
    "messages": [
      { "id": "6e5d4c3b-2a19-4f08-9e7d-6c5b4a392817", "role": "agent", "content": "Hola Ana, soy Laura del equipo. Ya revisé tu factura.", "created_at": "2026-09-14T15:44:10.502Z" }
    ],
    "now": "2026-09-14T15:44:12.000Z"
  }
}
```

Pass the previous response's `now` as the next `since`. The boundary is inclusive, so a message can appear twice: deduplicate by `id`. The standard widget polls every 4 seconds while the tab is visible and the WebSocket is down, and every 30 seconds once the WebSocket is connected. Polls count against the same [rate limits](#limits) as messages, so do not poll faster.

## Voice in the browser

For widgets with `voice_enabled`, the visitor can talk to the same agent. `POST /widget/voice-session` (no body) returns short-lived credentials for a direct voice connection between the browser and Jelliu's voice provider, ElevenLabs.

```json
{
  "data": {
    "signed_url": "wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agent_01j&conversation_signature=sig_example",
    "conversation_token": "eyJhbGciOi..."
  }
}
```

| Field                | Always present | Use                                                                                                                   |
| -------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- |
| `conversation_token` | No             | Token for a **WebRTC** connection, which adds echo cancellation and noise removal. Omitted if it could not be issued. |
| `signed_url`         | Yes            | URL for a **WebSocket** connection. The fallback.                                                                     |

Request a new pair for every call and never cache it. The standard widget uses the official browser SDK, [`@elevenlabs/client`](https://www.npmjs.com/package/@elevenlabs/client), version 1.25.0, and connects like this:

**`voice.js`**

```javascript title="voice.js"
import { Conversation } from '@elevenlabs/client';

const WIDGET_ID = 'YOUR_WIDGET_ID';
const API = 'https://api.jelliu.co';

export async function startVoice(callbacks) {
  // Ask for the microphone inside the click handler, or browsers ignore it.
  const probe = await navigator.mediaDevices.getUserMedia({ audio: true });

  try {
    const res = await fetch(`${API}/widget/voice-session`, {
      method: 'POST',
      credentials: 'omit',
      headers: {
        'x-widget-id': WIDGET_ID,
        'x-widget-parent-origin': window.location.origin,
        'Content-Type': 'application/json',
      },
      body: '{}',
    });
    const body = await res.json();
    if (!res.ok) throw new Error(body.error.code);

    const { conversation_token: token, signed_url: signedUrl } = body.data;

    // WebRTC first, WebSocket as the fallback.
    if (token) {
      try {
        return await Conversation.startSession({ ...callbacks, conversationToken: token, connectionType: 'webrtc' });
      } catch {
        // fall through to the WebSocket connection
      }
    }
    return await Conversation.startSession({ ...callbacks, signedUrl, connectionType: 'websocket' });
  } finally {
    // The SDK opens its own capture; the probe only obtained permission.
    probe.getTracks().forEach((t) => t.stop());
  }
}

// Usage:
// const conversation = await startVoice({
//   onConnect: () => console.log('connected'),
//   onModeChange: ({ mode }) => console.log(mode), // "speaking" or "listening"
//   onDisconnect: () => console.log('ended'),
//   onError: (err) => console.error(err),
// });
// ...later: await conversation.endSession();
```

If you embed your chat UI in an iframe, give the frame `allow="microphone"`. The standard embed sets it for you.

Voice session errors:

| Status | Code                 | Message                                                                   |
| ------ | -------------------- | ------------------------------------------------------------------------- |
| `403`  | `FORBIDDEN`          | `Voice is not enabled for this widget`                                    |
| `409`  | `CONFLICT`           | `Widget has no agent assigned`                                            |
| `409`  | `AGENT_PROVISIONING` | `Agent is still being configured`. Retry after `Retry-After` (5 seconds). |

## Build a custom chat UI

This is a complete browser client: session with resume, messages, realtime replies with polling fallback and deduplication. Serve it from a page whose origin is in the widget's `allowed_origins`. It only uses the public widget ID.

**`chat.html`**

```html title="chat.html"
<div id="log"></div>
<form id="composer">
  <input id="input" maxlength="4000" autocomplete="off" placeholder="Escribe un mensaje" />
  <button>Send</button>
</form>

<script type="module">
  const API = 'https://api.jelliu.co';
  const WIDGET_ID = 'YOUR_WIDGET_ID';
  const ORIGIN = window.location.origin;

  const seen = new Set();
  let sessionId = null;
  let conversationId = null;
  let socket = null;
  let lastSeen = new Date().toISOString();
  let pollEvery = 4000;
  let pollTimer = null;
  let retryMs = 0;

  const log = document.getElementById('log');
  function render(role, text) {
    const p = document.createElement('p');
    p.className = role;
    p.textContent = text; // never innerHTML
    log.appendChild(p);
  }

  async function widget(path, { method = 'POST', body } = {}) {
    const res = await fetch(API + path, {
      method,
      credentials: 'omit',
      headers: {
        'x-widget-id': WIDGET_ID,
        'x-widget-parent-origin': ORIGIN,
        ...(body ? { 'Content-Type': 'application/json' } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const json = await res.json();
    if (!res.ok) throw Object.assign(new Error(json.error.message), { status: res.status, code: json.error.code });
    return json.data;
  }

  function visitorId() {
    try {
      let id = localStorage.getItem('chat-visitor-id');
      if (!id) {
        id = crypto.randomUUID();
        localStorage.setItem('chat-visitor-id', id);
      }
      return id;
    } catch {
      return crypto.randomUUID(); // storage blocked: one session per page load
    }
  }

  function openSocket() {
    if (!conversationId || socket) return;
    const url = new URL('wss://api.jelliu.co/widget/ws');
    url.searchParams.set('wid', WIDGET_ID);
    url.searchParams.set('session_id', sessionId);
    url.searchParams.set('parent_origin', ORIGIN);
    socket = new WebSocket(url);
    socket.onopen = () => { retryMs = 0; setPoll(30000); };
    socket.onmessage = (evt) => {
      const payload = JSON.parse(evt.data);
      if (payload.type !== 'message') return;
      const m = payload.message;
      if (seen.has(m.id)) return;
      seen.add(m.id);
      render('agent', m.content);
    };
    socket.onclose = () => {
      socket = null;
      setPoll(4000);
      retryMs = retryMs ? Math.min(retryMs * 2, 30000) : 1000;
      setTimeout(openSocket, retryMs);
    };
  }

  function setPoll(ms) {
    pollEvery = ms;
    clearInterval(pollTimer);
    pollTimer = setInterval(poll, pollEvery);
  }

  async function poll() {
    if (!conversationId) return;
    try {
      const qs = new URLSearchParams({ session_id: sessionId, since: lastSeen });
      const data = await widget(`/widget/poll?${qs}`, { method: 'GET' });
      for (const m of data.messages) {
        if (seen.has(m.id)) continue;
        seen.add(m.id);
        render('agent', m.content);
      }
      lastSeen = data.now;
    } catch (err) {
      if (err.status === 429) setPoll(Math.min(pollEvery * 2, 60000));
    }
  }

  async function start() {
    const init = await widget('/widget/init', { body: { visitor_id: visitorId() } });
    sessionId = init.session_id;

    if (init.resumed) {
      const history = await widget(`/widget/history?session_id=${sessionId}`, { method: 'GET' });
      for (const m of history.messages) {
        seen.add(m.id);
        render(m.role, m.content);
      }
      if (history.messages.length > 0) {
        // Messages exist, so the session already has a conversation and the
        // WebSocket handshake will be accepted. The real conversation_id
        // arrives with the next /widget/message response.
        conversationId = 'resumed';
        lastSeen = history.messages[history.messages.length - 1].created_at;
        openSocket();
        setPoll(4000);
      }
    } else if (init.greeting_message) {
      render('agent', init.greeting_message);
    }
  }

  document.getElementById('composer').addEventListener('submit', async (e) => {
    e.preventDefault();
    const input = document.getElementById('input');
    const text = input.value.trim();
    if (!text) return;
    input.value = '';
    render('user', text);
    try {
      const data = await widget('/widget/message', { body: { session_id: sessionId, message: text } });
      seen.add(data.message_id);
      render('agent', data.reply);
      if (conversationId !== data.conversation_id) {
        conversationId = data.conversation_id;
        openSocket();
        setPoll(socket ? 30000 : 4000);
      }
    } catch (err) {
      render('error', err.status === 429 ? 'Demasiados mensajes. Intenta en un momento.' : 'No se pudo enviar el mensaje.');
    }
  });

  start();
</script>
```

### Calling from your server instead

To relay messages through your own backend, send `x-widget-api-key` instead of the widget ID and keep the key in your server's secrets. You still need `x-widget-parent-origin` set to one of the widget's allowed origins.

Rate limits on `/widget/*` are counted per client IP. When your server relays every visitor, all of them share your server's budget of 60 requests per minute. For high-traffic sites, let browsers call the API directly with the widget ID, or use the [web chat API](#server-side-web-chat-api) with a workspace key.

## Server-side web chat API

`/api/webchat` is for trusted backends that already know the user. It authenticates with a workspace API key like the rest of the REST API; see [Authentication](/authentication).

| Method | Path                                    | Scope   |
| ------ | --------------------------------------- | ------- |
| `POST` | `/api/webchat/message`                  | `write` |
| `GET`  | `/api/webchat/{conversationId}/history` | `read`  |
| `POST` | `/api/webchat/{conversationId}/close`   | `write` |

### Send a message

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

1 to 5000 characters.

---

**`visitorId`** `string`

Your identifier for the user: 1 to 100 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`. Messages with the same `visitorId` continue the same conversation. Required unless you send `conversationId`.

---

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

A `conversationId` returned by a previous call. Required unless you send `visitorId`.

---

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

The agent that answers. It must belong to your workspace, otherwise the request fails with `403`. Always send it on the first message: a conversation created without an agent is answered with a generic default prompt.

---

**`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": "customer_48213",
    "message": "Quiero cambiar la dirección de entrega"
  }'
```

**`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: 'customer_48213',
    message: 'Quiero cambiar la dirección de entrega',
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
console.log(body.data.conversationId, body.data.reply);
```

**`Python`**

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

res = requests.post(
    "https://api.jelliu.co/api/webchat/message",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
        "visitorId": "customer_48213",
        "message": "Quiero cambiar la dirección de entrega",
    },
    timeout=60,
)
res.raise_for_status()
data = res.json()["data"]
print(data["conversationId"], data["reply"])
```

```json
{
  "data": {
    "conversationId": "3a4b5c6d-7e8f-4091-a2b3-c4d5e6f7a8b9",
    "reply": "Claro. ¿Cuál es la nueva dirección?"
  }
}
```

### Read history and close

`GET /api/webchat/{conversationId}/history` returns the **first 50 messages** of the conversation, oldest first. An unknown ID returns an empty list rather than `404`. Responses are cached for up to 30 seconds, so a message you just sent may not appear immediately.

```json
{
  "data": [
    { "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b", "role": "user", "content": "Quiero cambiar la dirección de entrega", "contentType": "text", "createdAt": "2026-09-14T16:02:11.201Z" },
    { "id": "6f7a8b9c-0d1e-4f2a-9b3c-4d5e6f7a8b9c", "role": "agent", "content": "Claro. ¿Cuál es la nueva dirección?", "contentType": "text", "createdAt": "2026-09-14T16:02:13.944Z" }
  ]
}
```

For longer transcripts, use the [Conversations API](/resources/conversations).

`POST /api/webchat/{conversationId}/close` closes the conversation and returns `{ "data": { "closed": true } }`, or `404 CONVERSATION_NOT_FOUND`.

## Errors

Widget runtime errors use the standard [error envelope](/errors). The messages below are returned verbatim.

| Status        | Code                      | Message                                                                                                                                                          | Cause                                                                                                   |
| ------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `400`         | `VALIDATION_FAILED`       | `Invalid init payload`, `Invalid message payload`, `Invalid query`                                                                                               | Body or query failed validation.                                                                        |
| `401`         | `UNAUTHORIZED`            | `Missing widget credential`                                                                                                                                      | No `x-widget-api-key`, `x-widget-id` or `wid`.                                                          |
| `401`         | `UNAUTHORIZED`            | `Invalid widget API key`                                                                                                                                         | Unknown or deleted widget, or wrong key.                                                                |
| `403`         | `FORBIDDEN`               | `Widget is deactivated`                                                                                                                                          | The widget's `is_active` is `false`.                                                                    |
| `403`         | `FORBIDDEN`               | `Service unavailable`                                                                                                                                            | The workspace is not active.                                                                            |
| `403`         | `FORBIDDEN`               | `Widget has no allowed origins configured`                                                                                                                       | Add at least one origin.                                                                                |
| `403`         | `FORBIDDEN`               | `Missing X-Widget-Parent-Origin header. The widget UI should attach it automatically; if you are calling the API directly, set it to the embedding site origin.` | Header missing.                                                                                         |
| `403`         | `FORBIDDEN`               | `Invalid parent origin` / `Origin not allowed`                                                                                                                   | Not a URL, or not in `allowed_origins`.                                                                 |
| `404`         | `NOT_FOUND`               | `Session not found`                                                                                                                                              | Unknown session, or a session of another widget.                                                        |
| `413` / `415` | `ATTACHMENT_REJECTED`     | Varies                                                                                                                                                           | See [Attachments](#attachments).                                                                        |
| `429`         | `RATE_LIMIT_EXCEEDED`     | `Too many widget requests from this IP, please slow down`                                                                                                        | Per-IP limit.                                                                                           |
| `429`         | `RATE_LIMIT_EXCEEDED`     | `Rate limit exceeded`                                                                                                                                            | Per-widget or per-workspace limit.                                                                      |
| `429`         | `RATE_LIMIT_EXCEEDED`     | `Daily message limit reached for this account. Please try again tomorrow.`                                                                                       | Daily message cap. Resets at 00:00 UTC.                                                                 |
| `503`         | `INTERNAL_ERROR`          | `Rate limiter unavailable`                                                                                                                                       | Limits could not be checked, so the request was refused. Retry shortly.                                 |
| `503`         | `TEMPORARILY_UNAVAILABLE` | `No podemos responder por aquí en este momento. Por favor, inténtalo más tarde.`                                                                                 | The workspace has no active plan or used up its monthly AI message allowance. Safe to show to visitors. |

On `/api/webchat/message`, the same plan condition returns `403 BILLING_ERROR` with a message for the workspace owner (in Spanish) and `metadata` with `limit`, `current`, `tier` and `channel`. An `agentId` from another workspace returns `403 FORBIDDEN` with `Agent not found or not owned by tenant`.

## Limits

| Limit                  | Applies to                                 | Value                                                                  |
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- |
| Per client IP          | Every `/widget/*` endpoint                 | 60 requests per minute                                                 |
| Per widget             | Every authenticated `/widget/*` endpoint   | The widget's `rate_limit_rpm` (30 per minute by default)               |
| Per workspace          | All widgets together                       | 300 requests per minute                                                |
| Daily messages         | `POST /widget/message`, per workspace      | A daily cap per UTC day                                                |
| WebSocket              | `/widget/ws`                               | 60 connection attempts per minute per IP, 4 sockets per session        |
| Message length         | `/widget/message` / `/api/webchat/message` | 4000 / 5000 characters                                                 |
| Attachments            | `/widget/message`                          | 1 file, 10 MB                                                          |
| `/api/webchat/message` | Per client IP                              | 20 requests per minute, plus your plan's [general limit](/rate-limits) |

The per-widget, per-workspace and daily checks **fail closed**: if Jelliu cannot count requests, it answers `503` rather than letting traffic through.

**Plan.** Every AI reply on web chat, from either surface, counts against the workspace's monthly AI message allowance, the same one WhatsApp and the other text channels use. See [Billing and usage](/platform/billing-and-usage).

## Related

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

Embed the ready-made widget with one script tag.

#### [Embed a voice and chat agent](/recipes/embed-voice-and-chat-agent)

End-to-end tutorial: widget, custom UI and voice.

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

Read transcripts and reply as an operator.

#### [Security](/security)

Origins, credentials and tenant isolation.