API reference
These are the endpoints the widget itself uses. You do not need them to embed the widget — the script tag handles all of it — but they are here if you are building your own client or debugging one.
Authentication
Every call except the session handshake needs two things: a bearer token from POST /v1/widget/session, and an Origin header matching the one the token was issued to. The token is bound to that origin, so a token lifted from one site is useless on another. Tokens last 30 minutes.
Create a session
POST /v1/widget/session
Origin: https://your-site.example
{ "public_key": "your-agent-public-key" }{
"token": "<jwt>",
"agent_display_config": {
"agent_name": "Acme Support",
"accent": "#6366F1",
"initial_message": "Hi, I'm Acme Support's AI agent...",
"suggested_questions": [],
"branding": true,
"locale": null
}
}The origin is checked against the agent's allow-list, which accepts bare hosts, wildcards such as *.example.com, and full origins. A request from an origin that is not allowed gets origin_not_allowed. An empty allow-list permits every origin.
Send a message
POST /v1/chat
Authorization: Bearer <token>
Origin: https://your-site.example
{ "message": "How do I cancel?", "conversation_id": "<uuid, optional>" }The response is a server-sent event stream. Frames arrive as data: <json> and the stream ends with data: [DONE].
data: {"type":"token","text":"You can cancel "}
data: {"type":"token","text":"from Settings."}
data: {"type":"done","message_id":"...","conversation_id":"...",
"citations":[{"n":1,"id":"...","title":"Billing FAQ","url":"https://..."}],
"credits_used":1}
data: [DONE]Omit conversation_id on the first message and use the one that comes back on the done frame for the rest of the conversation.
When a human has taken the conversation over, the stream carries no tokens: it returns a single done frame with message_id set to human_handled and no credits used. The visitor's message is still recorded, and your reply reaches them through the updates endpoint below.
Errors during a stream
{"type":"error","error":{"code":"credits_exhausted",...}} on a 200 response — not as a 402. A client that only checks the status code will treat an unanswered message as answered.Identify a visitor
POST /v1/identify
Authorization: Bearer <token>
Origin: https://your-site.example
{
"external_id": "usr_8134",
"hmac": "<64 hex characters>",
"traits": { "email": "alice@example.com", "name": "Alice Chen" }
}{ "verified": true, "contact_id": "<uuid>" }See Identity verification for how to compute the signature. A rejection returns token_origin_mismatch whether the signature was wrong or no secret is configured — the two are deliberately indistinguishable.
Submit a lead
POST /v1/widget/lead
Authorization: Bearer <token>
Origin: https://your-site.example
{ "fields": { "email": "alice@example.com", "name": "Alice Chen" },
"conversation_id": "<uuid, optional>" }Returns { "ok": true, "contact_id": "<uuid>" }. This endpoint is exempt from the per-session rate limit — a visitor who hit the message limit can still leave their address — but the per-IP limit still applies.
Poll for human replies
GET /v1/conversation/<id>/updates?after=<message_id> Authorization: Bearer <token> Origin: https://your-site.example
{
"handled_by": "human",
"messages": [{ "id": "...", "content": "Hi, Alice — I can help.", "author_name": null }]
}Returns up to 20 messages written by a human since after. This is how the widget shows a takeover without the visitor reloading.
Error envelope
Every error shares one shape:
{ "error": { "code": "rate_limited", "message": "Too many messages.", "retryable": true } }| Code | Status | Retryable | Means |
|---|---|---|---|
origin_not_allowed | 403 | No | The calling origin is not on the agent's allow-list. |
token_origin_mismatch | 401 | No | Missing or invalid token, an origin that does not match it, or a refused identify call. |
rate_limited | 429 | Yes | A rate limit was hit. |
credits_exhausted | 402 | No | The workspace is out of credits. In practice this arrives as a stream frame, not this status. |
agent_offline | 503 | Yes | The agent is unavailable. |
validation_failed | 400 | No | The request body did not validate. |
internal_error | 500 | Yes | Something failed on our side. |
Two deviations worth coding against: credits_exhausted arrives inside the stream rather than as a 402, as described above, and the updates endpoint returns agent_offline with a 404 when the conversation does not exist, rather than the 503 the code normally implies.
A grounded “I don't know” is not an error and has no code. It is a normal answer with no citations.
Data-subject requests
Two endpoints on the dashboard app help you answer an access or erasure request from one of your visitors. They authenticate with your dashboard session and are restricted to owners and admins.
| Endpoint | Does |
|---|---|
POST /api/gdpr/export | Returns everything held about one subject: contacts, conversations, messages, and copies that live outside the transcript. |
POST /api/gdpr/erase | Deletes it. Without confirm: true it reports what would go without touching anything. |
Name the subject with exactly one of external_id, email, end_user_id, or conversation_id — an anonymous visitor has no durable identifier, so a conversation id is the only way to identify them.