Web SDK
There is nothing to install for the web: one <iframe> and a short script that speaks the bridge over postMessage. The host page below is the complete integration; the same page with a bridge log is part of the Octolit Embed SDK package.
#Install
- Your page's origin (scheme + host) is on the brand's frame allow-list - Octolit sets the module's CSP
frame-ancestorsper brand. Without it the browser shows an empty frame. - Your page is served over https; the module's origin is
https://live.your-brand.com. - Your backend can mint one-time tokens (SSO guide, scope
sso:issue).
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Your app - trading</title>
<style>
body { margin: 0; }
main { height: 100vh; padding: 12px; box-sizing: border-box; }
iframe { width: 100%; height: 100%; border: 0; border-radius: 12px; }
</style>
</head>
<body>
<main>
<!-- allow="camera; microphone": the KYC page can take ID photos inside the frame -->
<iframe id="mod" title="Trading" allow="camera; microphone"></iframe>
</main>
<script src="/host.js"></script>
</body>
</html>#Configure: the module URL
Options go on the module URL as query parameters. The module validates every value: a bad colour, radius or font URL is ignored, an unknown screen falls back to the default.
// the trader address of your brand - events are accepted only from here, commands sent only to here
var MODULE_ORIGIN = 'https://live.your-brand.com';
var frame = document.getElementById('mod');
function moduleUrl(extra) {
var p = new URLSearchParams({
embed: '1', // embed mode on
transport: 'iframe',
origin: window.location.origin, // your page's origin: required to receive the session in the login event
screen: 'terminal',
theme: 'dark', primary: '#ff7a1a', bg: '#101418', radius: '14',
deposit: 'host', // Deposit button -> deposit_requested
links: 'host', // outside links -> open_external
lock: '1', lock_after: '300' // lock screen after 5 min in the background
});
Object.keys(extra || {}).forEach(function (k) { p.set(k, extra[k]); });
return MODULE_ORIGIN + '/?' + p.toString();
}
frame.src = moduleUrl();| Option | Default | Meaning |
|---|---|---|
embed | 1 | Turns embed mode on. Always send it. |
transport | iframe | Who is hosting; decides the event channel (window.parent.postMessage) and links=auto. |
origin | - | Your page's origin. Required to receive the session in the login event. |
screen | terminal | First screen: terminal, wallet, history, account, positions. |
account | - | Trading account id to select. |
lang | browser language | UI language: en, de, es, pt, ar, … |
theme | - | light or dark. |
primary / bg / paper / text | - | #RRGGBB accent, page background, card background (default = bg), main text (4.5:1 check). |
radius | - | Corner radius in px, 0 - 32. |
font / font_url | - | Font family name and an https stylesheet URL that loads it. |
hide / show | header,nav,branding | Comma lists of the module's chrome to hide, and to take out of hide again. |
deposit | module | host = the Deposit button only sends deposit_requested. |
links | auto (= module on iframe) | host = outside links come to you as open_external; module = opened inside the frame. |
lock / lock_after | 0 / 300 | Lock screen on; seconds in the background before it locks (10 - 86 400). |
pro | - | 0 = simple terminal only. |
login_form | - | 1 = keep the module's own sign-in form when there is no session. |
token | - | A one-time login token on the first load (or use the login command). |
The full option list with the native-only keys (session, injected options) is in the embed guide.
#The bridge
Commands are objects with source: "qe-host" posted to the frame's window, targeted at the module's origin. Events arrive as message events on your window with source: "qe-embed". Check the origin and the source on every message.
// host -> module
function send(cmd) {
if (!frame.contentWindow) return;
frame.contentWindow.postMessage(Object.assign({ source: 'qe-host' }, cmd), MODULE_ORIGIN);
}
// module -> host
window.addEventListener('message', function (ev) {
if (ev.origin !== MODULE_ORIGIN) return; // 1. only the module's origin
var m = ev.data;
if (!m || m.source !== 'qe-embed') return; // 2. only bridge events
var data = m.data || {};
switch (m.type) {
case 'ready': hideSplash(); break;
case 'login': keepSession(data.session); break;
case 'logout': dropSession(); break;
case 'session_expired': dropSession(); signIn(); break;
case 'auth_required': signIn(); break;
case 'navigation': syncTabs(data.path); break;
case 'trade_opened': track('trade_opened', data.position); 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;
}
});Commands are only delivered once the frame has loaded; send them after the frame's load event or after ready. Nothing is sent by the module before the brand's embed switch is known; when the switch is off, one error { code: "embed_disabled" } arrives and the frame stays empty.
#Sign-in (SSO)
The page never holds the Partner API key. Your backend calls POST /api/external/v1/users/{external_id}/sso for the user who is signed in to your site and returns the one-time token; the page hands it to the module.
// your backend: POST https://api.your-brand.com/api/external/v1/users/{external_id}/sso -> { token, ... }
// the Partner API key stays on your server; the page only ever sees the one-time token
function signIn() {
fetch('/app/trading-token', { credentials: 'include' })
.then(function (r) { return r.json(); })
.then(function (j) { send({ type: 'login', data: { token: j.token } }); });
}
// alternative for the very first load: the token on the URL
// frame.src = moduleUrl({ token: tokenFromYourBackend });- The token lives for the brand's login link lifetime (default 60 s) and works once; fetch it right before you send it.
- The module answers
login { session, client_id, account, email }- only when theoriginoption matches your page. session_expired/auth_required: drop the kept session and sign in again with a fresh token.- Send
logoutwhen the user signs out of your site.
#Session restore
Keep session from the login event and hand it back with restoreSession on the next load: the user is signed in without a round trip through your backend. sessionStorage keeps it for the tab; use a stricter store if your page's threat model needs it, and never a cookie readable by other scripts.
function keepSession(s) { sessionStorage.setItem('trading_session', s); }
function dropSession() { sessionStorage.removeItem('trading_session'); }
var kept = sessionStorage.getItem('trading_session');
frame.src = moduleUrl();
if (kept) {
// after the frame loaded: hand the kept session over, no new token needed
frame.addEventListener('load', function once() {
frame.removeEventListener('load', once);
send({ type: 'restoreSession', data: { session: kept } });
});
} else {
signIn();
}#Events
| type | data | Do |
|---|---|---|
ready | { version, protocol: 1, transport, screen, authenticated } | Hide your splash; the module is up. |
state | { authenticated, locked, gate, path, … } | Answer to getState / resume. |
error | { code, command?, message? } | embed_disabled, bad_command, command_failed, unlock_denied. |
login | { session, client_id, account, email } | Keep session (only sent when origin matches your page). |
logout | null | Drop the kept session. |
session_expired | { path } | Drop the session, fetch a new token. |
auth_required | { path } | Fetch a token. |
navigation | { path } | Sync your own URL or tabs. |
trade_opened | { position, request } | Analytics, in-page messages. |
trade_closed | { position_id, partial, lots, position, bulk?, reason? } | Analytics. |
deposit_requested | { account, client_id } | Open your cashier (deposit=host). |
open_external | { url } | Open in a new tab (links=host). |
height | { height } | Auto-size the frame. |
unlock_requested | { reason } | Confirm the user your way, answer unlock { ok }. |
unlocked | null | The lock screen is gone. |
theme_applied | { mode?, primary?, bg?, paper?, text?, radius?, font? } | What the module really applied. |
push_registered | { ok, reason? } | Not used by a web host. |
Every event is { source: "qe-embed", v: 1, type, data, ts }. Payload details are in the embed guide.
#Commands
send({ type: 'navigate', data: { screen: 'wallet' } }); // terminal | wallet | history | account | positions
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: 'unlock', data: { ok: true } });
send({ type: 'getState' }); // module answers state
send({ type: 'logout' });| type | data | Effect |
|---|---|---|
login | { token, account? } | One-time token - the module reloads and signs in. |
restoreSession | { session, account? } | A kept session - signed in without a new login. |
logout | null | Sign out. |
setTheme | { theme, primary, bg, paper, text, radius, font } | Any subset; null clears. |
setLanguage | { lang } | Switch the UI language. |
navigate | { screen } or { path } | Move to a screen or a module path. |
lock | null | Show the lock screen. |
unlock | { ok } | Your confirmation result. |
resume | null | Tab became visible again (document.visibilityState); re-checks the lock timer. |
getState | null | Answered with state. |
init | null | Answered with state; not needed in a browser - the module initialises itself on load. |
pushTokenRegistered | { token, platform, provider } | Not used by a web host. |
A failed command answers error { code: "command_failed", command }; an unknown one bad_command.
#Theming and language
- Start values on the URL, runtime changes with
setTheme(any subset,nullclears). The module answerstheme_appliedwith what it really applied. - Text colours are checked for 4.5:1 readability; an unreadable value is replaced by the module's own.
- The browser's dark-mode preference does not override your theme.
setLanguageswitches the UI; RTL languages (ar, fa) flip the layout automatically.- With
hide=header,nav,branding(default) your page supplies the navigation;show=navbrings the module's own back.
#Navigation and deep links
// your own routes -> the module. Example: https://app.your-brand.com/trade/positions
var m = location.pathname.match(/^\/trade\/([a-z]+)/);
if (m) send({ type: 'navigate', data: { screen: m[1] } });
// keep your URL in step with the module
// case 'navigation': history.replaceState(null, '', '/trade' + data.path.replace('/dashboard', ''));navigate takes a screen (terminal, wallet, history, account, positions) or a module path (/dashboard/…). The module reports every move with navigation { path }; mirror it into your own URL so the browser's back button and shared links behave.
// the module reports its document height; let the frame grow with it instead of scrolling inside
// case 'height': frame.style.minHeight = data.height + 'px';
// or give the frame the full viewport (the sample page) and let the module scroll itself#Lock
With lock=1 the module locks after lock_after seconds while the tab is hidden, or on the lock command, and sends unlock_requested { reason }. A browser has no biometric prompt of its own: confirm the user your way (re-enter the site password, a passkey, a simple "Continue" if your page is already behind a login) and answer unlock { ok }. Send resume when the tab becomes visible again (visibilitychange) so the module re-checks the timer.
#Push
Not part of a web host: pushTokenRegistered and push_registered exist for the native SDKs. Trade and margin events for your own notifications come from the webhooks on your backend.
#KYC upload
The module's KYC page uses a normal file input. Give the frame allow="camera; microphone" so the browser lets it take an ID photo; without it the picker still offers files.
#Deposits and outside links
deposit=host: the Deposit button only sendsdeposit_requested { account, client_id }- open your cashier in your page; balances refresh by themselves.deposit=modulekeeps the platform cashier inside the frame.linksdefaults toauto, which on an iframe meansmodule: outside links open inside the frame. Setlinks=hostto receive them asopen_external { url }and open a new tab withnoopener.
#Test plan
Run against the demo brand, in the browsers your clients use, before go-live:
No session
Load with no session: the neutral sign-in page shows and
ready { authenticated: false }arrives.Token sign-in
login { token }with a token from the demo Partner API:loginarrives, the terminal shows, the token is not in the frame's URL. Reload the page:restoreSessionsigns in without a new token.Theme
Dark / light, your primary colour, radius, font →
theme_applied. Screenshots at 390 px wide.Hand-overs
Deposit button →
deposit_requested; withlinks=hostan outside link →open_external.Lock
Hide the tab for
lock_afterseconds (or sendlock) → lock screen → your confirmation →unlocked.Frame allow-list
The frame renders on your production origin and on your staging origin; a page on an origin that is not listed shows an empty frame.
KYC
A photo from the camera and a file from disk upload.
Deep links
Your
/trade/<screen>route opens the right screen on a cold load and from a running page.Rough conditions
Narrow viewport, the OS dark-mode switch (the module keeps your theme), offline and back online.
#Performance
- The module is tuned for a throttled mobile profile (4× CPU slowdown, slow 4G).
- One frame per page; keep it mounted while the user moves between your own views (hide it, do not remove it) and use
navigateinstead of reloading. - Give the frame its full height (the sample page) or follow the
heightevent; avoid a scroll inside a scroll.
#Security notes
- The module is framed only by origins on the brand's allow-list (CSP
frame-ancestors). - Your listener drops every message whose
ev.originis not the module's origin or whosesourceis notqe-embed; every command is posted with the module's origin as the target, never*. - The
loginevent carries the session only when theoriginoption names your page. - The one-time token is removed from the module's URL after the exchange; your page never stores it.
- Open outside links with
noopener.