Guide

Embed the trader

The full trading platform - terminal, chart, positions, wallet, history, KYC - as a module inside your own page or app, in your colours and language, signed in by your own login, with your cashier and your push notifications. The module is a web view with one small bridge: one message shape in each direction.

#What you get

Module
your brand's trader at https://live.your-brand.com in embed mode
Hosts
web page (iframe), iOS, Android, React Native
Bridge
protocol v1 - events from the module, commands from you
Sign-in
one-time token from the Partner API, session kept by the host

No chrome of the platform (header, navigation, branding hidden by default), your theme, your language. Your app stays yours: the module never opens another site, never charges a card and never registers for push - it asks you to.

#How it works

Your app or page host · source "qe-host" postMessage / evaluateJavascript Trading module web view · source "qe-embed" https://live.your-brand.com load ?embed=1&transport=…&token=… options as query string, one-time login token ready { protocol: 1, authenticated } first screen is up - hide your splash login { session, client_id, account } keep session in the keychain / keystore navigate { screen: "wallet" } answered by navigation { path } deposit_requested { account, client_id } deposit=host: open your cashier unlock { ok: true } after unlock_requested: your biometric result

One message shape each way. Commands go host → module as { source: "qe-host", type, data }; events come back as { source: "qe-embed", v: 1, type, data, ts }. The host keeps the session, runs biometrics, opens the cashier and registers push - the module does the trading.

  1. your appthe user signs in to your app as today
  2. your backendPOST …/users/{external_id}/sso on the Partner API → a one-time trading login token (the key never leaves your server)
  3. moduleloads with ?embed=1&…&token=… (or the login command), exchanges the token, sends ready and login { session }
  4. hostkeeps session; next time restoreSession signs in without a new token
  5. bothevents (trade_opened, deposit_requested, unlock_requested, …) and commands (navigate, setTheme, unlock, …) flow for as long as the module lives

#Before you start

  • Your brand's trader address (https://live.your-brand.com) - the module URL.
  • Partner API access for your backend with the sso:issue scope, and your external_id scheme - see the single sign-on guide.
  • Embed mode switched on for the brand (CRM Settings > Trader; default on). When it is off the module sends one error { code: "embed_disabled" } and shows nothing.
  • For a web host page: your page's origin added to the brand's frame allow-list (CSP frame-ancestors, set per brand by Octolit). Without it the browser refuses to show the frame.
  • A demo brand on the same build for your tests, with test logins.

#Web host (iframe)

One <iframe> plus a few lines of script. The frame loads the module with the options as a query string; window.postMessage carries the bridge in both directions.

  1. The frame

    index.html
    <!-- allow="camera; microphone" lets the KYC page take ID photos inside the frame -->
    <iframe id="mod" title="Trading" allow="camera; microphone"
            style="width:100%;height:100%;border:0;border-radius:12px"></iframe>
  2. Load, listen, send

    Three rules keep it safe: send commands only to the module's origin, accept events only from that origin, and only when source is qe-embed. Pass origin so the module may send you the session in the login event.

    host.js
    // the trader address of your brand
    var MODULE_ORIGIN = 'https://live.your-brand.com';
    var frame = document.getElementById('mod');
    
    function moduleUrl(extra) {
      var p = new URLSearchParams({
        embed: '1', transport: 'iframe', origin: window.location.origin,
        screen: 'terminal', theme: 'dark', primary: '#ff7a1a', bg: '#101418', radius: '14',
        deposit: 'host', links: 'host', lock: '1', lock_after: '300'
      });
      Object.keys(extra || {}).forEach(function (k) { p.set(k, extra[k]); });
      return MODULE_ORIGIN + '/?' + p.toString();
    }
    
    // host -> module: one command shape
    function send(cmd) {
      if (!frame.contentWindow) return;
      frame.contentWindow.postMessage(Object.assign({ source: 'qe-host' }, cmd), MODULE_ORIGIN);
    }
    
    // module -> host: one event shape
    window.addEventListener('message', function (ev) {
      if (ev.origin !== MODULE_ORIGIN) return;           // only the module
      var m = ev.data;
      if (!m || m.source !== 'qe-embed') return;         // only bridge events
      var data = m.data || {};
      switch (m.type) {
        case 'ready':             hideSplash(); break;
        case 'login':             sessionStorage.setItem('trading_session', data.session); break;
        case 'logout':            sessionStorage.removeItem('trading_session'); break;
        case 'session_expired':   sessionStorage.removeItem('trading_session'); signIn(); break;
        case 'auth_required':     signIn(); break;
        case 'deposit_requested': openCashier(data.account, data.client_id); break;
        case 'open_external':     window.open(data.url, '_blank', 'noopener'); break;
        case 'unlock_requested':  confirmUser(function (ok) { send({ type: 'unlock', data: { ok: ok } }); }); break;
        case 'height':            frame.style.minHeight = data.height + 'px'; break;
        case 'error':             console.warn('module error', data.code, data.command); break;
      }
    });
    
    // first load: restore a kept session, otherwise sign in with a one-time token
    var kept = sessionStorage.getItem('trading_session');
    frame.src = moduleUrl();
    if (kept) {
      frame.addEventListener('load', function once() {
        frame.removeEventListener('load', once);
        send({ type: 'restoreSession', data: { session: kept } });
      });
    } else {
      signIn();
    }
    
    function signIn() {
      // your backend calls POST /api/external/v1/users/{external_id}/sso and returns the token
      fetch('/app/trading-token', { credentials: 'include' })
        .then(function (r) { return r.json(); })
        .then(function (j) { send({ type: 'login', data: { token: j.token } }); });
    }
  3. Commands while the module is showing

    host.js - commands
    send({ type: 'navigate',    data: { screen: 'wallet' } });
    send({ type: 'navigate',    data: { path: '/dashboard/positions' } });
    send({ type: 'setTheme',    data: { theme: 'light', primary: '#2563eb', radius: 10 } });
    send({ type: 'setTheme',    data: null });                     // back to the brand's own theme
    send({ type: 'setLanguage', data: { lang: 'de' } });
    send({ type: 'lock' });                                        // module answers unlock_requested
    send({ type: 'getState' });                                    // module answers state
    send({ type: 'logout' });

