API reference
Widget API
The Widget API is the public HTTP surface the bundled JavaScript talks to. You normally don't call it yourself — the widget loader does — but the contract is documented here so you can build custom clients, audit traffic, or simulate the widget for testing.
All endpoints are under /api/v1/widget. Authentication is
a signed JWT issued by /init. CORS is permissive on
POST for cross-origin embeds.
POST POST /v1/widget/init
Boots the widget for a visitor. No auth — but the request's
Origin header must match the agent's
allowed_origins (see Allowed origins).
Request
POST /api/v1/widget/init
Origin: https://your-site.com
Content-Type: application/json
{
"agent_id": "01HXY...",
"page_url": "https://your-site.com/pricing",
"anon_id": "anon_abc123" // optional; persists visitor across reloads
}
Response (200)
{
"data": {
"conversation_id": "01HXZ...",
"visitor_id": "01HXY...",
"anonymous_id": "anon_abc123",
"jwt": "eyJhbGciOiJIUzI1NiI...",
"expires_at": "2026-05-07T13:00:00Z",
"agent": {
"id": "01HXY...",
"name": "Aria",
"persona": { "name": "Aria", "tone": "friendly" },
"theme": { "primary": "#111827", ... },
"starter_prompts": [ "..." ],
"language_default": "en"
},
"branding": { "show": true, "label": "...", "url": "...", "logo_url": "...", "display_mode": "logo_only" },
"behavior_rules": [ ... ],
"messages": [ ... ], // last 30 messages of the resumed conversation
"reverb": { "app_key": "...", "host": "...", "port": 8080, "scheme": "wss" }
}
}
Error responses
| Status | Code | Cause |
|---|---|---|
| 404 | agent_not_found | Agent doesn't exist or isn't published. |
| 403 | origin_forbidden | Origin not in allowed_origins. |
| 429 | plan_limit_reached | Workspace exceeded its monthly conversation quota. |
| 429 | (throttled) | Per-IP rate limit hit (60 rpm by default). |
POST POST /v1/widget/messages/stream
The streaming endpoint. SSE response. Auth: Authorization: Bearer
<jwt>. Use this for the visitor experience — every other
method is sync and slower.
Request
POST /api/v1/widget/messages/stream
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
Content-Type: application/json
{
"message": "What's your refund policy?",
"page_url": "https://your-site.com/pricing",
"page_context": { ... } // optional; structured data extracted from the current page
}
Response (Server-Sent Events)
HTTP/1.1 200 OK
content-type: text/event-stream
data: {"event":"token","token":"Our "}
data: {"event":"token","token":"refund "}
data: {"event":"token","token":"policy is 30 days "}
data: {"event":"citations","citations":[{"id":1,"url":"https://your-site.com/refunds"}]}
data: {"event":"done","conversation_id":"01HXZ..."}
Token events come fastest in the first few hundred ms — that's the
1-second-to-first-token target on the hot path. citations
event arrives once after streaming completes; done closes
the stream.
POST POST /v1/widget/messages
Sync version of /messages/stream. Returns the full response
in one JSON payload. Slower (visitor waits for the full response) but
easier to integrate with non-browser clients.
Response
{
"data": {
"message_id": "01HXZ...",
"conversation_id": "01HXZ...",
"content": "Our refund policy is 30 days...",
"citations": [{"id": 1, "url": "..."}],
"low_confidence": false
}
}
POST POST /v1/widget/leads
Submit captured contact info. Auth: same JWT as messages.
POST /api/v1/widget/leads
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
{
"name": "Alex",
"email": "alex@example.com",
"phone": "+1...",
"fields": { "company": "Acme" } // any agent-defined custom fields
}
Dedupes on (agent_id, email): repeat submissions update the existing lead instead of creating a new one. Rate-limited at 5 requests per JWT per window — abuse-resistant.
POST POST /v1/widget/events
Lightweight client-side analytics. The widget calls this with telemetry events (launcher opened, CTA clicked, dismissed, scroll trigger fired). Auth: JWT. Rate-limited.
{
"event": "cta.click",
"rule_id": "01HXY...",
"metadata": { ... }
}
POST POST /v1/widget/request-human
Visitor escalates to a live operator. The bot's reply pauses; the
conversation flips to human_requested_at and the
in-app Inbox alerts every workspace operator. Auth: JWT.
{ "reason": "I'd like to talk to sales" } // reason optional, max 500 chars
POST POST /v1/widget/typing
Visitor typing indicator. The widget pings while the visitor is composing so operators see "is typing…" in real time. Throttled to 600 rpm per IP (high to absorb keystroke bursts; per-IP instead of per-JWT so multiple tabs share the budget). No body required — a bare POST is enough.
POST POST /v1/widget/satisfaction
Capture the visitor's CSAT rating at end of conversation. Auth: JWT. Throttled to 60 rpm per IP.
{
"rating": "good", // good | bad
"comment": "Loved the help" // optional, max 500 chars
}
POST POST /v1/widget/coupon/apply
E-commerce vertical only. Visitor accepts an offered coupon CTA; the controller stamps the conversation with the coupon code so downstream attribution can credit the bot. Auth: JWT. Throttled to 120 rpm per IP.
{ "code": "SAVE20" } // 3-32 chars, alphanumeric + dashes
POST POST /v1/widget/transcribe
Turns a recorded voice clip into text. The widget only calls it in browsers whose own speech recognition doesn't work — Brave, Arc, Dia, Firefox — and puts the text in the input box for the visitor to send (see Voice mic). Auth: JWT. Not on the hot path: it never touches the message stream and writes nothing to the database. The clip is passed to the provider and dropped; neither the audio nor the transcript is stored or logged.
POST /api/v1/widget/transcribe
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
Content-Type: multipart/form-data; boundary=...
audio=<the clip> // WebM, Ogg, MP4, MP3 or WAV; max 2 MB
The part's type may carry codec parameters
(audio/webm;codecs=opus). The server checks the file's
first bytes too and tells the provider what the file really is.
Transcription runs on Cloudflare Workers AI or OpenAI Whisper —
whichever comes first in the chat provider order — with the agent's
default language as a hint.
Response (200)
{ "data": { "text": "What does shipping to Utrecht cost?" } }
text is empty when the clip held no speech.
Error responses
| Status | Code | Cause |
|---|---|---|
| 401 | missing_token / invalid_token | No JWT, or one that is expired or not ours. |
| 403 | conversation_forbidden | The JWT's conversation belongs to a different agent. |
| 403 | origin_forbidden | Origin not in allowed_origins. |
| 404 | conversation_not_found | The JWT's conversation no longer exists. |
| 413 | audio_too_large | Clip over 2 MB. |
| 422 | audio_required | No audio file in the request. |
| 422 | unsupported_audio_type | Not WebM, Ogg, MP4, MP3 or WAV — by declared type or by content. |
| 429 | rate_limited | More than 10 clips a minute or 60 an hour in this conversation. |
| 502 | transcription_failed | The provider failed or timed out. |
| 503 | transcription_unavailable | No configured provider can transcribe (OpenRouter only). |
JWT format
HS256, signed with WIDGET_JWT_SECRET. Claims:
{
"iss": "pitchbar",
"iat": 1714900000,
"exp": 1714903600, // 60 minutes
"agent_id": "01HXY...",
"visitor_id": "01HXY...",
"conversation_id": "01HXZ..."
}
Tokens are scoped to a single conversation. Re-init to get a fresh
token for a new conversation. Verifying happens in
WidgetJwt::verify() — invalid signatures, expired tokens,
or tampered claims all return 401.
Rate limits
| Endpoint | Limit | Key |
|---|---|---|
/init | 60 rpm | per IP + agent_id (throttle:widget-init) |
/messages, /messages/stream, /events, /conversation/*, DELETE /me | 30 rpm | per JWT (throttle:widget-session) |
/leads | 5 rpm | per JWT (throttle:widget-leads) |
/transcribe | 10 per minute, 60 per hour | per conversation, so a fresh JWT for the same conversation shares it (throttle:widget-transcribe) |
All return 429 with a Retry-After header on limit.