Web chat
The runtime API behind the widget: sessions, messages, realtime replies, attachments and browser voice.
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:
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
- Init creates a session for a visitor, or resumes the visitor’s last session.
- Message sends the visitor’s text (and optionally a file) and returns the agent’s reply synchronously. The first message creates the conversation.
- 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.
- History restores the transcript when a returning visitor resumes a session.
- 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.
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/initlooks for the most recent session of the same widget andvisitor_idthat was active in the last 30 days. If it finds one, it returns that session withresumed: trueinstead 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.
metadatasent 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.nameis 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
Your stable, random identifier for this visitor. At least 1 character.
Free-form key/value data stored on the session. Recognized keys: email and name (see Linking a visitor to a contact).
Response 201:
Send a message
POST /widget/message
The session from init.
The visitor’s text, 1 to 4000 characters.
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.
The visitor’s UI language as a primary language subtag, such as es or en (2 to 10 characters). Remembered on the session.
Response 200:
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.
HEIC photos are converted to JPEG, rotated photos are turned upright and very large images are scaled down before the agent sees them.
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:
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.
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
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:
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.
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.
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:
If you embed your chat UI in an iframe, give the frame allow="microphone". The standard embed sets it for you.
Voice session errors:
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.
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.
Send a message
1 to 5000 characters.
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.
A conversationId returned by a previous call. Required unless you send visitorId.
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.
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.
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.
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
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.