The complete host page with a bridge log is part of the Octolit Embed SDK package; the Web SDK page walks through it step by step, including sizing with the height event.

#Sign-in (SSO)

The only flow you must build on your server. Your backend asks the Partner API for a one-time token for the user who is signed in to your app:

POST /api/external/v1/users/cust_10042/sso (your backend)
curl -X POST "https://api.your-brand.com/api/external/v1/users/cust_10042/sso" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "target": "trader"
}'
  1. Hand the token to the module

    On the first load as ?token=… in the URL, or at any time with the login { token } command. The token lives for the brand's login link lifetime (default 60 s) and works once.

  2. Keep the session

    The module exchanges the token, signs in and sends login { session, client_id, account, email }. Keep session - the native SDKs store it in the keychain / keystore, a web host in its own storage. The token is removed from the URL after the exchange and is never stored.

  3. Restore next time

    Send restoreSession { session } (native SDKs: start() with no token) - signed in without a round trip through your backend.

  4. React to session_expired and auth_required

    session_expired means the server refused the session; auth_required means a screen needs a user and there is none. In both cases drop the kept session and repeat step 1 with a fresh token. Sign the module out (logout) when the user signs out of your app.

Never
put the Partner API key in the app or page, log the token or the session, or reuse a one-time token.

#Options

Sent as query parameters on the module URL (web) or injected as window.__QE_EMBED__ before the page scripts run (native SDKs do this). Both spellings of the two-word names are accepted. Everything you send is validated by the module: colours, radius, https-only font URL, known screens.

OptionDefaultMeaning
embed1Turns embed mode on. Always send it; a normal visit to the trader is unchanged without it.
transportiframe | ios | android | rnWho is hosting. Decides the event channel and the links=auto rule.
screenterminalFirst screen on a cold start: terminal, wallet, history, account, positions.
account-Trading account id to select.
langdevice / browser languageUI language: en, de, es, pt, ar, … RTL languages flip the layout.
theme-light or dark (injected key: theme or mode).
primary-#RRGGBB accent colour: buttons, links, active tabs.
bg-#RRGGBB page background.
paper= bg#RRGGBB card background.
text-#RRGGBB main text colour. Checked for 4.5:1 contrast; an unreadable value is replaced by the module's own.
radius-Corner radius in px, 0 - 32.
font-Font family name.
font_url-https URL of a stylesheet that loads the font.
hideheader,nav,brandingComma list of the module's chrome to hide.
show-Comma list taken out of hide.
depositmodulehost = the Deposit button only sends deposit_requested; module = the platform cashier inside the module.
linksautohost = outside links are handed to you (open_external); module = opened inside; auto = host on ios / android / rn, module on iframe.
lock01 = lock screen on; the host runs biometrics and answers unlock.
lock_after300Seconds in the background before the module locks (10 - 86 400).
pro-0 = simple terminal only. Otherwise each user keeps their own Pro mode choice.
login_form-1 = keep the module's own sign-in form when there is no session (default: a neutral "sign in" page; you sign the user in).
origin-iframe only: the host page origin that may receive events. Required to get the session in the login event.
session-Injected only (native SDKs): a session kept from an earlier login event - signed in without a new token.
token-URL or injected: a one-time login token from your backend. Exchanged for a session, then removed from the URL.

#Events (module → host)

One JSON object per message. On the web it arrives as a message event on the parent window; the native SDKs turn it into listener callbacks.

