Are you an LLM? You can read better optimized documentation at /docs/api/widget-api.md for this page in Markdown format
Public Widget API
You've built your own chat UI — or a mobile screen — and now you need to know exactly what to call. This page documents every HTTP and WebSocket endpoint the Perfox widget speaks, so you can drive the same AI conversation from any client.
The prebuilt widget calls all of these for you automatically. This reference is for the case where you replace the widget with your own chat UI and talk to Perfox directly.
Mount points
Every endpoint lives under /api/public/widget on your workspace's API host, https://<your-workspace>-api.perfox.ai:
| Method + path | Purpose |
|---|---|
POST /api/public/widget/init | Open a session, resolve the agent, return capabilities. |
POST /api/public/widget/send | Send a visitor message and run the agent. |
GET /api/public/widget/history | Page the transcript. |
GET /api/public/widget/conversations | List a verified visitor's past conversations. |
POST /api/public/widget/conversations/new | Start a fresh conversation. |
POST /api/public/widget/upload | Upload a file. |
GET /api/public/widget/upload/{upload_id} | Check an upload's processing status. |
POST /api/public/widget/file_url | Re-sign a stale media URL. |
POST /api/public/widget/transcribe | Turn a recorded voice note into text. |
GET /api/public/widget/tasks | List a conversation's open background tasks. |
POST /api/public/widget/tasks/cancel | Cancel one of them. |
POST /api/public/widget/voice/start | Open a browser voice session. |
POST /api/public/widget/voice/inject_text | Push a text turn into a live voice session. |
POST /api/public/widget/voice/stop | End a voice session. |
POST /api/public/widget/call | Ask the agent to phone the visitor. |
All of these accept cross-origin requests (any origin, no cookies). Replies can also be pushed to the browser over a WebSocket at /api/chat/connect — a separate mount, because it is the delivery channel rather than a resource.
Authentication
Send two headers on every request:
X-Perfox-Site: sa_site_live_…— your public site ID.Origin: https://your-allowed-origin— must match your site's allowed-origins list (case-insensitive exact match; a missingOriginis rejected).
The identity level Perfox assigns depends on the user_context you send:
Sent in user_context | Resulting auth_level |
|---|---|
no external_id | anonymous |
external_id + a valid user_hash | verified |
external_id without user_hash | self_asserted — unless your site's Require identity verification setting is on, in which case the request is rejected |
The user_hash is an HMAC-SHA256 of the canonical string <site_id>.<external_id>, keyed with your site secret (sa_secret_live_…), and is checked against every active and expiring secret. See Identity Verification.
POST /send — sending a message
The body carries the message and the conversation it belongs to:
| Field | What |
|---|---|
conversation_id | The conversation from /init (or /conversations/new). |
customer_id | The customer from /init. |
text | The visitor's message. May be empty only when file_refs is present. |
file_refs | Up to 5 upload_ids from /upload to attach to this message. |
user_context | The visitor's identity (and user_hash), re-checked on every send. |
delivery | async to get 202 and the reply over the WebSocket; omit (or sync) to get the reply in the HTTP response. |
Synchronous (the default): the response body is the reply — { "text", "message_id" } plus attachments, charts, cards, rich and suggested_actions when the reply has them.
Asynchronous ("delivery": "async"): the request returns 202 Accepted immediately, freeing the connection:
json
{ "accepted": true, "message_id": "…", "reply_id": "…", "server_time": "…" }The reply is then pushed over the WebSocket, tagged with the same reply_id. Open the socket before you send. The message:received SDK event fires when the push lands.
While a background task in the conversation is still running and blocking input, /send returns 409 task_in_progress with the task's label.
GET /connect (WebSocket) — reply push
wss://<your-workspace>-api.perfox.ai/api/chat/connect?site_id=…&conversation_id=…Why credentials are query params. Browsers can't set custom headers like X-Perfox-Site on a WebSocket upgrade, so site_id and conversation_id ride as query params. The Origin is read from the upgrade headers. user_hash is deliberately never in the URL — identity was already proven at /init and /send; the socket only receives.
Upgrade gate (checked before the socket opens):
- Missing
site_idorconversation_id→ rejected with400. - Site or Origin check fails → rejected with
403.
Receive-only. The socket is bound to the one conversation from the handshake; there is no join message and anything the client sends is ignored. If authorization is lost after connecting, the socket closes with code 1008. The server sends a { "type": "ping" } frame every 30 seconds to keep the connection alive — ignore it, but treat any frame as proof the socket is healthy, and reconnect if nothing at all arrives for about 75 seconds (a half-open connection can still report itself as open).
Frames the server pushes:
jsonc
// streamed text while the agent is still writing (see "Streaming" below)
{ "type": "reply_delta", "text": "<next chunk>" }
// the finished turn — authoritative
{ "type": "reply",
"reply_id": "<the id from the 202>",
"text": "<assistant text>",
"attachments": [ /* … */ ], // optional arrays, present ONLY when non-empty
"charts": [ /* … */ ],
"cards": [ /* … */ ],
"rich": [ /* … */ ],
"suggested_actions": [ /* … */ ] }
// follow-up suggestion chips, arriving after the reply they belong to
{ "type": "suggested_actions", "reply_id": "…", "suggested_actions": ["…"] }
// the turn failed
{ "type": "error",
"reply_id": "<same reply_id>",
"code": "workflow_provisioning" | "workflow_execution_failed",
"text": "<human-readable message>" }A reply with "unsolicited": true was started by the platform rather than by a /send — for example a follow-up after the visitor signs in, or a human agent taking over. It has no pending request to match; render it as a new assistant message. task_status frames report background-task progress, and ui:command frames carry page commands for sites that have enabled them.
Failure code workflow_provisioning means the agent's dedicated capacity is still starting up; workflow_execution_failed means the turn failed to run.
GET /history — transcript pagination
GET /api/public/widget/history?conversation_id=…&limit=20&before=…| Query param | Default | Meaning |
|---|---|---|
conversation_id | — (required; absent → 400) | The conversation to page. |
limit | 20 | Page size, capped at 200. |
before | — | ISO-timestamp cursor: returns only messages strictly older than it. An invalid cursor is ignored (you get the newest page), not a 400. |
Each call returns the most recent limit messages older than the cursor, in chronological order (oldest first within the page). Visitor messages, agent replies, human-agent messages and uploaded files are included; tool activity and internal status events are not. Each message has id, role (user or assistant), text and timestamp, plus attachments and any rich content it had.
json
{ "messages": [ … ], "has_more": true, "next_before": "2026-06-25T09:29:12.000Z" }Page backwards by passing next_before as the next before, and stop when has_more is false. Media links are re-signed as they are read, so an old attachment still opens.
GET /conversations and POST /conversations/new
GET /api/public/widget/conversations lists a returning visitor's past conversations (newest first, limit default 30, max 100), each with conversation_id, title, preview and updated_at. Pass the visitor's external_id (or phone / email) and user_hash as query parameters. Only a verified visitor gets a list — anyone else gets { "conversations": [] }, so one visitor can never list another's conversations.
POST /api/public/widget/conversations/new with { "user_context": … } closes the visitor's open conversation and returns a fresh { "conversation_id", "customer_id" }.
POST /file_url — re-sign a media URL
Media URLs expire; the durable file_key does not. Refresh a stale URL without an admin session:
POST /api/public/widget/file_url
{ "conversation_id": "…", "file_key": "…" } → { "url": "…" }The key must belong to the named conversation (or be a downloadable Knowledge Base file), so a visitor can only refresh media they were shown. Anything else returns 403 forbidden.
POST /upload — file upload
POST /api/public/widget/upload is a multipart request with one file field of up to 25 MB, plus optional conversation_id, customer_id and workflow_id. An unsupported file type returns 400 unsupported_type; more than 5 files still processing in one conversation returns 400 too_many_files.
The endpoint is gated by the Web Chat trigger's Enable file upload toggle. If it's off you get 403 file_upload_not_enabled; it fails with 503 no_workflow_configured, file_upload_prereq_failed or missing_credential when uploads are not ready to use.
The file is stored (nothing to configure on your side) and its content is extracted so the agent can read it — images are analysed, documents and spreadsheets are read, audio is transcribed, video is described. The response:
json
{ "upload_id": "…", "file_id": "…", "file_name": "…", "file_url": "…", "file_key": "…", "mime_type": "…", "status": "ready" }Attach the file to a message by sending its upload_id in file_refs on /send. GET /api/public/widget/upload/{upload_id}?conversation_id=… returns its status, parsed_chars, and — once written — a short summary (or an error_message if processing failed). Very long documents are also indexed, so the agent can search them instead of reading them whole. Full reference: File Upload.
POST /transcribe — voice notes
Send a recorded clip as a multipart file (an audio/* type, up to 25 MB) and get { "transcript": "…" } back, ready to put in the message box. A non-audio file returns 400 not_audio; silence returns 422 empty_transcript.
GET /tasks and POST /tasks/cancel
Some agent actions run in the background and show a progress card. GET /api/public/widget/tasks?conversation_id=… returns { "tasks": [...] } for the conversation's open tasks, and POST /api/public/widget/tasks/cancel with { "conversation_id", "task_key" } cancels one, returning { "cancelled": true | false }.
Voice — /voice/start, /voice/inject_text, /voice/stop, /call
The widget has two opt-in voice capabilities, each gated by a Web Chat trigger toggle plus a prerequisite check. Both surface in /init's capabilities — { voice_chat, dial_out, file_upload } — with capability_warnings when a toggle is on but something it needs is missing.
Browser voice chat — the Enable voice chat toggle:
POST /api/public/widget/voice/startreturnslivekit_url,livekit_token,room_nameandvoice_session_id, plusvoice_modeandvoice_animation_style(how the widget should draw the session). The browser joins the room directly with the LiveKit client SDK.POST /api/public/widget/voice/inject_textwith{ voice_session_id, text }pushes a text turn into the live session — for example the contents of a file uploaded mid-call.POST /api/public/widget/voice/stopwith{ voice_session_id }ends the session and saves the recording.
voice/start can also return 403 voice_chat_not_enabled, 503 voice_chat_prereq_failed, 402 out_of_credits, or 429 concurrent_voice_limit_reached when too many voice sessions are already running. Recordings (caller, agent and a combined stereo mix) are stored for you — nothing to configure.
Dial-out — the Enable dial-out toggle (requires an outbound number on your connected voice provider):
POST /api/public/widget/callwith{ phone_number, user_context }places a phone call to the visitor with the same persona and tools, and returns{ call_id, status: "calling", phone }. Disabled →403 dial_out_not_enabled; a bad number →400 invalid_phone_number; missing setup →503 dial_out_prereq_failed.
Full lifecycle: Voice & Dial-Out.
Streaming — text and voice
Text. How a text reply arrives is set on the Web Chat trigger's Response delivery option:
- Stream as written (the default) — over the WebSocket,
reply_deltaframes carry the reply as the agent writes it. Append eachtextchunk to a live bubble. When the finishedreplyframe arrives, replace the bubble's text with the reply'stext: the final frame is authoritative, and it can differ from what streamed (for example when personal details were masked at the end of the turn). - Send complete reply — no deltas; one
replyframe per answer, with any table or chart validated first.
Streaming needs the WebSocket, so it only happens with "delivery": "async". A turn can also fall back to a single complete reply when the agent's settings require the whole answer to be checked before it is shown. Either way, a client that ignores reply_delta and renders only reply frames still works.
Voice. Voice transcripts never come over the chat WebSocket. After /voice/start, each transcript is sent on the voice room's data channel under the transcript topic:
jsonc
{ "type": "transcript",
"speaker": "user" | "assistant", // note: `speaker`, not `role`
"text": "…",
"timestamp": 1750000000000,
"partial": true } // true = live delta (render, then replace); false = consolidated turn-endRender partial: true deltas as a live caption and replace them when the consolidated partial: false frame for the turn arrives.
Worked example — a visitor sends a message
Acme Support has a custom chat UI built against this API. Here's what happens when Priya, a customer, types "What's the status of my return?" and presses send:
Setup. Asha, the Acme Support admin, left the Web Chat trigger's Response delivery on Stream as written. Priya's browser opened the WebSocket at /api/chat/connect when the chat panel opened.
Action. Priya's UI calls POST /api/public/widget/send with her message, conversation_id, "delivery": "async" and a user_hash proving she's a verified user. The server answers 202 Accepted with a reply_id.
Result. Within a couple of seconds reply_delta frames start arriving and the answer appears word by word. Then a reply frame lands with the same reply_id and the final text: "Your return is in transit and expected by Thursday." Priya's UI swaps in the final text.
What just happened. The 202 freed the HTTP connection immediately, the deltas let Priya see the answer begin before it was finished, and the reply_id on the final frame tied it to her message.
Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | missing_site_id | X-Perfox-Site header absent. |
| 404 | unknown_site | No site with that ID. |
| 403 | site_suspended | The site is suspended. |
| 403 | origin_not_allowed | Origin not in your allowed-origins list. |
| 403 | identity_verification_required | external_id supplied without user_hash on a site that requires it. |
| 403 | identity_verification_failed | user_hash didn't match any active or expiring secret. |
| 403 | voice_chat_not_enabled / dial_out_not_enabled / file_upload_not_enabled | The capability's toggle is off. |
| 409 | task_in_progress | A blocking background task is still running. |
| 503 | no_workflow_configured | The site is not bound to a published agent. |
See also
You can now drive a complete conversation — text, voice, and file sharing — from any client against these endpoints. To go further:
- Site Management — manage the site ID and signing secrets that guard every request above.
- Identity Verification — compute and rotate the
user_hashthat proves a visitor's identity. - From Scratch (raw protocol) — a complete client implementation against these endpoints.
- Voice & Dial-Out · File Upload · window.Perfox SDK