Skip to content

The HelpPuff protocol ​

Version v1. JSON in, JSON out, over HTTPS.

The protocol is the contract between a widget and a server. The widget knows nothing about which AI backend answers; the server knows nothing about how the widget renders. Either side can be replaced with your own implementation in any language.

The normative definition is the Zod schemas in packages/protocol/src in the repository. Where this page and the schemas disagree, the schemas win.


Endpoints ​

MethodPathPurpose
GET/v1/sites/:siteId/configPublic widget config
POST/v1/sites/:siteId/sessionsStart a session
POST/v1/sessions/messagesSend a message or an action
GET/v1/sessions/messages?after=:idPoll for new messages
POST/v1/sessions/feedbackRate a reply (thumbs up/down)
POST/v1/sites/:siteId/retell/kbKnowledge lookup for a Retell agent (signed by Retell; see knowledge.md)
POST/v1/sessions/endEnd a session, best effort

Session endpoints authenticate with Authorization: Bearer <sessionToken> and respond with Cache-Control: no-store.

GET /v1/sites/:siteId/config is public and cacheable (public, max-age=60, s-maxage=300).


Errors ​

Every failure, at every status code, uses one envelope:

json
{ "error": { "code": "rate_limited", "message": "Too many messages. Try again shortly.", "retryAfter": 30 } }
CodeStatusMeaning
bad_request400The request did not match the schema, or a lead field failed validation
unauthorized401Missing, malformed or tampered session token
session_expired401A well-formed token that has aged out
forbidden_origin403Origin missing or not on the site's allowlist, or the visitor's IP is blocked
forbidden403The public API: the key lacks the scope, belongs to another site, or is used from an address it does not allow
conflict409The public API: it already exists (a lead with that email, an account)
captcha_failed403Turnstile rejected the token
not_found404Unknown site id or unknown route
rate_limited429A per-IP or per-minute limit was hit; see retryAfter
quota_exceeded429A per-session or per-day cap was hit
connector_error502The backend failed or timed out
internal500Anything else

message is always safe to show a visitor. A backend's own error text, a stack trace, and the internal detail used for logging never appear in the envelope. An HTML error page is never returned — a client's parser always has valid JSON to read.


GET /v1/sites/:siteId/config ​

ts
type ConfigResponse = {
  siteId: string;
  widget: WidgetConfig;           // see packages/protocol/src/config.ts
  capabilities: { poll: boolean; end: boolean; stream?: boolean; feedback?: boolean };
};

Only public values appear here. Secrets stay on the server; a captcha's site key is public by design, its secret is not.

Every field of WidgetConfig has a default, so a client that receives {} still renders a working widget.


POST /v1/sites/:siteId/sessions ​

ts
type StartSessionRequest = {
  lead?: Record<string, string>;   // keys match the configured form fields
  context: {
    pageUrl: string;
    pageTitle?: string;
    referrer?: string;
    utm?: Partial<Record<'source' | 'medium' | 'campaign' | 'term' | 'content', string>>;
    locale?: string;
    timezone?: string;
  };
  firstMessage?: string;
  captchaToken?: string;
};

type StartSessionResponse = {
  sessionToken: string;
  sessionId: string;
  expiresAt: number;               // epoch ms
  messages: Message[];             // greeting, and the reply to firstMessage
  capabilities: { poll: boolean; end: boolean; stream?: boolean; feedback?: boolean };
};

Lead values are trimmed, capped at 200 characters each, and checked against the site's configured fields. Keys that are not configured are dropped rather than rejected, so a stale client cannot break a site whose form just changed.

The session token ​

The server stores no session state. sessionToken is base64url(payload).base64url(hmacSha256(payload)), and the payload carries the site id, session id, a small opaque connectorState, a message count and an expiry. Every later request presents it; the server verifies the signature and hands the state back to the connector.

Treat it as opaque. It is not a bearer credential for anything but this one conversation, and it expires.


POST /v1/sessions/messages ​

