Skip to content

Ask the docs

Answers come only from these docs and are written by an AI model (Gemini), so check the linked pages. Your question is not kept. Do not include personal details or keys.

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

CallWhat 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 /history200 { 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>.jsThe chat box’s script, no session needed
  • A new anonymous visitor sends {}. The answer’s visitor is 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 has signed_in: true and no visitor. On 401 identity_invalid, start an anonymous session instead.
  • expires_at is in milliseconds since the epoch. Start a new session before it, and on any 401 session_invalid.

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.

With Accept: text/event-stream, the answer is a stream of:

SSE eventData
hello{ cursor }, first, once the stream is open
messageOne 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).

POST /v1/web-chat/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" }
}
  • id is your own id for the message: 1 to 64 letters, digits, _, ., : or -. Send the same id on every retry; the agent takes it once.
  • A tap on a button or a list row is text: <label>, choice: { id, title: <label> }.
  • page and context together are at most 1 KB, and page.url must be on an allowed site.
  • 202 { id } means the agent’s store has the message.
  • The visitor’s own message comes back as a message event (from: "person"), so every tab shows it.

An event is { seq, at, type, ... }, at in milliseconds since the epoch:

typeFields
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?
carouselid, text, cards ([{ image: { url }, text?, buttons?, link? }])
reactionmessageId, emoji

Not on the web chat yet: files from visitors, edits and unsends.

Errors are { error, message, retry_after? }; a 429 also sends Retry-After in seconds.

StatuserrorWhat to do
400bad_requestFix the request; message says what is wrong
401session_invalidStart a new session
401identity_invalidStart an anonymous session
403origin_not_allowedAdd the site to origins
413too_largeThe body is over 16 KB
429rate_limitedWait retry_after seconds (limits)
503not_ready, not_storedSend the same request again after retry_after
500failedTry 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:

Minimal web chat client
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.