Skip to content

From Scratch (raw protocol) ​

Your mobile app, game engine, or server process needs to talk to Perfox — but there's no JavaScript runtime to drop a script tag into. This page walks you through the REST and WebSocket wire protocol directly, so you can build a working chat in any environment.

If you're on a JavaScript platform, the headless library implements everything on this page for you. Reach for this guide only when you genuinely can't run it.

What you'll build ​

By the end of this page you'll be able to send a message, receive the reply over a WebSocket, handle missed pushes with a fallback poll, attach a file, and optionally start a voice session — all from scratch, speaking the wire protocol directly. You'll need your API host (<slug>-api.perfox.ai), a Site key (sa_site_live_…) from the Install tab of your agent's Web Chat trigger, and, if you want verified identity, a server-side HMAC signing step.

The shape of a conversation ​

Open the socket first, send with delivery: "async", and match the pushed reply frame back to the 202 by its reply_id.

The handshake — every REST request ​

Every call to /api/public/widget/* carries two things:

http
POST /api/public/widget/init HTTP/1.1
Host: example-api.perfox.ai
X-Perfox-Site: sa_site_live_xxxxxxxxxxxxxxxx
Origin: https://example.com
Content-Type: application/json
  • X-Perfox-Site: <site_id> — your public Site key. Missing → 400 missing_site_id; unknown → 404 unknown_site; suspended → 403 site_suspended.
  • Origin — must be in the Site's allowed origins, or the request is rejected 403 origin_not_allowed. Browsers set this automatically; a server-to-server caller sets it explicitly.

The Host is your own API host (example-api.perfox.ai). The host you call is what reaches your workspace, so there's no workspace id to put in the body. Your data stays private to your workspace.

Identity (optional) — sign user_hash on your server ​

Every conversation starts anonymous. Anonymous chat needs none of this. When your visitor is signed in to your app and you want Perfox to trust who they are — so they can list their past chats, and so nobody else can claim their identity once you require verification — the identity must be verified with an HMAC your server computes:

js
// On YOUR server — never in the browser (the secret would leak).
import { createHmac } from 'node:crypto';

function sign_identity(site_id, external_id, raw_site_secret) {
  return createHmac('sha256', raw_site_secret)
    .update(`${site_id}.${external_id}`)   // canonical string: "<site_id>.<external_id>"
    .digest('hex');
}

The canonical string is exactly <site_id>.<external_id>; the key is the raw site secret (sa_secret_live_…). Hand the resulting hex to the client, which passes it as the identity's user_hash field on /init, /send, and the voice, dial-out and upload calls.

The identity itself is a user-context object — all fields optional: name (display only), phone, email, external_id (the stable customer anchor and the field HMAC signs), attributes (a free-form string→string map), user_hash, and tenant_session_token. Perfox resolves the trust level from what you send: no external_id → anonymous; external_id + valid user_hash → verified; external_id without user_hash → self_asserted, unless the site's Require HMAC identity verification toggle is on (then rejected 403 identity_verification_required). A hash that matches no secret is rejected 403 identity_verification_failed. The full multi-language signing spec and secret rotation are on Identity Verification (HMAC); the field set and its forwarding to your MCP tools are on User Context & Identity.

Step 1 — POST /init ​

Resolve the agent and the conversation, and learn what capabilities it offers.

js
const API  = 'https://example-api.perfox.ai';        // your API host
const SITE = 'sa_site_live_xxxxxxxxxxxxxxxx';      // your Site key

const saved = localStorage.getItem('perfox_conversation');   // resume after a reload (optional)

const init = await fetch(`${API}/api/public/widget/init`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Perfox-Site': SITE },
  body: JSON.stringify({
    ...(saved ? { conversation_id: saved } : {}),
    locale: navigator.language,                         // lets the agent answer in the visitor's language
    user_context: { name: 'Priya Sharma', external_id: 'cust_abc123' /*, user_hash */ },
  }),
}).then((r) => r.json());

// → { conversation_id, customer_id, workflow_id, auth_level,
//     capabilities: { voice_chat, dial_out, file_upload, show_history, allow_new_chat, allow_export },
//     capability_warnings, ui, starters?, input_placeholder?, next_actions_enabled }
const conversation_id = init.conversation_id;
localStorage.setItem('perfox_conversation', conversation_id);

Optional body fields: conversation_id (resume a conversation you were given earlier — an anonymous visitor resumes only an anonymous conversation, an identified one only their own), workflow_id (pin a specific published agent; otherwise the agent the Site is bound to answers), locale, and user_context. An identified visitor with no conversation_id resumes their open web conversation if they have one.

The conversation itself is created on the first /send, so opening the chat never leaves an empty conversation behind. Gate any voice, call, or attach UI on init.capabilities, and render ui (display name, avatar, colour, greeting, starters) however you like.

Step 2 — open the reply WebSocket ​

Open this before the first async send, so the reply has somewhere to land.