ts
type SendRequest =
  | { kind: 'text'; text: string; clientId: string }
  | { kind: 'action'; actionId: string; value: string; label: string; clientId: string };

type SendResponse = { messages: Message[] };

clientId is generated by the client for optimistic rendering and deduplication. An action carries both the label (for display and history) and the value (for the backend).

When the connector's state changes, the response carries a refreshed token in the X-HelpPuff-Token header. A client must use it for the next request. The header is listed in Access-Control-Expose-Headers so a browser can read it.

A refreshed token keeps the original expiry: a conversation cannot extend itself indefinitely.


Streamed replies ​

When capabilities.stream is true, a client may ask for either POST …/sessions or POST /v1/sessions/messages to be streamed by sending Accept: text/event-stream. Streaming is a per-site setting on connectors that support it (the stream option on openai, gemini and echo).

The server streams only when it was asked and the site streams. Otherwise it answers with the usual JSON, so asking is always safe; a client reads either by the response's Content-Type.

Everything that can fail cheaply — the origin, the body, rate limits, the captcha — is checked before the stream starts and fails with the usual status code and envelope. Only the connector call is streamed, as server-sent events:

Eventdata
delta{ text: string }Reply text as it is written. A preview: never stored, superseded by done
donethe endpoint's JSON bodyThe sanitized messages. For a send, it also carries token, the refreshed session token, since the headers left before it existed
errorthe envelope's errorA failure after the 200 was sent

done is authoritative. A client shows the deltas while they arrive, then replaces them with done's messages — which are sanitized; the deltas are not rich messages and may differ in detail, such as a trailing space. A card, an options list or a link list arrives only in done.

Reasoning is never streamed. Connectors forward only the text meant for the visitor; a model's thinking produces no deltas, so the client keeps showing its typing indicator until the answer begins.

A stream that ends without done or error was cut off, and should be treated as a retryable failure.


GET /v1/sessions/messages?after=:id ​

Only meaningful when capabilities.poll is true; otherwise it returns bad_request. Returns { messages: Message[] }, possibly empty.

POST /v1/sessions/feedback ​

Authorization: Bearer <sessionToken>, body { "messageId": "m_…", "value": 1 | -1 | 0 } (0 takes a rating back) → 204. Rates one of the assistant's replies in this session; stored with the transcript for the dashboard. Offered only when the /config capabilities include "feedback": true (the deployment records conversations); otherwise 404. Rate-limited per IP. A widget sends it fire-and-forget and never shows its failure.

POST /v1/sessions/end ​

Returns 204 immediately. Any backend teardown runs after the response.


Messages ​

ts
type Base = {
  id: string;
  ts: number;
  role: 'user' | 'agent' | 'system';
  /** `human`: written by a person on the team (live chat), shown with `agentName`. */
  meta?: { agentName?: string; avatar?: string; human?: boolean };
};

type Message =
  | Base & { type: 'text'; text: string }
  | Base & { type: 'notice'; text: string; tone?: 'info' | 'warn' }
  | Base & { type: 'options'; text?: string; options: Option[]; multi?: boolean }
  | Base & { type: 'card'; title: string; body?: string; image?: Img; actions?: Action[] }
  | Base & { type: 'carousel'; cards: CardItem[] }
  | Base & { type: 'links'; title?: string; links: LinkItem[] }
  | Base & { type: 'form'; title?: string; fields: Field[]; submitLabel?: string }
  // Live chat's progress: waiting for a person, joined, back to the assistant, closed, or nobody in time.
  | Base & { type: 'handover'; status: 'waiting' | 'joined' | 'left' | 'closed' | 'missed'; text: string; agentName?: string }
  // The booking picker (Settings → Bookings), or with `booked` the appointment it booked. Only the server writes `booked`.
  | Base & { type: 'booking'; text?: string; from?: string; to?: string; part?: 'morning' | 'afternoon' | 'evening';
             prefill?: { name?: string; email?: string; phone?: string }; reschedule?: string;
             booked?: { id: string; title: string; start: number; end: number; timezone: string; status: 'confirmed' | 'cancelled'; location?: string; cancel?: boolean; change?: boolean } };

