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
https://live.your-brand.com in embed modeNo 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
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.
- your appthe user signs in to your app as today
- your backend
POST …/users/{external_id}/ssoon the Partner API → a one-time trading login token (the key never leaves your server) - moduleloads with
?embed=1&…&token=…(or thelogincommand), exchanges the token, sendsreadyandlogin { session } - hostkeeps
session; next timerestoreSessionsigns in without a new token - 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:issuescope, and yourexternal_idscheme - 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.
-
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> -
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
sourceisqe-embed. Passoriginso the module may send you the session in theloginevent.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 } }); }); } -
Commands while the module is showing
host.js - commandssend({ 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:
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"
}'const res = await fetch("https://api.your-brand.com/api/external/v1/users/cust_10042/sso", {
method: "POST",
headers: {
"X-Api-Key": process.env.OCTOLIT_PARTNER_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"target": "trader"
}),
});
if (!res.ok) throw new Error(`POST failed: ${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);import os, requests
r = requests.request(
"POST",
"https://api.your-brand.com/api/external/v1/users/cust_10042/sso",
headers={"X-Api-Key": os.environ["OCTOLIT_PARTNER_KEY"]},
json={
"target": "trader",
},
timeout=10,
)
r.raise_for_status()
print(r.json())<?php
$ch = curl_init("https://api.your-brand.com/api/external/v1/users/cust_10042/sso");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-Api-Key: " . getenv("OCTOLIT_PARTNER_KEY"),
"Content-Type: " . "application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"target" => "trader",
]),
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($data);using var http = new HttpClient();
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.your-brand.com/api/external/v1/users/cust_10042/sso");
req.Headers.Add("X-Api-Key", Environment.GetEnvironmentVariable("OCTOLIT_PARTNER_KEY"));
req.Content = new StringContent("""
{
"target": "trader"
}
""", Encoding.UTF8, "application/json");
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.your-brand.com/api/external/v1/users/cust_10042/sso"))
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{
"target": "trader"
}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());body := strings.NewReader(`{
"target": "trader"
}`)
req, err := http.NewRequest("POST", "https://api.your-brand.com/api/external/v1/users/cust_10042/sso", body)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Api-Key", os.Getenv("OCTOLIT_PARTNER_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.StatusCode, string(out))var req = URLRequest(url: URL(string: "https://api.your-brand.com/api/external/v1/users/cust_10042/sso")!)
req.httpMethod = "POST"
req.setValue(ProcessInfo.processInfo.environment["OCTOLIT_PARTNER_KEY"], forHTTPHeaderField: "X-Api-Key")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = """
{
"target": "trader"
}
""".data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: req)
print((response as! HTTPURLResponse).statusCode, String(decoding: data, as: UTF8.self))val client = OkHttpClient()
val req = Request.Builder()
.url("https://api.your-brand.com/api/external/v1/users/cust_10042/sso")
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.method("POST", """{
"target": "trader"
}""".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(req).execute().use { res ->
println("${res.code} ${res.body?.string()}")
} Hand the token to the module
On the first load as
?token=…in the URL, or at any time with thelogin { token }command. The token lives for the brand's login link lifetime (default 60 s) and works once.Keep the session
The module exchanges the token, signs in and sends
login { session, client_id, account, email }. Keepsession- 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.Restore next time
Send
restoreSession { session }(native SDKs:start()with no token) - signed in without a round trip through your backend.React to session_expired and auth_required
session_expiredmeans the server refused the session;auth_requiredmeans 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.
#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.
| Option | Default | Meaning |
|---|---|---|
embed | 1 | Turns embed mode on. Always send it; a normal visit to the trader is unchanged without it. |
transport | iframe | ios | android | rn | Who is hosting. Decides the event channel and the links=auto rule. |
screen | terminal | First screen on a cold start: terminal, wallet, history, account, positions. |
account | - | Trading account id to select. |
lang | device / browser language | UI 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. |
hide | header,nav,branding | Comma list of the module's chrome to hide. |
show | - | Comma list taken out of hide. |
deposit | module | host = the Deposit button only sends deposit_requested; module = the platform cashier inside the module. |
links | auto | host = outside links are handed to you (open_external); module = opened inside; auto = host on ios / android / rn, module on iframe. |
lock | 0 | 1 = lock screen on; the host runs biometrics and answers unlock. |
lock_after | 300 | Seconds 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.
{
"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
}| type | data | When |
|---|---|---|
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. |
logout | null | Signed 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. |
unlocked | null | The 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.
| type | data | Effect |
|---|---|---|
init | null | Answered 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. |
logout | null | Sign 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. |
lock | null | Show the lock screen; the module sends unlock_requested. |
unlock | { ok: true | false } | Your biometric / confirmation result. false answers error unlock_denied. |
resume | null | App came to the foreground; answered with state (+ unlock_requested when locked). |
getState | null | Answered 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 withsetTheme- 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 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.hidedefaults toheader,nav,brandingbecause your app has its own navigation. Useshowto bring a part back.
#Deposits, outside links, lock
The host does
- Deposit (
deposit=host): the Deposit button only sendsdeposit_requested { account, client_id }. Open your cashier; balances refresh by themselves afterwards. Usedeposit=modulefor the platform cashier inside the module. - Outside links (
links=host): every link to another site arrives asopen_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): afterlock_afterseconds in the background, or on thelockcommand, the module shows its lock screen and sendsunlock_requested { reason }. Run Face ID / fingerprint (native) or your own confirmation (web) and answerunlock { ok }. Sendresumewhen 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.
iOS SDK
Swift package, iOS 14+, SwiftUI and UIKit views, Face ID unlock.
→Android SDK
Gradle module, minSdk 24, BiometricPrompt unlock, camera picker for KYC.
→Web SDK
The iframe host page: load, bridge, sizing, session restore.
→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). Checkev.originon 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.