Headless Quickstart Beta
The Headless Widget API is a server-to-server surface: your backend calls Elfa under /widget-api/v1/* with your widget key and relays each end-user's context inline on every request. It is enterprise-only — see Widget Overview for plans and how to get a key.
For cross-product agent setup and surface selection, start with Agent Quickstart. This page covers the Headless Widget auth model and first request.
Base URL
https://api.elfa.ai/widget-api/v1
Auth Model: One Tier, No Token Minting
The hosted iframe uses a two-step flow (your server mints a short-lived user token, the browser sends it). Headless collapses that to one tier: because your backend already holds the widget-key secret, there is no token to mint, cache, or refresh. Every request carries:
X-Widget-Key— yourelfaw_…widget key. In headless mode this is a server secret.x-user-identifier— a stable, unique ID for the end-user the request is on behalf of (required). Also accepted asuserIdentifierin the request body or query string; the header wins.
Optionally, request bodies may include:
| Field | Type | Purpose |
|---|---|---|
userData | object | Personalization context for this end-user (portfolio, preferences, …) — pulled by the chat runtime |
limits | object | Per-end-user usage caps you set — see Per-User Limits |
domain | string | Optional attribution host recorded on the session |
Elfa keeps a server-side session keyed by (widgetKey, endUser) — session history, quota counters, and personalization all hang off it. A request that omits userData/limits (for example any bodyless GET) preserves the values a previous request stored; they are only replaced when you send them again.
In headless mode elfaw_… is a bearer credential with no Origin/domain guard — anyone holding it can act as any of your end-users. Never embed it in a browser, mobile app, or client-visible config. Only the hosted iframe surface tolerates browser exposure (it is domain-locked); use separate keys if you run both surfaces.
x-user-identifier must be unique per end-userQuotas, sessions, and workspace identity are all keyed on it. Sending a constant value collapses all your users into one Elfa user — one quota bucket, shared chat history.
First Request: Chat Stream
POST /widget-api/v1/chat/stream streams a chat answer as Server-Sent Events:
WIDGET_KEY="elfaw_your_widget_key"
curl -N -X POST "https://api.elfa.ai/widget-api/v1/chat/stream" \
-H "X-Widget-Key: ${WIDGET_KEY}" \
-H "x-user-identifier: user-42" \
-H "Content-Type: application/json" \
-d '{
"message": "What is driving BTC price action today?",
"analysisType": "chat",
"userData": { "displayName": "Ada", "watchlist": ["BTC", "ETH"] },
"limits": { "user_daily_chats": 50 }
}'
Key body fields: message, analysisType (chat, trendingQueries, tokenQuestions, macro, summary, tokenIntro, tokenAnalysis, accountAnalysis, tokenDiscovery, athenaBuilder), optional sessionId to continue an existing session, speed, and attachments.imageIds.
SSE Response
The response is text/event-stream. Each event is a data: line of JSON with a type discriminator — the ones to handle first:
data: {"type":"session_info","sessionId":"…"}
data: {"type":"title","title":"BTC price action"}
data: {"type":"text","content":"Bitcoin is…"}
data: {"type":"text","content":" trading higher after…"}
data: {"type":"status","status":"Searching mentions…"}
data: {"type":"complete","sessionId":"…","convId":123,"speed":"expert","success":true}
data: [DONE]
Concatenate text events for the answer; complete carries the persisted sessionId/convId (reuse sessionId for follow-up turns); data: [DONE] terminates the stream. Other event types (reasoning, chart, trades, questions, credits, error, …) can be ignored until you need them. A mid-stream failure emits {"type":"error","error":…,"code":…} before [DONE].
Per-User Limits (limits)
limits lets you cap each end-user's chat usage. All fields are optional non-negative integers; omitted or null means uncapped:
{
"limits": {
"user_daily_chats": 50,
"user_monthly_chats": 500,
"user_session_messages": 30
}
}
Daily/monthly windows reset at midnight UTC / the first of the month (UTC). When a cap is hit, chat requests return 429 QUOTA_EXCEEDED with limitType, limit, used, and resetsAt. Limits persist on the end-user's session until you send a new limits object.
Error Contract
Errors are JSON with an error message and a stable code:
| Status | Code | Meaning |
|---|---|---|
| 401 | WIDGET_KEY_MISSING | No X-Widget-Key header |
| 401 | WIDGET_KEY_INVALID_FORMAT | Key does not start with elfaw_ |
| 401 | WIDGET_KEY_NOT_FOUND | Key not recognized |
| 401 | WIDGET_KEY_INACTIVE | Key exists but is not active |
| 401 | WIDGET_KEY_EXPIRED | Key past its expiry date |
| 403 | WIDGET_PLAN_NOT_ELIGIBLE | Free-plan key on the headless surface — enterprise required |
| 400 | USER_IDENTIFIER_MISSING | No x-user-identifier header (or userIdentifier body/query field) |
| 400 | USER_IDENTIFIER_INVALID | userIdentifier is empty or not a string |
| 403 | USER_ACCESS_DENIED | This end-user is banned or suspended (response includes reason) |
| 403 | WIDGET_FEATURE_NOT_ENABLED | Route requires a feature flag your key lacks (response includes missingFeatures) |
| 403 | DOMAIN_NOT_AUTHORIZED | A browser Origin header was present and not on your allowlist — server-to-server calls should send none |
| 429 | QUOTA_EXCEEDED | A limits cap you set for this end-user is exhausted |
| 429 | WIDGET_KEY_RATE_LIMITED | Too many invalid-key attempts from your IP (120/min); honors Retry-After |
| 402 | FREE_CREDITS_EXHAUSTED | The key's monthly credit budget is exhausted (response includes limit, used, resetsAt) |
There is no per-IP rate limit on valid requests — a partner backend fans out for many end-users from one IP, so usage is bounded by per-user quotas and the key's credit budget instead. See the API Reference for which routes meter quota and credits.
Next Steps
- Headless API Reference — every
/widget-api/v1/*endpoint - Auto docs — the query model behind the widget's Auto endpoints