Web chat

The runtime API behind the widget: sessions, messages, realtime replies, attachments and browser voice.

View as Markdown

The web chat 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:

SurfaceBase pathCredentialCalled 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 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

  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.

HeaderRequiredValue
x-widget-idOne of theseThe widget’s UUID. Public. This is what a browser should send. You can also pass it as the wid query parameter.
x-widget-api-keyOne of theseThe 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-originYesThe 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 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
stringRequired

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

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" }
}'

Response 201:

{
"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
}
}
FieldTypeDescription
session_iduuidSend it with every later call.
resumedbooleantrue when an existing session was returned. Load the history instead of greeting the visitor again.
greeting_messagestring or nullThe widget’s greeting.
brandingobjectThe widget’s branding, including quick_replies, agent_names, agent_avatars and i18n when set.
voice_enabledbooleanWhether voice sessions are available.

Send a message

POST /widget/message

session_id
string (uuid)Required

The session from init.

message
stringRequired

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 -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"
}'

Response 200:

{
"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?"
}
}
FieldDescription
conversation_idThe conversation this session is bound to. Use it with the Conversations API.
message_idID of the stored reply. The same reply is also published on the realtime channel, so keep this ID to skip the duplicate.
replyThe 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.

RuleValue
Files per message1
Maximum size10 MB
Accepted typesPNG, JPEG, WEBP, GIF, HEIC/HEIF, AVIF, TIFF and PDF
Checked againstThe 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 -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"

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:

{
"error": {
"code": "ATTACHMENT_REJECTED",
"reason": "unsupported_type",
"message": "You can attach an image (PNG, JPG, WEBP, HEIC, AVIF, TIFF) or a PDF."
}
}
reasonStatusMeaning
too_large413Over 10 MB.
empty415The file has no content.
unsupported_type415Not an accepted type.
content_mismatch415The bytes do not match the declared type, for example a renamed archive.
needs_conversion415A 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 -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"
{
"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

wss://api.jelliu.co/widget/ws?wid=WIDGET_ID&session_id=SESSION_ID&parent_origin=https%3A%2F%2Fwww.example.com
Query parameterRequiredValue
widYesThe widget ID (UUID). The API key is not accepted here.
session_idYesThe session from init.
parent_originYesURL-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:

{ "type": "hello", "conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a" }
{
"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.

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

{
"data": {
"signed_url": "wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agent_01j&conversation_signature=sig_example",
"conversation_token": "eyJhbGciOi..."
}
}
FieldAlways presentUse
conversation_tokenNoToken for a WebRTC connection, which adds echo cancellation and noise removal. Omitted if it could not be issued.
signed_urlYesURL 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, version 1.25.0, and connects like this:

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:

StatusCodeMessage
403FORBIDDENVoice is not enabled for this widget
409CONFLICTWidget has no agent assigned
409AGENT_PROVISIONINGAgent 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
<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 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.

MethodPathScope
POST/api/webchat/messagewrite
GET/api/webchat/{conversationId}/historyread
POST/api/webchat/{conversationId}/closewrite

Send a message

message
stringRequired

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 -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"
}'
{
"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.

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

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. The messages below are returned verbatim.

StatusCodeMessageCause
400VALIDATION_FAILEDInvalid init payload, Invalid message payload, Invalid queryBody or query failed validation.
401UNAUTHORIZEDMissing widget credentialNo x-widget-api-key, x-widget-id or wid.
401UNAUTHORIZEDInvalid widget API keyUnknown or deleted widget, or wrong key.
403FORBIDDENWidget is deactivatedThe widget’s is_active is false.
403FORBIDDENService unavailableThe workspace is not active.
403FORBIDDENWidget has no allowed origins configuredAdd at least one origin.
403FORBIDDENMissing 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.
403FORBIDDENInvalid parent origin / Origin not allowedNot a URL, or not in allowed_origins.
404NOT_FOUNDSession not foundUnknown session, or a session of another widget.
413 / 415ATTACHMENT_REJECTEDVariesSee Attachments.
429RATE_LIMIT_EXCEEDEDToo many widget requests from this IP, please slow downPer-IP limit.
429RATE_LIMIT_EXCEEDEDRate limit exceededPer-widget or per-workspace limit.
429RATE_LIMIT_EXCEEDEDDaily message limit reached for this account. Please try again tomorrow.Daily message cap. Resets at 00:00 UTC.
503INTERNAL_ERRORRate limiter unavailableLimits could not be checked, so the request was refused. Retry shortly.
503TEMPORARILY_UNAVAILABLENo 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

LimitApplies toValue
Per client IPEvery /widget/* endpoint60 requests per minute
Per widgetEvery authenticated /widget/* endpointThe widget’s rate_limit_rpm (30 per minute by default)
Per workspaceAll widgets together300 requests per minute
Daily messagesPOST /widget/message, per workspaceA daily cap per UTC day
WebSocket/widget/ws60 connection attempts per minute per IP, 4 sockets per session
Message length/widget/message / /api/webchat/message4000 / 5000 characters
Attachments/widget/message1 file, 10 MB
/api/webchat/messagePer client IP20 requests per minute, plus your plan’s general limit

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.