Web chat HTTP contract
Web chat APIBuild your own chat box, or a mobile app's chat, against the routes webChat() serves: sessions, history, events over SSE or long-polling, and messages.
The web chat’s script is one client of this contract. Use it to build a box
of your own. Narrow webChat({ info }) to what your box can show, so the agent sends only that.
Every route is under the channel’s base, /v1/web-chat by default. Bodies are JSON. Every call
but POST /session and the script files needs Authorization: Bearer <session>. Browsers on the
allowed sites get CORS headers; any other Origin gets
403 origin_not_allowed.
Routes
Section titled “Routes”| Call | What it does |
|---|---|
POST /session { visitor?, identity? } | 200 { session, expires_at, signed_in, visitor?, limits }: a session for 1 hour. limits.message_chars is the longest message |
GET /history | 200 { messages, cursor }: the conversation on this channel, oldest first, and the newest event number |
GET /events?after=<seq> | The events after seq: server-sent events with Accept: text/event-stream, else a long-poll |
POST /messages { id, text, choice?, page?, context? } | 202 { id } once the agent’s store has the message |
GET /widget.js, GET /widget/<name>.js | The chat box’s script, no session needed |
Sessions
Section titled “Sessions”- A new anonymous visitor sends
{}. The answer’svisitoris their credential: store it. - A returning visitor sends the stored
visitor. The answer has it renewed, valid 30 days from now: store the new one. - A signed-in visitor sends
identity, a token from your backend (signed-in visitors). The answer hassigned_in: trueand novisitor. On401 identity_invalid, start an anonymous session instead. expires_atis in milliseconds since the epoch. Start a new session before it, and on any401 session_invalid.
History and the cursor
Section titled “History and the cursor”GET /history returns up to the last 200 entries. Entries older than the outbox, which keeps a
week, have seq: 0. Draw the history, then read events after its cursor.
Events
Section titled “Events”With Accept: text/event-stream, the answer is a stream of:
| SSE event | Data |
|---|---|
hello | { cursor }, first, once the stream is open |
message | One event (below); its seq is also the SSE id |
live | { typing, status }: whether the agent is typing, and its status line or null |
reset | {}: the cursor cannot be continued (older events deleted). Read GET /history again |
reconnect | { cursor }, after 14 minutes: open a new stream after the last event you showed |
A comment line (: ping) comes every 15 seconds.
Without that header, the call is a long-poll that waits up to 25 seconds:
200 { events, cursor, live }, or 200 { reset: true }.
Remember the last seq you showed. Show each seq once, and ignore one you already showed:
after a reconnect, a restart or a deploy, ask for after=<last seq shown> and nothing is lost or
repeated. One visitor may hold 6 event requests open at once (their tabs).
Messages
Section titled “Messages”{ "id": "m-7f3a2c", "text": "Is this one still available?", "page": { "url": "https://www.example.com/listing/ps5", "title": "PS5 Disc" }, "context": { "listing": "ps5-disc" }}idis your own id for the message: 1 to 64 letters, digits,_,.,:or-. Send the sameidon every retry; the agent takes it once.- A tap on a button or a list row is
text: <label>, choice: { id, title: <label> }. pageandcontexttogether are at most 1 KB, andpage.urlmust be on an allowed site.202 { id }means the agent’s store has the message.- The visitor’s own message comes back as a
messageevent (from: "person"), so every tab shows it.
Events
Section titled “Events”An event is { seq, at, type, ... }, at in milliseconds since the epoch:
type | Fields |
|---|---|
message, from: "agent" | id, Markdown text, and optionally replyTo, buttons ([{ id, label }]), list ({ button, rows: [{ id, title, description? }] }), link ({ label, url }), image ({ url }) |
message, from: "person" | id, text, choice? |
carousel | id, text, cards ([{ image: { url }, text?, buttons?, link? }]) |
reaction | messageId, emoji |
Not on the web chat yet: files from visitors, edits and unsends.
Errors
Section titled “Errors”Errors are { error, message, retry_after? }; a 429 also sends Retry-After in seconds.
| Status | error | What to do |
|---|---|---|
| 400 | bad_request | Fix the request; message says what is wrong |
| 401 | session_invalid | Start a new session |
| 401 | identity_invalid | Start an anonymous session |
| 403 | origin_not_allowed | Add the site to origins |
| 413 | too_large | The body is over 16 KB |
| 429 | rate_limited | Wait retry_after seconds (limits) |
| 503 | not_ready, not_stored | Send the same request again after retry_after |
| 500 | failed | Try again |
A minimal client in the browser
This sketch uses long-polling only. AGENT is the agent’s address, and show(event) is your own
function that draws an event:
const AGENT = "https://agent.example.com/v1/web-chat";const stored = localStorage.getItem("chat-visitor") ?? undefined;const s = await (await fetch(`${AGENT}/session`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ visitor: stored }),})).json();if (s.visitor) localStorage.setItem("chat-visitor", s.visitor);const auth = { authorization: `Bearer ${s.session}` };
const { messages, cursor } = await (await fetch(`${AGENT}/history`, { headers: auth })).json();messages.forEach(show);let last = cursor;
async function send(text: string) { const id = crypto.randomUUID(); await fetch(`${AGENT}/messages`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ id, text, page: { url: location.href } }), });}
for (;;) { const got = await (await fetch(`${AGENT}/events?after=${last}`, { headers: auth })).json(); if (got.reset) break; // read the history again for (const event of got.events) if (event.seq > last) (last = event.seq), show(event);}A production client also renews the session before expires_at, retries failed sends with the
same id, honours retry_after and prefers server-sent events.
Thanks, we read every comment.