an event
{
  "source": "qe-embed",
  "v": 1,
  "type": "trade_opened",
  "data": {
    "position": { "id": 812, "symbol": "EUR-USD", "side": "buy", "lots": 0.1, "price": 1.0752 },
    "request": { "symbol": "EUR-USD", "side": "buy", "lots": 0.1 }
  },
  "ts": 1762853400123
}
typedataWhen
ready{ version, protocol: 1, transport, screen, authenticated }The module shows its first screen - hide your splash. login or navigation may arrive before it.
state{ version, protocol, transport, authenticated, locked, gate, path }Answer to init, getState and resume.
error{ code, command?, message? }embed_disabled (brand switched embed off), bad_command, command_failed, unlock_denied.
login{ session, client_id, account, email }Signed in. Keep session - it restores the sign-in next time.
logoutnullSigned out; drop the kept session.
session_expired{ path }The server refused the session - get a new one-time token from your backend.
auth_required{ path }A screen needs a signed-in user and there is none.
navigation{ path }The module moved to another screen.
trade_opened{ position: { id, symbol, side, lots, price }, request: { symbol, side, lots } }A position was opened.
trade_closed{ position_id, partial, lots, position, bulk?, reason? }A position was closed, fully or partly.
deposit_requested{ account, client_id }deposit=host: open your cashier.
open_external{ url }links=host: open the URL in a new tab or the system browser.
height{ height }iframe: document height for auto-sizing.
unlock_requested{ reason: user | host | resume | timeout }Run your unlock, answer with the unlock command.
unlockednullThe lock screen is gone.
theme_applied{ mode?, primary?, bg?, paper?, text?, radius?, font? }What the module applied after setTheme (or the start options).
push_registered{ ok, reason? }Answer to pushTokenRegistered.

Nothing is sent before the brand's embed switch is known (events queue, at most 50). Commands you send before ready are queued by the SDKs; a web host should wait for ready or the frame's load event.

#Commands (host → module)

Web: frame.contentWindow.postMessage({ source: "qe-host", type, data }, moduleOrigin). Native: window.QEEmbed.handle({ type, data }) through evaluateJavascript - the SDKs wrap every command in a method.

typedataEffect
initnullAnswered with state. The SDKs send it for you.
login{ token, account? }One-time login token - the module reloads and signs in.
restoreSession{ session, account? }A kept session - signed in without a new login.
logoutnullSign the module out.
setTheme{ theme, primary, bg, paper, text, radius, font }Any subset; null clears back to the brand theme. Answered with theme_applied.
setLanguage{ lang }Switch the UI language.
navigate{ screen } or { path: "/dashboard/…" }Move to a screen; answered with navigation.
pushTokenRegistered{ token, platform: ios | android, provider: apns | fcm }Hand over the device token; answered with push_registered.
locknullShow the lock screen; the module sends unlock_requested.
unlock{ ok: true | false }Your biometric / confirmation result. false answers error unlock_denied.
resumenullApp came to the foreground; answered with state (+ unlock_requested when locked).
getStatenullAnswered with state.

A failed command answers error { code: "command_failed", command }; an unknown one bad_command.

#Theming and language

  • Start with the options (theme, primary, bg, paper, text, radius, font, font_url) and change at runtime with setTheme - any subset, null clears. The module answers theme_applied with what it really applied.
  • Text colours are checked for 4.5:1 readability; an unreadable value is replaced by the module's own.
  • The OS dark-mode switch does not override your theme: the module keeps what you sent.
  • setLanguage { lang } switches the UI; RTL languages (ar, fa) flip the layout automatically.
  • hide defaults to header,nav,branding because your app has its own navigation. Use show to bring a part back.

#Deposits, outside links, lock

The host does

  • Deposit (deposit=host): the Deposit button only sends deposit_requested { account, client_id }. Open your cashier; balances refresh by themselves afterwards. Use deposit=module for the platform cashier inside the module.
  • Outside links (links=host): every link to another site arrives as open_external { url }. Open it in a new tab or the system browser; the web view itself never leaves the module's origin.
  • Lock (lock=1): after lock_after seconds in the background, or on the lock command, the module shows its lock screen and sends unlock_requested { reason }. Run Face ID / fingerprint (native) or your own confirmation (web) and answer unlock { ok }. Send resume when your app returns to the foreground.
  • Push: register with APNs / FCM yourself and hand the token over once with pushTokenRegistered. Trade, margin and deposit pushes for that user then go to that device.

The module does

  • Trading, chart, positions, wallet, history and KYC screens.
  • Token exchange, session handling, the neutral sign-in page when there is no session.
  • Validation of everything you send.
  • KYC uploads through a normal file input - the frame needs allow="camera; microphone", a native app the camera and photo permissions.

#iOS and Android

The Octolit Embed SDK wraps the web view and the bridge for both platforms: a Swift package (QEEmbedController, QEEmbedView) and a Gradle module (io.qeembed.QEEmbedView), with a keychain / keystore session store, biometric unlock, push and deep-link helpers and a KYC file picker. Packages are provided by Octolit on request.

A React Native host implements the same protocol with transport=rn: the module posts events through window.ReactNativeWebView.postMessage and takes commands through injectJavaScript; see the SDK overview.

#Security

  • Web: the module is framed only by origins on the brand's allow-list (CSP frame-ancestors). Check ev.origin on every event and target the module's origin on every command.
  • The one-time token is removed from the URL after the exchange and never stored; the session is kept by the host only (keychain / keystore on mobile).
  • The web view loads only the module's origin; everything else goes to the system browser.
  • Text you pass is validated; a bad colour, radius or font URL is ignored, an unknown screen falls back to the default.
  • No certificate pinning in the SDK by design - the platform's certificates rotate automatically.