Elfa Apollo Widget - Partner Integration Guide
Last update: 21 Jul 2026
This guide covers the end-to-end integration flow for embedding the Elfa Apollo widget in your product, including the backend token exchange and the full PostMessage contract required on the host page. The widget is simplified to make embedding easy and convenient while maintaining strong security standards to prevent abuse or exploitation by external parties.
Scope: this guide covers the embedded (iframe) widget — the script-tag integration rendered inside your page. If you are building a server-to-server (headless) integration instead, see the Headless Widget API documentation at https://docs.elfa.ai/widget/overview (enterprise).
Overview
You will:
- Request a short-lived user token from Elfa using your widget key.
- Embed the widget with a script tag and required data attributes.
- (optional) Listen for widget messages and send host updates via PostMessage when needed.
Step 1: Retrieve User Token (Backend)
This backend exchange ensures only authorized partners can embed the widget and prevents unauthorized usage or exploitation. The widget key must stay server-side, and short-lived user tokens are issued per request.
Endpoint:
POST https://api.elfa.ai/embed/v1/get-user-token
Required headers:
Content-Type: application/jsonX-Widget-Key: <your_widget_key>
Recommended headers:
Origin: https://your-domain.exampleCache-Control: no-cache, no-store, must-revalidatePragma: no-cache
Request body:
userIdentifier(string, required) - your internal user IDuserData(object, required) - user context for personalization. Must be a non-empty object: send real attributes (e.g.name,walletAddress, preferred chains, risk profile).{}is not a valid value — Ask Elfa and the Workspace surface rely on this context, and an empty payload degrades both.domain(string, required) - the host domain where the widget is embeddedlimits(object, optional) - per-user usage limits enforced server-side. See Per-user usage limits below.partnerHmacSecret(string, optional) - Per-user HMAC secret for signing widget order webhooks. Between 16-255 characters. When provided, it's stored with the widget user and used (combined with your widget key) to signmarket_order/limit_orderwebhook deliveries. Not echoed in the response. See Widget Order Webhook Signature Validation below.
Response:
userTokenis returned at the top level.quota(object, optional) - present when limits are active for the user. Containslimits,usage(counters today/this month), andremaining. See Per-user usage limits.
Example (Node.js / fetch):
async function getUserToken(widgetKey, userId, userData, domain, partnerHmacSecret = null) {
const response = await fetch("https://api.elfa.ai/embed/v1/get-user-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Widget-Key": widgetKey,
Origin: domain,
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
},
body: JSON.stringify({
userIdentifier: userId,
userData: userData,
domain,
// Optional: provide per-user HMAC secret for order webhook signing
partnerHmacSecret,
}),
});
const data = await response.json();
return data.userToken;
}
Example (curl):
curl -X POST "https://api.elfa.ai/embed/v1/get-user-token" \
-H "Content-Type: application/json" \
-H "X-Widget-Key: YOUR_WIDGET_KEY" \
-H "Origin: https://your-domain.example" \
-d '{
"userIdentifier": "user_123",
"userData": {"name": "Demo User", "walletAddress": "0x..."},
"domain": "your-domain.example"
// Optional: per-user HMAC secret for order webhook signing (16-255 chars)
// "partnerHmacSecret": "my-user-specific-secret-key"
}'
Notes:
- User tokens are short-lived.
- You can call
/embed/v1/get-user-tokenagain whenever user context changes (include updateduserData). - Be prepared for the edge case where the widget can’t re-auth itself and requests a reauth instead.
- Store the widget key securely and never expose it client-side.
Per-user usage limits
You can cap how much each user is allowed to use the widget. Limits are enforced server-side, so they cannot be spoofed by malicious clients. Pass an optional limits object when requesting a user token:
| Field | Type | Description |
|---|---|---|
user_daily_chats | number | null | Maximum chats this user can send per UTC calendar day. null/omitted = unlimited. |
user_monthly_chats | number | null | Maximum chats this user can send per UTC calendar month. null/omitted = unlimited. |
user_session_messages | number | null | Maximum messages allowed in a single conversation session. null/omitted = unlimited. |
Each field is independent — you can set any combination. Omitted or null fields are not enforced. Use 0 to explicitly block a user for that dimension without changing their account status.
Counters reset automatically at midnight UTC (daily) and at the start of each month UTC (monthly). user_session_messages does not reset — it is bounded by the conversation session itself.
Example request with limits:
await fetch("https://api.elfa.ai/embed/v1/get-user-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Widget-Key": widgetKey,
},
body: JSON.stringify({
userIdentifier: "user_123",
userData: { name: "Demo User" },
domain: "your-domain.example",
limits: {
user_daily_chats: 50,
user_monthly_chats: 1000,
user_session_messages: 30,
},
}),
});
Response includes a quota block when limits are active:
{
"userToken": "…",
"expiresAt": "2026-04-15T10:30:00.000Z",
"expiresInSec": 900,
"features": ["chat"],
"personalization": [],
"rotated": false,
"quota": {
"limits": {
"userDailyChats": 50,
"userMonthlyChats": 1000,
"userSessionMessages": 30
},
"usage": {
"dailyChats": 12,
"monthlyChats": 87
},
"remaining": {
"userDailyChats": 38,
"userMonthlyChats": 913,
"userSessionMessages": 30
}
}
}
When a user has hit a limit, the chat stream endpoint will respond with HTTP 429:
{
"error": "Usage limit exceeded",
"code": "QUOTA_EXCEEDED",
"limitType": "daily_chats",
"limit": 50,
"used": 50,
"resetsAt": "2026-04-16T00:00:00.000Z"
}
limitType will be one of daily_chats, monthly_chats, or session_messages. resetsAt is an ISO timestamp for daily_chats / monthly_chats, and null for session_messages (which is bounded by the conversation, not a clock).
The widget surfaces this inline as a compact card with a friendly title and a used/limit counter:
daily_chats→ "Daily chat limit reached" + "Resets in X hours"monthly_chats→ "Monthly chat limit reached" + "Resets in X days"session_messages→ "Message limit reached for this conversation"
If you wrap the embed yourself (or want to replace the default card UI with your own), listen for the chat stream error and branch on code === "QUOTA_EXCEEDED" to render a custom "limit reached / upgrade" UX.
Tips:
- Pass updated
limitswhenever a user upgrades/downgrades their plan — values take effect on the next token call (latest call wins). - Each field is independent. Include only the ones you want to enforce; omit or pass
nullfor the rest. - Prefer stable user identifiers (wallet address, internal user ID). If you generate a new identifier per session, users will get fresh counters every session.
Step 2: Embed the Widget (Frontend)
Add the embed script to your page and pass the required data attributes.
Example (full configuration):
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-width="500"
data-height="700"
data-launcher-icon-size="80"
data-theme="dark"
data-default-tab="ask-elfa"
data-symbol="pippin"
data-chain="solana"
data-contract-address="Dfh5DzRgSvvCFDoYc2ciTkMrbDfRKybA4SoFbPmApump"
data-launcher-icon-src="https://your-cdn.example/icon.png"
data-trade-callback="true"
data-trusted-origins="https://your-domain.example"
></script>
Supported data attributes:
| Attribute | Required | Description | Format / Values | Default |
|---|---|---|---|---|
data-user-token | Yes | User token returned by /embed/v1/get-user-token. | String | None |
data-width | No | Widget panel width. | Number (px) | 500 |
data-height | No | Widget panel height. | Number (px) | 700 |
data-launcher-icon-size | No | Launcher icon size. Applies to both the default Elfa icon and custom icons — no feature flag required. | Number (px) | 80 |
data-theme | No | Widget color theme. | light, dark | dark |
data-color-scheme | No | Controls the iframe color-scheme CSS property. force applies the theme to the iframe canvas; none leaves it transparent. | force, none | none |
data-default-tab | No | Which tab to show when the widget first opens. | ask-elfa, insight, auto | ask-elfa |
data-lookup-preference | No | Token lookup mode. Use symbol to resolve by symbol only; use address (or omit) to resolve by chain + contract address. | symbol, address | address |
data-symbol | No | Token symbol. | String | None |
data-chain | No | Chain identifier. | String (e.g., eth, solana) | None |
data-contract-address | No | Token contract address. | String | None |
data-image-url | No | Token image/logo URL to display in the widget header. | URL string | None |
data-launcher-icon-src | No | Custom image URL for the launcher button. Requires personalization to be enabled on your account. | URL string | Elfa default icon |
data-margin-x | No | Horizontal margin from each screen edge for the launcher snap position. | Number (px) | 20 |
data-margin-y | No | Vertical margin from each screen edge for the launcher snap position. | Number (px) | 20 |
data-elfa-hot-key | No | Custom keyboard shortcut to toggle the widget open/close. Uses TanStack Hotkeys format. | String (e.g. Shift+Mod+E) | None (disabled) |
data-elfa-style | No | JSON object of color overrides for custom branding. | JSON string | None |
data-widget-style | No | Inline CSS style rules applied to the widget core container. | CSS string | None |
data-custom-labels | No | JSON object of UI label overrides. Requires custom_labels personalization on the widget-key. | JSON string | None |
data-trade-callback | No | Enables Trade button callback in Ask Elfa cards. When enabled, clicking Trade emits a postMessage callback payload based on displayed prefilled order values. | true, false | false |
data-trusted-origins | No | Extra allowed host origins for PostMessage. | Comma-separated origins | window.location.origin |
data-block-link-navigation | No | When true, prevents default link navigation in chat. The host receives elfa.apollo.widget.link_click and is responsible for routing. | true, false | false |
Notes:
- The widget only renders when a user token is provided.
data-trusted-originsmust be a comma-separated list of full origins (scheme://host:port).- If
data-lookup-preference="symbol", providedata-symbol. - If lookup preference is omitted or set to
address, provide bothdata-chainanddata-contract-address.
Professional & Enterprise plans
The widget supports both standard features + customization (available to all integrators) and advanced capabilities in Professional & Enterprise plans. Advanced capabilities are marked below with (requires professional / enterprise plan).
If you want to enable advanced capabilities, please contact us.
Features
The Apollo widget provides two tabs:
Chat: Ask Elfa
(requires professional / enterprise plan)
An AI-powered conversational interface for crypto intelligence. Users can ask natural-language questions about tokens, market trends, on-chain data, and more. Responses are grounded in real-time data from the Elfa platform.
For stronger personalization, pass meaningful userData when requesting the user token (for example: preferred chains, risk profile, watchlist interests, or account attributes). Ask Elfa uses this context to adapt responses, making guidance more relevant for each user.
Feed: Sentiment & Insights
A live sentiment tracker and news feed for the configured token. Includes:
- 24h Sentiment Chart: visual sentiment score over time.
- Curated News Feed: relevant articles and social posts with topic tags (e.g., Tailwind, Tokenomics).
Personalization
Responsive Layout
- Desktop: floating panel anchored to a screen corner with a draggable launcher icon. The widget can be resized by dragging its edges.
- Mobile: full-screen bottom-sheet drawer with a drag handle to swipe down and dismiss. Activates automatically when the viewport is narrower than the configured widget width.
If your site is a traditional multi-page application with a full page refresh on every navigation and you always provide the correct symbol / chain / contractAddress, Steps 1–2 are typically sufficient (ignoring edge cases). The PostMessage contract is mainly for single-page apps and re-auth edge cases.
Keyboard Shortcuts
The widget has built-in keyboard shortcuts:
- Escape — closes the widget if it is currently open. The event is stopped so it does not propagate to other handlers on your page.
You can also define a custom toggle hotkey using data-elfa-hot-key. No special feature flag is required — any integrator can use this attribute. The value follows TanStack Hotkeys format:
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-elfa-hot-key="Shift+Mod+E"
></script>
Common modifiers:
| Modifier | Key |
|---|---|
Mod | ⌘ on macOS, Ctrl on Windows/Linux |
Ctrl | Control key |
Shift | Shift key |
Alt | Alt / Option key |
Meta | ⌘ on macOS, ⊞ Win on Windows |
Examples: Shift+Mod+E, Ctrl+Shift+Space, Alt+Shift+A.
When the hotkey is pressed, the widget toggles between open and closed states.
Theme
Set data-theme="light" or data-theme="dark" to match your site's appearance.
If your site supports both modes, re-embed the widget (or reload the page) when the user toggles their theme. The widget does not currently auto-detect host-page theme changes at runtime.
The data-color-scheme attribute controls whether the iframe's own CSS color-scheme is forced to match the theme. Use force if the default iframe background color bleeds through before the widget renders; leave it as none (default) if your page already handles dark backgrounds.
Launcher
Icon Size
Use data-launcher-icon-size to control launcher icon size in pixels (default 80).
This attribute applies to both the default Elfa icon and custom icons, and does not require personalization to be enabled.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-launcher-icon-size="72"
></script>
Position & Margins
The launcher icon snaps to the nearest screen corner when the user finishes dragging it. Control how far it sits from the edges with data-margin-x and data-margin-y (both default to 20px). On mobile viewports the launcher is centered at the bottom of the screen.
Custom Icon
(requires professional plan)
You can replace the default Elfa launcher button with your own brand icon. It can be set via data-launcher-icon-src pointing to your icon image.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-launcher-icon-src="https://your-cdn.example/my-icon.png"
></script>
Use a square image (PNG or SVG) for best results. If the image fails to load, the widget falls back to the default Elfa icon.
Custom Color Overrides
(requires professional plan)
You can override the widget's accent and surface colors to match your brand by providing a single data-elfa-style JSON attribute.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-elfa-style='{"accent":"#FF6B00","surfacePrimary":"#1a1a2e","surfaceSecondary":"#16213e","contentPrimary":"#f0f0f0","contentSecondary":"#a0a0b0"}'
></script>
Supported keys:
| Key | CSS Variable | Description |
|---|---|---|
accent | --content-accent | Primary accent color used for buttons, links, active states, highlights, and the pill badges. Also automatically sets --btn-background-primary, --stroke-accent, --pills-content-accent, --pills-background-accent (10% opacity), and --btn-background-primary-hover (90% opacity). |
surfacePrimary | --surface-primary | Main background surface (cards, panels). |
surfaceSecondary | --surface-secondary | Secondary surface (nested sections, hover states). |
contentPrimary | --content-primary | Primary text / icon color. |
contentSecondary | --content-secondary | Secondary text / muted content color. |
Rules:
- Values must be valid hex colors (e.g.
#FF6B00,#fff,#1a1a2eFF). Invalid values are silently skipped. - Unknown keys are ignored (forward-compatible — new keys may be added in future versions).
- Omit any key to use the widget's default value for that variable.
- Overrides apply to both light and dark themes.
- The JSON must be a flat object (no nesting).
Example (accent-only override):
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-elfa-style='{"accent":"#FF6B00"}'
></script>
Widget Style Override
(requires professional plan)
You can apply custom inline CSS style rules to the widget's core container (elfa-widget-apollo-core) using the data-widget-style attribute. This lets you override or extend the widget's default styling directly.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-widget-style="border-radius: 16px; border: 2px solid #ff6b00; font-family: Inter, sans-serif"
></script>
The CSS rules are injected as a <style> tag targeting the widget's root container, so they take effect alongside the widget's default styles.
Rules:
- The value is a CSS declaration block (property-value pairs separated by semicolons), without the surrounding braces.
- Can be combined with
data-elfa-stylecolor overrides for full branding control.
Custom Label Overrides
(requires enterprise plan)
You can override hardcoded branding strings in the widget — for example, renaming the "Ask Elfa" heading shown on the empty chat state to match your product's branding — using the data-custom-labels attribute. Functionality labels (e.g. quick-action button copy) are intentionally not customizable so action semantics remain consistent across deployments.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-custom-labels='{"tab.labels.chat":"Ask Acme","tab.labels.insight":"Insights","chat.landing.title":"Ask Acme","chat.input.placeholder":"Ask Acme anything..."}'
></script>
Keys follow an i18n flat-dotted naming convention. Supported keys:
| Key | Description |
|---|---|
tab.labels.chat | Replaces the "Chat" tab label. |
tab.labels.insight | Replaces the "Feed" tab label. |
chat.landing.title | Replaces the "Ask Elfa" heading shown on the empty chat state. |
chat.input.placeholder | Replaces the "How can Elfa help?" placeholder text in the chat input field. |
Rules:
- The value must be a flat JSON object (no nesting or arrays).
- Each label is trimmed and capped at 64 characters. Longer values are silently truncated.
- Newlines and tabs within a label value are collapsed to a single space.
- Unknown keys are ignored (forward-compatible).
- Omit any key to keep the widget's default text for that label.
- HTML-escape special characters in label values. The attribute is wrapped in single quotes around JSON, so apostrophes inside a brand name need to be escaped: use
'(e.g.data-custom-labels='{"chat.landing.title":"Joe's Bot"}'). - Requires the
custom_labelspersonalization flag granted on the widget-key.
Trade Callback
By default, Trade buttons inside Ask Elfa trade cards are hidden in the embed widget.
Set data-trade-callback="true" to enable the Trade button. When clicked, the widget emits a postMessage callback (elfa.apollo.widget.trade_callback) containing the currently displayed prefilled order values.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-trade-callback="true"
></script>
Auto Widget Order Execution (Backend Request)
This flow is automatic. Integrators are expected to execute the order after receiving the backend request.
When a widget Auto plan condition is met, Elfa AI/Athena sends a signed server-to-server backend request to your configured partner endpoint. Each request includes HMAC-SHA256 signature headers for per-user verification — see Widget Order Webhook Signature Validation below.
Backend request payload
The backend request payload is a JSON object that includes plan context, execution context, normalized order details, and the partner user identifier.
Common top-level fields:
status(string) - currentlytriggeredfor widget order events.title(string) - usuallyWidget Order Triggered.body(string) - plan title.queryId(string)executionId(string)triggerTime(ISO string)conditionsMet(number)orderType(market_orderorlimit_order)params(object) - order parameters to execute.userIdentifier(string) - partner user identifier to route execution on your side.timestamp(number, unix ms)
Example 1 - Market order (amount-based):
{
"status": "triggered",
"title": "Widget Order Triggered",
"body": "Buy BTC breakout",
"queryId": "2dbf0d70-a85f-4f67-9bd3-876e8fd89f86",
"executionId": "f1aa2b33-44cc-4dd5-9eef-001122334455",
"triggerTime": "2026-05-15T10:00:00.000Z",
"conditionsMet": 1,
"orderType": "market_order",
"params": {
"symbol": "BTC",
"side": "buy",
"amount": "150",
"leverage": 2,
"tp": "5%",
"sl": "2%"
},
"userIdentifier": "partner-user-789",
"timestamp": 1778839200000
}
Example 2 - Limit order (size-based):
{
"status": "triggered",
"title": "Widget Order Triggered",
"body": "ETH pullback entry",
"queryId": "8c4f99b2-5a22-4df9-b1f9-71c703d34760",
"executionId": "0e9bc95a-c5de-4f4f-a54f-4f675a865287",
"triggerTime": "2026-05-15T10:05:00.000Z",
"conditionsMet": 2,
"orderType": "limit_order",
"params": {
"symbol": "ETH",
"side": "buy",
"size": "0.75",
"price": "2875",
"reduceOnly": false
},
"userIdentifier": "partner-user-789",
"autoDetails": "Only enter if momentum confirms.",
"timestamp": 1778839500000
}
Example 3 - Market order (position percentage + reduce-only):
{
"status": "triggered",
"title": "Widget Order Triggered",
"body": "Trim SOL position on reversal",
"queryId": "3501e60e-b37d-43f7-8d12-e994a53f5bc9",
"executionId": "0a261b2b-3c8d-4659-bb85-33e94d2df3aa",
"triggerTime": "2026-05-15T10:08:00.000Z",
"conditionsMet": 1,
"orderType": "market_order",
"params": {
"symbol": "SOL",
"side": "sell",
"positionSizePercent": 30,
"reduceOnly": true
},
"userIdentifier": "partner-user-789",
"timestamp": 1778839680000
}
params shape can vary by plan setup. For example, limit_order includes price, while market_order does not.
Widget Order Webhook Signature Validation
All market_order and limit_order backend requests include HMAC-SHA256 signature headers for per-user verification. This allows you to confirm that the webhook was genuinely sent by Elfa and is associated with the correct user.
Prerequisites:
- You must have provided a
partnerHmacSecretwhen calling/embed/v1/get-user-tokenfor the user. - Your widget key's
webhookUrlmust be configured in the widget configuration.
Signature Headers:
| Header | Description |
|---|---|
X-Elfa-Widget-Signature | HMAC-SHA256 signature with v1= prefix (e.g., v1=abc123...) |
X-Elfa-Widget-Signature-Timestamp | Unix timestamp in seconds when the signature was generated |
X-Elfa-Widget-Event-Id | Unique event identifier (also available as X-Auto-Event-Id for compatibility) |
X-Elfa-Widget-Key-Id | The widget key ID (your partner identifier) |
Signing Algorithm:
derivedSecret = sha256(yourWidgetKey + ":" + partnerHmacSecret)
signedPayload = timestamp + "." + eventId + "." + rawBody
signature = HMAC-SHA256(derivedSecret, signedPayload)
Verification Steps:
- Extract the signature from
X-Elfa-Widget-Signature(remove thev1=prefix) - Extract
timestampfromX-Elfa-Widget-Signature-Timestamp - Extract
eventIdfromX-Elfa-Widget-Event-Id - Read the raw request body (as bytes, before parsing JSON)
- Compute:
expectedSignature = HMAC-SHA256(derivedSecret, timestamp + "." + eventId + "." + rawBody) - Compare the computed signature with the received signature using constant-time comparison
Node.js / Express Verification Example:
import crypto from 'crypto';
const WIDGET_KEY = process.env.ELFA_WIDGET_KEY; // Your widget key
function verifyWidgetOrderSignature(req, partnerHmacSecret) {
const signatureHeader = req.headers['x-elfa-widget-signature'];
const timestamp = req.headers['x-elfa-widget-signature-timestamp'];
const eventId = req.headers['x-elfa-widget-event-id'];
if (!signatureHeader || !timestamp || !eventId) {
return false;
}
// Remove "v1=" prefix
const receivedSignature = signatureHeader.slice(3);
// Derive the secret
const derivedSecret = crypto
.createHash('sha256')
.update(`${WIDGET_KEY}:${partnerHmacSecret}`)
.digest('hex');
// Get raw body - in Express with body-parser, use a raw body middleware
const rawBody = req.rawBody || Buffer.from(JSON.stringify(req.body));
// Construct signed payload
const signedPayload = `${timestamp}.${eventId}.${rawBody.toString()}`;
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', derivedSecret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}
// Usage with Express (ensure raw body is available)
app.post('/elfa-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const userIdentifier = req.body.userIdentifier;
const partnerHmacSecret = getPartnerHmacSecretForUser(userIdentifier);
if (!verifyWidgetOrderSignature(req, partnerHmacSecret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process the order
console.log('Order params:', req.body.params);
res.status(200).json({ success: true });
});
Python / Flask Verification Example:
import hashlib
import hmac
import json
from flask import request
WIDGET_KEY = "your_widget_key"
def verify_widget_order_signature(partner_hmac_secret):
signature_header = request.headers.get('X-Elfa-Widget-Signature')
timestamp = request.headers.get('X-Elfa-Widget-Signature-Timestamp')
event_id = request.headers.get('X-Elfa-Widget-Event-Id')
if not all([signature_header, timestamp, event_id]):
return False
# Remove "v1=" prefix
received_signature = signature_header[3:]
# Derive the secret
derived_secret = hashlib.sha256(f"{WIDGET_KEY}:{partner_hmac_secret}".encode()).hexdigest()
# Get raw body
raw_body = request.get_data(as_text=True)
# Construct signed payload
signed_payload = f"{timestamp}.{event_id}.{raw_body}"
# Compute expected signature
expected_signature = hmac.new(
derived_secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected_signature, received_signature)
@app.route('/elfa-webhook', methods=['POST'])
def handle_webhook():
user_identifier = request.json.get('userIdentifier')
partner_hmac_secret = get_partner_hmac_secret_for_user(user_identifier)
if not verify_widget_order_signature(partner_hmac_secret):
return {"error": "Invalid signature"}, 401
# Process the order
print("Order params:", request.json.get('params'))
return {"success": True}
Important Notes:
- The
partnerHmacSecretmust match the value you provided when creating the user token via/embed/v1/get-user-token. - Always use constant-time comparison (
crypto.timingSafeEqualin Node.js,hmac.compare_digestin Python) to prevent timing attacks. - Verify the signature before processing the order to prevent spoofing attacks.
- The raw body must be used exactly as received — do not parse and re-serialize JSON, as this can change whitespace and cause signature mismatches.
- If you did not provide a
partnerHmacSecretfor a user, the signature will be computed with an empty string as the per-user secret component.
Link Click Callback
The widget emits elfa.apollo.widget.link_click whenever a user clicks any link inside the chat. No flag required — fires for all partners by default.
Default behavior: PostMessage fires and the link opens normally in a new tab.
With data-block-link-navigation="true": PostMessage fires and default navigation is blocked. The host is responsible for routing the click.
<script
src="https://widget.elfa.ai/embed-apollo.js"
data-user-token="RETRIEVED_USER_TOKEN"
data-block-link-navigation="true"
></script>
window.addEventListener("message", (event) => {
if (event.data?.type === "elfa.apollo.widget.link_click") {
const { href, text } = event.data.payload;
if (href.includes("polymarket.com")) {
openMarketModal(href); // intercept — open native modal
} else {
window.open(href, "_blank"); // pass through all other links
}
}
});
Chat: Ask Elfa with user context
(requires enterprise plan)
For stronger personalization, pass meaningful userData when requesting the user token, e.g. preferred chains, risk profile, watchlist interests, or account attributes. Ask Elfa uses this context to adapt responses, making guidance more relevant for each user.
Make sure to use clear, human-readable keys and values whenever possible (for example, symbol instead of s, canceled instead of c) so the model can interpret the context reliably.
Example userData payload:
{
"tradingProfile": {
"style": "day_trader",
"preferredChains": ["solana", "base"],
"riskProfile": "moderate"
},
"accountSnapshot": {
"healthPct": 82.4,
"totalCollateralUsd": 150430.22,
"availableCollateralUsd": 39200.57,
"outstandingDebt": {
"asset": "USDC",
"amount": 25000
},
"overallLeverage": 2.1,
"isolatedLeverage": {
"BTC-PERP": 4,
"ETH-PERP": 3
},
"openPositions": [
{
"symbol": "BTC-PERP",
"direction": "long",
"entryPrice": 88350,
"size": 0.75,
"unrealizedPnlUsd": 1860
},
{
"symbol": "ETH-PERP",
"direction": "short",
"entryPrice": 2285,
"size": 12,
"unrealizedPnlUsd": -420
}
]
},
"recentActivity": {
"lastClosedTrades": [
{
"symbol": "SOL-PERP",
"pnlUsd": 320,
"durationMinutes": 95
},
{
"symbol": "BTC-PERP",
"pnlUsd": -140,
"durationMinutes": 36
}
],
"orders": [
{
"symbol": "ETH-PERP",
"type": "limit",
"status": "open"
},
{
"symbol": "BTC-PERP",
"type": "take_profit",
"status": "canceled"
}
]
}
}
PostMessage Contract (Host <-> Widget)
This section is most relevant for single-page applications and for handling cases like
- re-auth without a full page refresh, or
- knowing when new chat initiated so latest user context can be re-shared, or
- capture suggested trade information and pre-fill values.
All messages use a shared envelope. Top-level keys must only include:
type(string)source(string)version(number)instanceId(string)timestamp(number,Date.now())payload(object, optional)
Example envelope:
{
"type": "elfa.apollo.widget.ready",
"source": "elfa-apollo-widget",
"version": 1,
"instanceId": "f7b7b2c1-4f2c-4d7f-9d5f-88e6c0f7c9aa",
"timestamp": 1700000000000,
"payload": {}
}
The embed script will normalize host messages by adding missing source, version, instanceId, and timestamp before forwarding them to the iframe.
Core messages
| Direction | Message | Payload | When to use |
|---|---|---|---|
| Widget → Host | elfa.apollo.widget.ready | omitted | General notification that the widget is ready and returns instanceId. |
| Widget → Host | elfa.apollo.widget.chat_message_sent | { "analysisType": string, "hasMessage": boolean, "messageLength": number, "hasAttachments": boolean, "attachmentCount": number, "sessionId"?: string } | Emitted immediately before sending a non-resume chat request to the backend. Useful as a host trigger to refresh tokenized user context (userData) when needed. |
| Widget → Host | elfa.apollo.widget.trade_callback | { "prefilledOrderValues": { "type"?: "limit" | "market" | "stop market" | "stop limit", "side"?: "buy" | "sell", "limit"?: number, "stopPrice"?: number, "stopLoss"?: number, "takeProfit"?: number }, "token"?: { "ticker": string, "chain": string, "contractAddress": string, "imageUrl"?: string } } | Emitted when the Ask Elfa Trade button is clicked and data-trade-callback is enabled. Use this to drive host-side trade flows from the displayed suggestion. |
| Widget → Host | elfa.apollo.widget.link_click | { "href": string, "text": string, "source": "chat_message" } | Emitted on every chat link click. Always fires. Set data-block-link-navigation="true" to also prevent default navigation. |
| Widget → Host | elfa.apollo.auth.reauth_required | { "status": number, "code"?: string, "reason"?: string, "error"?: string, "endpoint"?: string } | Edge case where the widget can't re-auth on its own; the host should fetch a new user token and send it via elfa.apollo.auth.update. |
| Host → Widget | elfa.apollo.auth.update | { "userToken": string } | Only after receiving elfa.apollo.auth.reauth_required. |
| Host → Widget | elfa.apollo.context.update | { "symbol"?: string, "chain"?: string, "contractAddress"?: string, "lookupPreference"?: "symbol" | "address" } | When users navigate to different token pages (or a generic page) so the widget shows the most relevant token details and auto-references accordingly. |
Security and validation rules
- The embed script only forwards host messages when
event.originis in the trusted allowlist. - The embed script only accepts widget messages when
event.originmatches the widget origin derived fromdata-base-url. - The embed script only accepts widget messages when
event.sourceis the iframe window. - The embed script only accepts widget messages when
event.data.source === "elfa-apollo-widget". - The embed script only accepts widget messages when
event.data.instanceIdmatches the current instance. - The widget validates host messages against the parent origin derived from
document.referrerwhen available. - The widget ignores host messages with mismatched
instanceId. - The embed script generates and appends a per-embed
instanceId, exposeswindow.__ELFA_APOLLO_INSTANCE_ID, and setsiframe.dataset.instanceId.
Host Responsibilities
- Listen for
elfa.apollo.auth.reauth_required, request a new user token from/embed/v1/get-user-token, and sendelfa.apollo.auth.updatewith the refreshed token. - Optionally listen for
elfa.apollo.widget.chat_message_sentto refresh user context: call/embed/v1/get-user-tokenwith updateduserData. - Optionally listen for
elfa.apollo.widget.trade_callbackto trigger host-side trading flows with the provided prefilled order values. - Optionally listen for
elfa.apollo.widget.link_clickto intercept chat link clicks. Setdata-block-link-navigation="true"to prevent default navigation and fully own link routing. - Wait for
elfa.apollo.widget.readybefore relying on UI state. The embed script will queue host messages by type and flush them on ready.
Examples
Host event listener (receive widget messages)
const widgetOrigin = "https://widget.elfa.ai";
window.addEventListener("message", (event) => {
if (event.origin !== widgetOrigin) return;
const data = event.data;
if (!data || data.source !== "elfa-apollo-widget") return;
switch (data.type) {
case "elfa.apollo.widget.ready":
console.log("Widget ready", data.instanceId);
break;
case "elfa.apollo.auth.reauth_required":
console.log("Reauth required", data.payload);
break;
case "elfa.apollo.widget.chat_message_sent":
void handleChatMessageSent(data.payload);
break;
case "elfa.apollo.widget.trade_callback":
console.log("Trade callback", data.payload);
break;
case "elfa.apollo.widget.error":
console.error("Widget error", data.payload?.error);
break;
default:
break;
}
});
Host -> Widget: context update
const instanceId = window.__ELFA_APOLLO_INSTANCE_ID;
function sendContextUpdate({ symbol, chain, contractAddress, lookupPreference }) {
window.postMessage(
{
type: "elfa.apollo.context.update",
source: "elfa-apollo-host",
version: 1,
instanceId,
timestamp: Date.now(),
payload: { symbol, chain, contractAddress, lookupPreference },
},
window.location.origin,
);
}
Host -> Widget: auth update
const instanceId = window.__ELFA_APOLLO_INSTANCE_ID;
function sendAuthUpdate(userToken) {
window.postMessage(
{
type: "elfa.apollo.auth.update",
source: "elfa-apollo-host",
version: 1,
instanceId,
timestamp: Date.now(),
payload: { userToken },
},
window.location.origin,
);
}
Reauth flow example
async function handleReauthRequired(payload) {
const newToken = await getUserToken(
"YOUR_WIDGET_KEY",
"user_123",
{ name: "Demo User" },
window.location.origin,
);
if (newToken) {
sendAuthUpdate(newToken);
}
}
Optional: refresh userData context on chat send
async function handleChatMessageSent(_payload) {
const latestUserData = getLatestUserDataFromYourApp();
const newToken = await getUserToken(
"YOUR_WIDGET_KEY",
"user_123",
latestUserData,
window.location.origin,
);
if (newToken) {
sendAuthUpdate(newToken);
}
}
Troubleshooting
- Widget does not render: confirm
data-user-tokenis set. - Messages are ignored: confirm the host origin is in
data-trusted-originsandinstanceIdmatches. - No widget messages received: verify you are listening for
event.origin === "https://widget.elfa.ai"andevent.data.source === "elfa-apollo-widget". - Reauth loops: ensure you only call
/embed/v1/get-user-tokenwhen you receiveelfa.apollo.auth.reauth_required. - Chat returns "Usage limit exceeded" (HTTP
429,code: QUOTA_EXCEEDED): the user has hit a per-user limit you configured vialimitsin/embed/v1/get-user-token. InspectlimitTypeandresetsAtin the response to surface a friendly UI or extend the limit.
Changelog
Changes to this integration contract are tracked in the Changelog.