Skip to content
Chatvia Docs

Shakomako widget

Backend API

Get in touch: Ahmad Mayahi, creator of Chatvia, Spectra and Shakomako

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:

http
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

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

  1. Calls GET agents/verify, unless this browser already got a success for this agent.
  2. Calls GET agents/settings and draws itself from the answer.
  3. Calls POST auth/exchange, only for a protected agent.
  4. Calls POST sessions/init to get its session.
  5. Calls GET conversations. When the list is empty, or the active conversation is not in it, it calls POST conversations.
  6. Calls GET conversations/messages for the active conversation, and GET handoff/status.
  7. 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.

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

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

json
{
  "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:

json
{ "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:

json
[
  { "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:

json
{
  "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:

text
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:

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

json
{
  "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:

json
{ "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:

json
{
  "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:

json
{ "token": "eyJhbGciOi…" }

Check the token, and answer with a session token for the widget:

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

js
// 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:

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

Search guides, settings and commands.