Rules both sides must follow:

  • The server validates every connector message before returning it. Invalid messages are dropped and counted in the logs. If that leaves nothing, a single friendly notice is returned instead of an error.
  • A client must never crash on unexpected input. An unknown type renders nothing.
  • text is a markdown subset only: paragraphs, line breaks, **bold**, *italic*, `code`, [links](url) and - bullets. No headings, images, HTML or tables.
  • URLs accept only https:, http:, mailto: and tel: — anywhere in the protocol. Image sources are narrower still: https: or http: only. Anything else is rejected server-side and rendered as plain text client-side.

Batches are capped at 20 messages; options at 10; card actions at 3.

Actions ​

ts
type Action =
  | { id: string; kind: 'reply'; label: string; value: string }
  | { id: string; kind: 'url';   label: string; url: string; newTab?: boolean }
  | { id: string; kind: 'tel';   label: string; phone: string }
  | { id: string; kind: 'email'; label: string; email: string }
  | { id: string; kind: 'flow';  label: string; flowId: string }
  | { id: string; kind: 'form';  label: string; formId: string }
  | { id: string; kind: 'booking'; label: string };

reply sends a SendRequest of kind action. flow and form are handled entirely client-side and reach the server only as the message they produce. booking opens the booking picker (a booking message the widget shows itself).

The picker talks to the server on the session token, outside the message stream: POST /v1/sessions/booking/slots { from?, to?, tz?, part?, reschedule? } (free times, each with a signed id; a POST so the origin is always checked), POST /v1/sessions/booking { slot, name, email, phone?, timezone?, reschedule? } (answers { messages } with the booked card, or { redirect, messages } when the visitor finishes on the provider's page) and POST /v1/sessions/booking/cancel { appointment }. A connector can open the picker by returning a booking message without booked; a booked one from a connector is dropped.


Live chat ​

When capabilities.live is true, a visitor can be handed to a person (see Live chat). The server decides: the action { kind: "action", actionId: "handover" } asks for a person whichever backend runs the chat, and the reply is a handover message (waiting, or missed plus the callback form when nobody is available).

While the latest handover message is waiting, joined or missed, the client opens GET /v1/live/socket as a WebSocket with the subprotocols helppuff.v1 and t.<session token> (never the token in the URL). The server sends JSON frames:

ts
type LiveFrame =
  | { t: 'msg'; message: Message }        // a person's reply, or a handover message
  | { t: 'status'; status: HandoverStatus; agentName?: string }
  | { t: 'typing'; on: boolean };

The client may send { "t": "typing", "on": true }, and ping (answered pong). The visitor's messages still go over POST /v1/sessions/messages, which answers { messages: [] } while a person has the chat. A socket the server closes with code 1000 and reason left or closed is over. Clients that cannot open a socket poll GET /v1/sessions/messages?after=<ms> for messages newer than that time.

Origin and CORS ​

The origin allowlist is mandatory. Without it, anyone can copy an embed snippet and spend the site owner's AI budget.

  • A session request whose Origin is missing or not on the site's allowlist is rejected with forbidden_origin.
  • The server reflects an allowlisted origin only, sends Access-Control-Allow-Headers: content-type, authorization, and exposes X-HelpPuff-Token and Retry-After.
  • CORS reflection decides only what a browser may read. Authorization is enforced separately, per site, inside each route.

Implementing your own server ​

Implement the five endpoints above and any HelpPuff widget can point at you with data-api. The checklist:

  1. Return the error envelope for every failure, including 404 and 500. Never HTML.
  2. Enforce an origin allowlist on session endpoints.
  3. Validate every outbound message against the schema — drop what fails.
  4. Send Cache-Control: no-store on session endpoints.
  5. Expose X-HelpPuff-Token when you rotate tokens.

The Zod schemas in packages/protocol are what the reference server validates with; copy them (MIT) rather than re-deriving the shapes.

MIT licensed. Runs in your own Cloudflare account.