js
const ws_base = API.replace(/^http/, 'ws');   // https→wss, http→ws
const ws = new WebSocket(
  `${ws_base}/api/chat/connect?site_id=${SITE}&conversation_id=${conversation_id}`,
);

Why site_id and conversation_id are query params. A browser can't set custom headers on a WebSocket upgrade, so they ride in the URL. The user_hash is deliberately never in the URL — identity was already proven at /init / /send. The upgrade runs the same Origin + Site verification: a missing param is 400; a bad origin or unknown site is 403. The conversation id is unguessable and only ever handed to your /init call, so it acts as the key to that conversation's socket.

The socket is receive-only — bound to the one conversation from the handshake; anything you send on it is ignored. The server sends a keep-alive { "type": "ping" } frame every 30 seconds or so. If you hear nothing at all for about 75 seconds, treat the socket as dead, reconnect, and use the fallback poll.

Step 3 — POST /send with delivery: "async" ​

js
async function send(text) {
  render_user_bubble(text);                       // optimistic echo

  const ack = await fetch(`${API}/api/public/widget/send`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Perfox-Site': SITE },
    body: JSON.stringify({ conversation_id, customer_id: init.customer_id, text, delivery: 'async' }),
  }).then((r) => r.json());

  // → 202 { accepted: true, message_id, reply_id, server_time }
  pending.add(ack.reply_id);                       // remember it to match the WS frame
}

The reply is pushed over the WebSocket and correlated by reply_id. Delivery is cross-node safe — the turn may run on a different backend than the one holding your socket, and the reply still finds its way to the right open socket.

Other /send body fields: user_context (send the identity on every call), workflow_id, locale, file_refs (see Upload), and structured_data (the machine-readable values from an interactive input, alongside the human-readable text).

Use delivery: "async" only while the socket is open. Omit delivery (or send "sync") for a server-to-server caller with no socket: the request then blocks and returns { text, message_id } in the 200 body (plus attachments, charts, cards, rich or suggested_actions when present). It can also fail with 503 workflow_provisioning or 500 workflow_execution_failed.

Step 4 — render the pushed frames ​

The socket carries several frame types. Handle the ones you need and ignore any type you don't recognise:

js
let streaming = '';
ws.onmessage = (ev) => {
  let frame;
  try { frame = JSON.parse(ev.data); } catch { return; }
  switch (frame.type) {
    case 'reply_delta':                        // text so far, while the agent is still writing
      streaming += frame.text;
      render_streaming_bubble(streaming);
      break;
    case 'reply':
      if (frame.unsolicited) { render_agent_bubble(frame); break; }   // a message nobody asked for
      if (!pending.has(frame.reply_id)) break;
      pending.delete(frame.reply_id);
      streaming = '';
      render_agent_bubble(frame);              // the final text REPLACES the streamed preview
      break;
    case 'error':
      if (!pending.has(frame.reply_id)) break;
      pending.delete(frame.reply_id);
      streaming = '';
      render_error(frame.text);                // frame.code: workflow_provisioning | workflow_execution_failed
      break;
    case 'suggested_actions':                  // follow-up chips for the reply you just rendered
      attach_chips(frame.reply_id, frame.suggested_actions);
      break;
    // 'task_status', 'ui:command' and 'ping' — see below
  }
};

The frame shapes are:

jsonc
// streamed preview — zero or more per turn; append in order
{ "type": "reply_delta", "text": "…" }

// the finished turn — ONE per assistant turn; authoritative
{ "type": "reply", "reply_id": "…", "text": "…",
  "attachments": [ /* files */ ],             // each key present only when non-empty
  "charts": [ … ], "cards": [ … ],
  "rich": [ /* ordered blocks: cards | table | compare | detail | chart */ ],
  "suggested_actions": [ "…" ] }

// a server-initiated message (for example a follow-up after login) — no reply_id to match
{ "type": "reply", "unsolicited": true, "text": "…" }

// failure
{ "type": "error", "reply_id": "…", "code": "workflow_execution_failed", "text": "…" }

// follow-up chips, sent after the reply they belong to
{ "type": "suggested_actions", "reply_id": "…", "suggested_actions": [ "…" ] }

Streaming is a preview: the final reply frame always carries the complete text, so if you'd rather not stream, ignore reply_delta. When rich is present, render it instead of charts and cards.

Two more frame types exist for richer clients. task_status reports a background job the agent started (for example "Reading your document") with task_key, status and label — show a status chip, and disable input while a blocking task is open. ui:command carries a command for the host page; see UI-command bus.

Step 5 — the fallback poll ​

A push can be missed. Make delivery self-healing by polling /history while a reply_id is still pending:

js
async function poll_history() {
  const page = await fetch(
    `${API}/api/public/widget/history?conversation_id=${conversation_id}&limit=20`,
    { headers: { 'X-Perfox-Site': SITE } },
  ).then((r) => r.json());
  // page → { messages, has_more, next_before }
  reconcile(page.messages);   // upsert by id — the awaited assistant turn appears here too
}

