Skip to main content

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 — your elfaw_… 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 as userIdentifier in the request body or query string; the header wins.

Optionally, request bodies may include:

FieldTypePurpose
userDataobjectPersonalization context for this end-user (portfolio, preferences, …) — pulled by the chat runtime
limitsobjectPer-end-user usage caps you set — see Per-User Limits
domainstringOptional 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.

The widget key is a server secret

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.

warning
x-user-identifier must be unique per end-user

Quotas, 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:

StatusCodeMeaning
401WIDGET_KEY_MISSINGNo X-Widget-Key header
401WIDGET_KEY_INVALID_FORMATKey does not start with elfaw_
401WIDGET_KEY_NOT_FOUNDKey not recognized
401WIDGET_KEY_INACTIVEKey exists but is not active
401WIDGET_KEY_EXPIREDKey past its expiry date
403WIDGET_PLAN_NOT_ELIGIBLEFree-plan key on the headless surface — enterprise required
400USER_IDENTIFIER_MISSINGNo x-user-identifier header (or userIdentifier body/query field)
400USER_IDENTIFIER_INVALIDuserIdentifier is empty or not a string
403USER_ACCESS_DENIEDThis end-user is banned or suspended (response includes reason)
403WIDGET_FEATURE_NOT_ENABLEDRoute requires a feature flag your key lacks (response includes missingFeatures)
403DOMAIN_NOT_AUTHORIZEDA browser Origin header was present and not on your allowlist — server-to-server calls should send none
429QUOTA_EXCEEDEDA limits cap you set for this end-user is exhausted
429WIDGET_KEY_RATE_LIMITEDToo many invalid-key attempts from your IP (120/min); honors Retry-After
402FREE_CREDITS_EXHAUSTEDThe 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