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
| Method | Path | Purpose |
|---|---|---|
GET | /v1/sites/:siteId/config | Public widget config |
POST | /v1/sites/:siteId/sessions | Start a session |
POST | /v1/sessions/messages | Send a message or an action |
GET | /v1/sessions/messages?after=:id | Poll for new messages |
POST | /v1/sessions/feedback | Rate a reply (thumbs up/down) |
POST | /v1/sites/:siteId/retell/kb | Knowledge lookup for a Retell agent (signed by Retell; see knowledge.md) |
POST | /v1/sessions/end | End 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:
{ "error": { "code": "rate_limited", "message": "Too many messages. Try again shortly.", "retryAfter": 30 } }| Code | Status | Meaning |
|---|---|---|
bad_request | 400 | The request did not match the schema, or a lead field failed validation |
unauthorized | 401 | Missing, malformed or tampered session token |
session_expired | 401 | A well-formed token that has aged out |
forbidden_origin | 403 | Origin missing or not on the site's allowlist, or the visitor's IP is blocked |
forbidden | 403 | The public API: the key lacks the scope, belongs to another site, or is used from an address it does not allow |
conflict | 409 | The public API: it already exists (a lead with that email, an account) |
captcha_failed | 403 | Turnstile rejected the token |
not_found | 404 | Unknown site id or unknown route |
rate_limited | 429 | A per-IP or per-minute limit was hit; see retryAfter |
quota_exceeded | 429 | A per-session or per-day cap was hit |
connector_error | 502 | The backend failed or timed out |
internal | 500 | Anything 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
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
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
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:
| Event | data | |
|---|---|---|
delta | { text: string } | Reply text as it is written. A preview: never stored, superseded by done |
done | the endpoint's JSON body | The sanitized messages. For a send, it also carries token, the refreshed session token, since the headers left before it existed |
error | the envelope's error | A 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
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
noticeis returned instead of an error. - A client must never crash on unexpected input. An unknown
typerenders nothing. textis 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:andtel:— anywhere in the protocol. Image sources are narrower still:https:orhttp: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
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:
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
Originis missing or not on the site's allowlist is rejected withforbidden_origin. - The server reflects an allowlisted origin only, sends
Access-Control-Allow-Headers: content-type, authorization, and exposesX-HelpPuff-TokenandRetry-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:
- Return the error envelope for every failure, including 404 and 500. Never HTML.
- Enforce an origin allowlist on session endpoints.
- Validate every outbound message against the schema — drop what fails.
- Send
Cache-Control: no-storeon session endpoints. - Expose
X-HelpPuff-Tokenwhen 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.