limit defaults to 20 (maximum 200). Page backwards for scroll-up by passing the previous next_before as before. The same endpoint replays the whole transcript when you resume a conversation after a reload.

Worked example — Priya asks about her test results ​

Setup. Asha, an admin at Acme Diagnostics, has configured a Web Chat trigger with file upload enabled. The agent can look up lab reports through Acme's own MCP tools.

Action. Priya opens the Acme Diagnostics chat — a custom native iOS app built on this protocol. Behind the scenes the app calls /init with external_id: "cust_abc123" and a user_hash signed on Acme's server. It opens the WebSocket, then she types: "Can you tell me about my last blood panel?"

Result. The /send call returns a 202 within milliseconds. The answer streams in over the socket, then a reply frame delivers the final text and an attachments array containing a signed link to her PDF report. The app renders the bubble and the download button without polling.

What just happened. Because Acme's server signed Priya's identity, Perfox accepted cust_abc123 as genuine and forwarded it to Acme's tools, which returned her report. The agent composed its reply and the socket delivered it — first as a live preview, then as one complete frame.

Upload ​

Attaching a file is two steps: upload it, then send it with a message.

POST /api/public/widget/upload (multipart, X-Perfox-Site + Origin) accepts one file field (up to 25 MB) plus conversation_id, and optionally customer_id, workflow_id and user_context (as a JSON string). It's gated by the Web Chat trigger's Enable file upload switch plus a prerequisite check — off → 403 file_upload_not_enabled (and 503 no_workflow_configured / file_upload_prereq_failed / missing_credential when uploads aren't ready); an unsupported type → 400 unsupported_type. The file is stored privately and its content extracted by type: images → vision, PDF/Word/Excel → vision/OCR/structured extraction, audio → transcription, video → frames + audio, text formats → read as-is. The response is { upload_id, file_id, file_name, file_url, file_key, mime_type, status: "ready" }.

Then send it: POST /send { conversation_id, text, file_refs: ["<upload_id>"], … } — up to 5 files per message, and text may be empty. A file that isn't ready yet makes /send return 409 attachments_not_ready; check it with GET /api/public/widget/upload/<upload_id>?conversation_id=<id>. See File Upload.

Voice ​

Voice is not on this WebSocket. Both voice capabilities are opt-in, gated by a Web Chat trigger switch plus a prerequisite check, and reported in /init capabilities (with capability_warnings when a switch is on but something it needs is missing).

  • Browser voice (Enable voice chat): POST /api/public/widget/voice/start { conversation_id, user_context } returns { livekit_url, livekit_token, room_name, voice_session_id, voice_mode, voice_animation_style }. Get microphone permission before you call it. /voice/inject_text { voice_session_id, text } pushes a typed turn into the call; /voice/stop { voice_session_id } ends it. Disabled → 403 voice_chat_not_enabled.
  • Outbound dial-out (Enable dial-out (Call Me)): POST /api/public/widget/call { phone_number, conversation_id, customer_id, user_context } places an outbound phone call using the same persona and tools and returns { call_id, status: "calling", phone }. An undialable number → 400 invalid_phone_number; disabled → 403 dial_out_not_enabled.

You join the real-time voice session with the URL and token from /voice/start using a LiveKit client SDK for your platform, publish the microphone, and read captions off the session's data channel (topic transcript). The raw caption frame is:

jsonc
{ "type": "transcript",
  "speaker": "user" | "assistant",   // raw wire field is `speaker` (the library renames it `role`)
  "text": "…",
  "partial": true }                  // true = live delta (append); false = consolidated turn-end (replace)

The same channel also carries session_event frames — for example agent_ready when the greeting has finished (unmute the mic then), agent_busy / agent_idle while the agent looks something up, and conversation_ended when the agent ends the call.

Note the field is speaker here, where the headless library's transcript event uses role. Same values (user / assistant); the library normalizes the name. See Voice & Dial-Out.

You can now build a full custom conversation experience ​

You know how to open a conversation, prove identity, send messages, receive replies, handle missed pushes, attach files, and start a voice session — all without any Perfox JavaScript dependency.

Where to go next:

  • Overview (3 tiers) — see where this tier fits relative to the drop-in widget and the headless library, and decide whether you still need the raw protocol.
  • Headless Library (@perfox/widget-core) — if you're on a JavaScript platform, this implements everything above for you and saves the wiring.
  • Public Widget API — the full endpoint and frame schemas, for when you need every field documented precisely.
  • Identity Verification (HMAC) — multi-language signing examples and secret rotation, so you can implement the user_hash step in any backend language.
  • User Context & Identity — the full user-context field set and how trust levels work.
  • File Upload — upload limits, extraction details, and what the agent sees.
  • Voice & Dial-Out — voice modes and the complete voice lifecycle.