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/json
  • X-Widget-Key: <your_widget_key>

Recommended headers:

  • Origin: https://your-domain.example
  • Cache-Control: no-cache, no-store, must-revalidate
  • Pragma: no-cache

Request body:

  • userIdentifier (string, required) - your internal user ID
  • userData (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 embedded
  • limits (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 sign market_order / limit_order webhook deliveries. Not echoed in the response. See Widget Order Webhook Signature Validation below.

Response:

  • userToken is returned at the top level.
  • quota (object, optional) - present when limits are active for the user. Contains limits, usage (counters today/this month), and remaining. 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-token again whenever user context changes (include updated userData).
  • 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:

FieldTypeDescription
user_daily_chatsnumber | nullMaximum chats this user can send per UTC calendar day. null/omitted = unlimited.
user_monthly_chatsnumber | nullMaximum chats this user can send per UTC calendar month. null/omitted = unlimited.
user_session_messagesnumber | nullMaximum 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 limits whenever 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 null for 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:

AttributeRequiredDescriptionFormat / ValuesDefault
data-user-tokenYesUser token returned by /embed/v1/get-user-token.StringNone
data-widthNoWidget panel width.Number (px)500
data-heightNoWidget panel height.Number (px)700
data-launcher-icon-sizeNoLauncher icon size. Applies to both the default Elfa icon and custom icons — no feature flag required.Number (px)80
data-themeNoWidget color theme.light, darkdark
data-color-schemeNoControls the iframe color-scheme CSS property. force applies the theme to the iframe canvas; none leaves it transparent.force, nonenone
data-default-tabNoWhich tab to show when the widget first opens.ask-elfa, insight, autoask-elfa
data-lookup-preferenceNoToken lookup mode. Use symbol to resolve by symbol only; use address (or omit) to resolve by chain + contract address.symbol, addressaddress
data-symbolNoToken symbol.StringNone
data-chainNoChain identifier.String (e.g., eth, solana)None
data-contract-addressNoToken contract address.StringNone
data-image-urlNoToken image/logo URL to display in the widget header.URL stringNone
data-launcher-icon-srcNoCustom image URL for the launcher button. Requires personalization to be enabled on your account.URL stringElfa default icon
data-margin-xNoHorizontal margin from each screen edge for the launcher snap position.Number (px)20
data-margin-yNoVertical margin from each screen edge for the launcher snap position.Number (px)20
data-elfa-hot-keyNoCustom keyboard shortcut to toggle the widget open/close. Uses TanStack Hotkeys format.String (e.g. Shift+Mod+E)None (disabled)
data-elfa-styleNoJSON object of color overrides for custom branding.JSON stringNone
data-widget-styleNoInline CSS style rules applied to the widget core container.CSS stringNone
data-custom-labelsNoJSON object of UI label overrides. Requires custom_labels personalization on the widget-key.JSON stringNone
data-trade-callbackNoEnables Trade button callback in Ask Elfa cards. When enabled, clicking Trade emits a postMessage callback payload based on displayed prefilled order values.true, falsefalse
data-trusted-originsNoExtra allowed host origins for PostMessage.Comma-separated originswindow.location.origin
data-block-link-navigationNoWhen true, prevents default link navigation in chat. The host receives elfa.apollo.widget.link_click and is responsible for routing.true, falsefalse

Notes:

  • The widget only renders when a user token is provided.
  • data-trusted-origins must be a comma-separated list of full origins (scheme://host:port).
  • If data-lookup-preference="symbol", provide data-symbol.
  • If lookup preference is omitted or set to address, provide both data-chain and data-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:

ModifierKey
Mod⌘ on macOS, Ctrl on Windows/Linux
CtrlControl key
ShiftShift key
AltAlt / 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:

KeyCSS VariableDescription
accent--content-accentPrimary 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-primaryMain background surface (cards, panels).
surfaceSecondary--surface-secondarySecondary surface (nested sections, hover states).
contentPrimary--content-primaryPrimary text / icon color.
contentSecondary--content-secondarySecondary 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-style color 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:

KeyDescription
tab.labels.chatReplaces the "Chat" tab label.
tab.labels.insightReplaces the "Feed" tab label.
chat.landing.titleReplaces the "Ask Elfa" heading shown on the empty chat state.
chat.input.placeholderReplaces 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 &apos; (e.g. data-custom-labels='{"chat.landing.title":"Joe&apos;s Bot"}').
  • Requires the custom_labels personalization 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) - currently triggered for widget order events.
  • title (string) - usually Widget Order Triggered.
  • body (string) - plan title.
  • queryId (string)
  • executionId (string)
  • triggerTime (ISO string)
  • conditionsMet (number)
  • orderType (market_order or limit_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 partnerHmacSecret when calling /embed/v1/get-user-token for the user.
  • Your widget key's webhookUrl must be configured in the widget configuration.

Signature Headers:

HeaderDescription
X-Elfa-Widget-SignatureHMAC-SHA256 signature with v1= prefix (e.g., v1=abc123...)
X-Elfa-Widget-Signature-TimestampUnix timestamp in seconds when the signature was generated
X-Elfa-Widget-Event-IdUnique event identifier (also available as X-Auto-Event-Id for compatibility)
X-Elfa-Widget-Key-IdThe widget key ID (your partner identifier)

Signing Algorithm:

derivedSecret = sha256(yourWidgetKey + ":" + partnerHmacSecret)
signedPayload = timestamp + "." + eventId + "." + rawBody
signature = HMAC-SHA256(derivedSecret, signedPayload)

Verification Steps:

  1. Extract the signature from X-Elfa-Widget-Signature (remove the v1= prefix)
  2. Extract timestamp from X-Elfa-Widget-Signature-Timestamp
  3. Extract eventId from X-Elfa-Widget-Event-Id
  4. Read the raw request body (as bytes, before parsing JSON)
  5. Compute: expectedSignature = HMAC-SHA256(derivedSecret, timestamp + "." + eventId + "." + rawBody)
  6. 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 partnerHmacSecret must match the value you provided when creating the user token via /embed/v1/get-user-token.
  • Always use constant-time comparison (crypto.timingSafeEqual in Node.js, hmac.compare_digest in 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 partnerHmacSecret for a user, the signature will be computed with an empty string as the per-user secret component.

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

DirectionMessagePayloadWhen to use
Widget → Hostelfa.apollo.widget.readyomittedGeneral notification that the widget is ready and returns instanceId.
Widget → Hostelfa.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 → Hostelfa.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 → Hostelfa.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 → Hostelfa.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 → Widgetelfa.apollo.auth.update{ "userToken": string }Only after receiving elfa.apollo.auth.reauth_required.
Host → Widgetelfa.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.origin is in the trusted allowlist.
  • The embed script only accepts widget messages when event.origin matches the widget origin derived from data-base-url.
  • The embed script only accepts widget messages when event.source is 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.instanceId matches the current instance.
  • The widget validates host messages against the parent origin derived from document.referrer when available.
  • The widget ignores host messages with mismatched instanceId.
  • The embed script generates and appends a per-embed instanceId, exposes window.__ELFA_APOLLO_INSTANCE_ID, and sets iframe.dataset.instanceId.

Host Responsibilities

  • Listen for elfa.apollo.auth.reauth_required, request a new user token from /embed/v1/get-user-token, and send elfa.apollo.auth.update with the refreshed token.
  • Optionally listen for elfa.apollo.widget.chat_message_sent to refresh user context: call /embed/v1/get-user-token with updated userData.
  • Optionally listen for elfa.apollo.widget.trade_callback to trigger host-side trading flows with the provided prefilled order values.
  • Optionally listen for elfa.apollo.widget.link_click to intercept chat link clicks. Set data-block-link-navigation="true" to prevent default navigation and fully own link routing.
  • Wait for elfa.apollo.widget.ready before 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-token is set.
  • Messages are ignored: confirm the host origin is in data-trusted-origins and instanceId matches.
  • No widget messages received: verify you are listening for event.origin === "https://widget.elfa.ai" and event.data.source === "elfa-apollo-widget".
  • Reauth loops: ensure you only call /embed/v1/get-user-token when you receive elfa.apollo.auth.reauth_required.
  • Chat returns "Usage limit exceeded" (HTTP 429, code: QUOTA_EXCEEDED): the user has hit a per-user limit you configured via limits in /embed/v1/get-user-token. Inspect limitType and resetsAt in the response to surface a friendly UI or extend the limit.

Changelog

Changes to this integration contract are tracked in the Changelog.