Backend API
This page is the contract between Shakomako and its server.
Build these endpoints in any language, and the widget works with your
backend. Chatvia builds all of them under /widget.
Paths are relative to the
API base URL: stream means
https://api.example.com/widget/stream.
Which endpoints you need
| Endpoint | When the widget calls it | Needed for |
|---|---|---|
GET agents/verify |
On the first visit | Required |
GET agents/settings |
On every page load | Required |
POST sessions/init |
On every page load | Required |
GET conversations |
On every page load, and for the history list | Required |
POST conversations |
When there is no conversation yet, and on New chat | Required |
GET conversations/messages |
When a conversation opens | Required |
POST stream |
For each message the visitor sends | Required |
POST conversations/title |
After the first answer in a conversation | Recommended |
GET handoff/status |
When a conversation opens, and after each message | Recommended |
POST heartbeat |
Every 30 seconds | Recommended |
DELETE conversations |
When the visitor deletes a conversation | Deleting history |
POST feedback |
When the visitor rates an answer | Feedback |
POST conversations/attachments |
When the visitor attaches a file | File uploads |
POST forms/{id}/submit |
When the visitor sends a form from an answer | Forms |
POST auth/exchange |
On load, for a protected agent | Protected agents |
POST transcribe |
When the visitor dictates a message | Dictation |
POST voice, DELETE voice/calls/{id} |
When the visitor starts and ends a voice call | Voice calls |
POST handoff/end, POST typing, POST mark-read, POST broadcasting/auth |
During live chat | Live chat |
The widget calls handoff/status and heartbeat even when you have no live
chat. Without them it still works, but the browser console shows failed
requests. They take three lines each: see the
example. Without conversations/title, the history
list shows a conversation without a title until your server gives it one.
Requests
Headers
| Header | When the widget sends it | Value |
|---|---|---|
X-Agent-Id |
Always | The data-agent-id of the embed tag. |
X-Session-Id |
After sessions/init |
The visitor's session. |
X-Conversation-Id |
On calls about one conversation | The conversation. |
X-Message-Id |
On feedback |
The rated message. |
X-Auth-Token |
For a protected agent, after auth/exchange |
The widget's session token. |
The widget writes the names in capitals (X-AGENT-ID). HTTP header names
ignore case. Request bodies are JSON, except the file upload. The widget
counts any 2xx status as a success.
Sessions
A session is one visitor in one browser. The widget keeps the session id in
localStorage and sends it with every request after start-up. Treat it as
the visitor's key: show a session only its own conversations. Create session
ids on your server and check them, so that nobody can make up someone else's
id. Chatvia creates random UUIDs and looks each one up for the agent (and,
on a protected agent, for the signed-in chatter).
CORS
The widget runs on other websites, so every call is a cross-origin request
with custom headers, and the browser sends a preflight OPTIONS request
first. Answer the preflight, and every other response, with:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept, Cache-Control, X-Agent-Id, X-Session-Id, X-Conversation-Id, X-Message-Id, X-Auth-TokenThe widget sends no cookies, so * is allowed. To keep an agent on its own
websites, compare the Origin header of each request with a list of sites
for that agent, and refuse the others. Chatvia does this with each agent's
allowed sites.
Time limits
The widget gives up on a JSON request after 30 seconds and on an upload after 2 minutes. A stream can run as long as it needs, but it fails after 2 minutes without new data.
Start-up
On each page load the widget:
- Calls
GET agents/verify, unless this browser already got a success for this agent. - Calls
GET agents/settingsand draws itself from the answer. - Calls
POST auth/exchange, only for a protected agent. - Calls
POST sessions/initto get its session. - Calls
GET conversations. When the list is empty, or the active conversation is not in it, it callsPOST conversations. - Calls
GET conversations/messagesfor the active conversation, andGET handoff/status. - Starts the heartbeat.
After that it waits for the visitor. Each message is one POST stream.
Endpoints
GET agents/verify
May this agent run on this page?
| Your answer | What the widget does |
|---|---|
| 2xx | Starts, and remembers the success in localStorage. |
Another status, like 403 |
Shows the window locked, with a "verification failed" notice. |
| No answer (network error) | Starts anyway. |
The widget reads only the status. Chatvia answers {"verified": true}, or
{"verified": false} with 403. Because a success is remembered, also check
the origin on the other endpoints when you need to block a site.
GET agents/settings
Everything the visitor sees. Every field is optional, but without
color_theme and color_shade the header and the buttons have no color.
{
"title": "Support",
"color_theme": "blue",
"color_shade": 600,
"theme": "auto",
"welcome_message": "Hi! How can I help?",
"composer_placeholder": "Ask a question…",
"starters": [{ "text": "Where is my order?" }, { "text": "Talk to sales" }],
"allow_file_uploads": false
}| Field | Meaning |
|---|---|
title |
The name in the header. |
color_theme, color_shade |
The brand color as a Tailwind palette and shade, from 50 to 950. The palettes are amber, black, blue, cyan, emerald, fuchsia, gray, green, indigo, lime, neutral, orange, pink, purple, red, rose, sky, slate, stone, teal, violet, yellow and zinc. |
theme |
auto, light (default) or dark. The embed tag's data-theme wins. |
logo_url, avatar_url |
The image in the header, and the image next to each answer. An https:// URL or a base64 data:image/… URL. |
welcome_message |
The first message of a new conversation. Markdown. |
composer_placeholder |
The text in the empty message box. |
chat_footer_text |
A line under the message box, like "AI can make mistakes." |
privacy_policy_url |
Shows a privacy notice above the message box, linking here. |
starters |
Conversation starters: strings, or { "text": "…", "icon": "…" }. |
bubbles, bubble_translations |
Short teaser messages next to the closed bubble, and their translations, one object per bubble: [{ "de": "Hallo" }]. |
text_translations |
title, composer_placeholder and welcome_message per language: { "title": { "de": "Hilfe" } }. |
window_size |
small, medium or large. |
position |
bottom-right or bottom-left. |
bar_placement |
Where the bar sits: top (default), top-right, bottom or bottom-right. |
keyboard_trigger |
The shortcut that opens the widget: cmd+k (default), cmd+j, cmd+/, / or shift+/. |
is_fullscreen |
true opens the chat full screen. |
show_on_load, show_on_load_delay |
true opens the chat by itself, after the delay in milliseconds. |
allow_file_uploads |
File uploads. On unless you send false. |
max_files_per_chat |
How many files one message can carry. Default 2. |
allow_voice_input |
true shows a microphone that dictates a message. See POST transcribe. |
voice_conversation |
true shows the button for a voice call. |
models |
The models the visitor may pick from, the agent's own first: [{ "id": 1, "name": "…", "logo": null, "is_default": true }]. With fewer than two, the widget shows no picker. |
chat_deletion_mode |
What the visitor's "remove this chat" does: end (default) starts a new chat and keeps the old one, hide takes it out of the visitor's history, delete erases it. The widget only changes its wording. Your server decides what DELETE conversations does. |
ai_disclosure |
false hides the "AI assistant" line under the title. "Human agent" still shows while a person answers. On unless you send false. |
widget_enabled, bar_enabled |
false hides that launcher completely. |
protected |
true when visitors must sign in. See POST auth/exchange. |
branding |
A "Powered by" line with the ChatVia mark: { "enabled": true, "name": "…" }. It always links to chatvia.ai. "chatvia_branding": false hides it too. |
reverb |
The WebSocket server for live chat: { "key": "…", "host": "…", "port": 443, "scheme": "https" }. |
When this request fails, the widget does not start.
POST sessions/init
Starts the visitor's session, or confirms the one the widget already has.
When the browser has a session or a conversation from an earlier visit, the
widget sends them as X-Session-Id and X-Conversation-Id.
{
"session_id": "5b0c1c9e-4f5a-4d47-9e43-6f0f8a2d2c11",
"conversation_id": "a1d6…",
"is_new_session": false,
"is_new_conversation": false
}session_id is required: without it the chat is disabled. Send back the
session the widget sent when it is valid, or a new one when it is not.
conversation_id is the conversation to open, or null.
GET conversations
The session's conversations, newest first, for the history list.
{
"conversations": [
{ "id": "a1d6…", "title": "Where is my order?", "timestamp": 1791880352 }
],
"active_conversation_id": "a1d6…"
}timestamp is in Unix seconds. title can be null. When this request
fails, the widget carries on with the conversation from sessions/init.
POST conversations
Creates an empty conversation for the session, and answers with it:
{ "id": "f42e…", "title": null, "timestamp": 1791880400 }id is required.
DELETE conversations
With X-Conversation-Id, deletes that conversation. Without it, deletes all
of the session's conversations. Answer with any 2xx status.
GET conversations/messages
The messages of the conversation in X-Conversation-Id, oldest first, as a
JSON array:
[
{ "id": "m1", "role": "user", "content": "Where is my order?", "created_at": "2026-10-13T09:12:00Z" },
{ "id": "m2", "role": "assistant", "content": "Your order **#1042** ships today.", "created_at": "2026-10-13T09:12:03Z", "feedback": "positive" }
]| Field | Meaning |
|---|---|
id |
Used for feedback. |
role |
user, assistant, system, or agent for a person in live chat. |
content |
The text. Markdown for answers. |
created_at |
An ISO 8601 string, or Unix seconds or milliseconds. |
feedback |
positive, negative or null. |
attachments |
The files of a visitor message, as returned by the upload. |
metadata.artifacts |
Forms in an answer. See POST forms/{id}/submit. |
llm_id |
The id of the model that wrote an answer. The widget shows its name when the agent offers a choice. |
POST stream
The visitor sent a message. The request carries X-Conversation-Id and this
body:
{
"agent_id": "support-bot",
"session_id": "5b0c…",
"conversation_id": "a1d6…",
"type": "message",
"content": "Where is my order?",
"url": "https://shop.example.com/orders",
"referrer": "https://www.google.com/",
"attachments": [],
"model_id": null
}url is the page the visitor is on. model_id is the model the visitor
picked from models, or null for the agent's own. Save the message, then answer with
Content-Type: text/event-stream and write the reply as Server-Sent Events.
Each event is one data: line of JSON and a blank line:
data: {"type":"start","id":"m2","data":{"assistant_message_id":"m2"}}
data: {"type":"delta","id":"m2","data":{"text":"Your order "}}
data: {"type":"delta","id":"m2","data":{"text":"**#1042** ships today."}}
data: {"type":"done","id":"m2","data":{}}type |
data |
What the widget does |
|---|---|---|
start |
{ "assistant_message_id": "m2" } |
Remembers the id of the answer, for feedback. |
delta |
{ "text": "…" } |
Adds the text to the answer. Send as many as you like. |
tool |
{ "phase": "start", "tool_call_id": "t1", "activity_label": "Checking your order…" } |
Shows the label while the tool runs. "phase": "end" hides it, and can carry artifacts. Without activity_label, the widget shows "Connecting to {label}…" or "Working…". |
replace |
{ "text": "…" } |
Replaces everything streamed so far with this text, for example an answer a safety check corrected. |
sources |
{ "sources": [{ "id": "…", "label": "Returns policy", "kind": "file", "url": null, "page": 3 }] } |
Lists the sources of the answer under it, once it is done. Chatvia sends the kinds file, media, website, text and video. |
error |
{ "code": "…", "message": "…" } |
Shows message, or a general error in the visitor's language. Then send done, or close the stream. |
meta |
{ "key": "handoff", "value": { "status": "pending" } } |
Switches to live chat and ends the turn. |
done |
{} |
Ends the turn. Closing the stream does the same. |
The widget ignores other types, so you can send more for other clients.
Chatvia also sends a sequence number (seq) and a version (v) on each
event.
When the server answers 429, 502, 503 or 504, the widget sends the
same message again, up to two times. Answer with those only when you saved
nothing.
Turn off buffering
A proxy that buffers or compresses the response makes the whole answer
arrive at once. For nginx, send the header X-Accel-Buffering: no. Send
Cache-Control: no-cache too.
During live chat, the widget posts the visitor's messages to stream with
Accept: application/json instead. Save the message, pass it to the person,
and answer with any 2xx status.
POST conversations/title
Asks the server to name the conversation in X-Conversation-Id from its
first question and answer. The widget calls it once, after the first answer.
Answer with { "title": "Where is my order?" }, or { "title": null } when
it has no title yet. A failure is only logged.
GET handoff/status
Who answers the conversation in X-Conversation-Id. Without live chat,
always answer:
{ "handoff_status": "ai" }With live chat, handoff_status is ai, pending (waiting for a person) or
human. Add admin_name, the person's name. The widget calls this after a
reload, so a running live chat comes back.
POST heartbeat
The widget is loaded on a page. The body is
{ "conversation_id": "a1d6…" }. Chatvia uses it to show which visitors are
online. Without live chat, answer 204.
POST feedback
The visitor rated an answer. The request carries X-Conversation-Id,
X-Message-Id and the body { "feedback": true } for thumbs up or
{ "feedback": false } for thumbs down. Answer with any 2xx status. Send
the rating back as feedback in the messages.
POST conversations/attachments
The visitor attached a file, before sending the message. The request is
multipart/form-data with one field, file. The widget offers PDFs.
{
"data": {
"id": "f1",
"filename": "invoice.pdf",
"size_bytes": 48211,
"mime_type": "application/pdf",
"url": "https://api.example.com/files/f1.pdf"
}
}The widget then sends this object in the attachments of the next
POST stream. To refuse a file, answer 422 with
{ "errors": { "file": ["The file is too large."] } }: the widget shows the
first message.
To turn uploads off, send "allow_file_uploads": false in the
settings.
POST transcribe
The visitor dictated a message with the microphone. The request is
multipart/form-data with one field, audio, the recording. Answer with
{ "text": "…" }, or { "text": "" } when nothing was heard. The widget puts
the text in the message box, and the visitor sends it. The widget shows the
microphone only when the settings have "allow_voice_input": true.
Voice calls
With "voice_conversation": true in the settings, the visitor can talk with
the agent in a live call over WebRTC. The widget posts { "sdp": "…" }, its
WebRTC offer, to voice with X-Conversation-Id. Answer with the WebRTC
answer and the call:
{ "sdp": "…", "call_id": "c1", "conversation_id": "a1d6…", "max_seconds": 600, "join_timeout": 10 }max_seconds is the longest the call may last. join_timeout is how many
seconds the widget waits for the server to join the call. To refuse a call,
answer with an error status and { "message": "…" }: the widget shows the
message. When the call ends, the widget calls
DELETE voice/calls/{call_id}.
POST forms/{id}/submit
An answer can hold a form that the visitor fills in. Send it in the end
phase of a tool event, and in the message's metadata.artifacts:
{
"kind": "form",
"data": {
"form_instance_id": "frm_123",
"status": "pending",
"title": "Book a demo",
"submit_label": "Send",
"success_message": "Thanks, we'll be in touch.",
"schema": [
{ "$formkit": "text", "name": "name", "label": "Name", "validation": "required" },
{ "$formkit": "email", "name": "email", "label": "Email", "validation": "required|email" }
]
}
}When the visitor sends the form, the widget posts
{ "fields": { "name": "…", "email": "…" }, "assistant_message_id": "m2" }
to forms/frm_123/submit.
| Your answer | What the widget shows |
|---|---|
2xx with { "success_message": "…" } |
The success message. |
409 (sent already) |
The form's own success_message. |
410 (expired) |
A notice that the form has expired. |
POST auth/exchange
Only for an agent whose settings say "protected": true. The website signs a
short-lived token for each visitor and puts it in the embed tag's data-auth.
The widget posts it once:
{ "token": "eyJhbGciOi…" }Check the token, and answer with a session token for the widget:
{ "auth_token": "wst_…", "expires_at": "2026-10-13T21:00:00Z" }The widget then sends X-Auth-Token on every request. When the exchange
fails, the widget shows the window locked. Chatvia's tokens are described in
Protected agents.
Live chat
Live chat lets a person take over a conversation from the bot. It needs a
WebSocket server that speaks the Pusher protocol, such as Laravel Reverb or
Soketi. Send where it runs in the settings' reverb field, or put it on the
embed tag. Without a key and a host, the widget doesn't connect, and live
chat is off.
The widget subscribes to the presence channel presence-chat.{conversation id}.
To join it, it posts socket_id and channel_name to broadcasting/auth,
with X-Session-Id, X-Agent-Id and X-Auth-Token. Answer like any Pusher
presence channel: { "auth": "…", "channel_data": "…" }.
On that channel, send these events:
| Event | Data | What the widget does |
|---|---|---|
handoff |
{ "status": "human", "admin_name": "Sara", "message": "…" } |
Changes who answers: pending, human, or ai when the bot takes over again. |
message |
{ "id": "…", "role": "agent", "content": "…", "sender_name": "Sara", "timestamp": "…" } |
Shows a message from the person, and opens the widget when it is closed. |
typing |
{ "is_typing": true, "typer_type": "agent" } |
Shows that the person is typing. The widget ignores "typer_type": "user". |
During live chat the widget also calls:
| Endpoint | Body | When |
|---|---|---|
POST stream |
The message body | The visitor sends a message. |
POST typing |
{ "conversation_id": "…", "is_typing": true } |
The visitor types. The widget first tries a client-typing whisper on the channel. |
POST mark-read |
{ "conversation_id": "…" } |
The visitor has read the person's messages. |
POST handoff/end |
{ "conversation_id": "…" } |
The visitor ends the live chat. |
A complete example
This Node.js server is a working backend: an echo bot with conversation
history. It has no packages, no database and no LLM. Replace the reply in
POST /widget/stream with your own logic.
// server.mjs: run with `node server.mjs`.
import http from 'node:http';
import { randomUUID } from 'node:crypto';
const conversations = new Map();
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers':
'Content-Type, Accept, Cache-Control, X-Agent-Id, X-Session-Id, X-Conversation-Id, X-Message-Id, X-Auth-Token',
};
function json(res, status, body) {
res.writeHead(status, { ...cors, 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
async function readJson(req) {
let body = '';
for await (const chunk of req) body += chunk;
return body ? JSON.parse(body) : {};
}
http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, cors);
return res.end();
}
const route = `${req.method} ${new URL(req.url, 'http://localhost').pathname}`;
const session = req.headers['x-session-id'];
const found = conversations.get(req.headers['x-conversation-id']);
// A visitor only sees the conversations of their own session.
const conversation = found && found.session === session ? found : undefined;
switch (route) {
case 'GET /widget/agents/verify':
return json(res, 200, { verified: true });
case 'GET /widget/agents/settings':
return json(res, 200, {
title: 'Echo bot',
welcome_message: 'Hi! Say something.',
color_theme: 'blue',
color_shade: 600,
allow_file_uploads: false,
});
case 'POST /widget/sessions/init':
return json(res, 200, {
session_id: session ?? randomUUID(),
conversation_id: conversation?.id ?? null,
});
case 'GET /widget/conversations':
return json(res, 200, {
conversations: [...conversations.values()]
.filter((c) => c.session === session)
.map(({ id, title, timestamp }) => ({ id, title, timestamp })),
active_conversation_id: null,
});
case 'POST /widget/conversations': {
const created = {
id: randomUUID(),
session,
title: null,
timestamp: Math.floor(Date.now() / 1000),
messages: [],
};
conversations.set(created.id, created);
return json(res, 201, { id: created.id, title: null, timestamp: created.timestamp });
}
case 'DELETE /widget/conversations':
// One conversation with X-Conversation-Id, otherwise all of the session's.
for (const c of conversations.values()) {
if (c.session === session && (!conversation || c === conversation)) conversations.delete(c.id);
}
res.writeHead(204, cors);
return res.end();
case 'GET /widget/conversations/messages':
return json(res, 200, conversation?.messages ?? []);
case 'POST /widget/stream': {
if (!conversation) return json(res, 404, { message: 'Unknown conversation' });
const turn = await readJson(req);
const now = new Date().toISOString();
const reply = { id: randomUUID(), role: 'assistant', content: `You said: ${turn.content}`, created_at: now };
conversation.messages.push({ id: randomUUID(), role: 'user', content: turn.content, created_at: now }, reply);
conversation.title ??= turn.content.slice(0, 40);
res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
const send = (type, data) => res.write(`data: ${JSON.stringify({ type, id: reply.id, data })}\n\n`);
send('start', { assistant_message_id: reply.id });
for (const word of reply.content.split(/(?<= )/)) send('delta', { text: word });
send('done', {});
return res.end();
}
// Called on every load, and the heartbeat every 30 seconds.
// Without live chat there is nothing to do.
case 'GET /widget/handoff/status':
return json(res, 200, { handoff_status: 'ai' });
case 'POST /widget/heartbeat':
res.writeHead(204, cors);
return res.end();
default:
return json(res, 404, { message: 'Not found' });
}
}).listen(8000);Start it, and embed the widget on any page:
<script id="chatbot-embed-script"
src="https://cdn.example.com/shakomako/embed.js"
data-agent-id="echo"
data-api-url="http://localhost:8000/widget"
defer></script>Only a sketch
This server accepts any session id the browser sends, and forgets everything
when it stops. A real backend creates and checks its session ids, stores
conversations, and checks the Origin of each request.