Single sign-on
Your user is signed in on your site and presses "Trade". They land in the trader already signed in - no second password, no e-mail link. This guide walks both ways of doing it from the first call to the last, including sign-out and the failure cases.
#Two ways in
| Way 1: one-time login link | Way 2: your signed JWT | |
|---|---|---|
| Who asks | Your backend calls the Partner API with your key | Your platform signs a token; the browser carries it to Octolit |
| Credential | Partner API key with scope sso:issue | Your RS256 private key (or an HS256 shared secret) saved for the brand |
| Endpoint | POST /api/external/v1/users/{external_id}/sso | GET /api/sso/brands/{brand_id}/login (redirect) or POST (JSON) |
| User must exist | Yes - create it first with PUT /users/{external_id} | No, when the brand has "Create the client on first sign-in" on |
| Best for | A server-side "Trade" button, native apps with the Embed SDK | Identity-provider setups, many brands, no API key on the web tier |
Both end the same way: the browser opens https://live.your-brand.com/login?token=…, the one-time token becomes a session and the trader opens. Both are recorded as a sign-in (method sso) and sent to your webhook as client.login.
#Before you start
- The brand switch Single sign-on is on (Octolit CRM, Settings › API & Webhooks). Without it both ways answer
403 sso_disabled. - The brand has a trader domain (and a dashboard domain if you use
target: "dashboard"). Otherwise:409 no_app_domain. - Way 1: a Partner API key with
sso:issue(andusers:writeto create users). - Way 2: issuer, audience and the algorithm with its key are saved for the brand under "Broker login (SSO)". Note the
brand_idshown there. - Your client id (
external_id/sub) is stable and matches^[A-Za-z0-9._@:+-]{1,191}$. Use the id you key your own users by, not an e-mail that can change.
#Way 1: one-time login link
- your siteUser presses "Trade"
- your backend
PUT …/users/cust_10042(first time only), thenPOST …/users/cust_10042/sso - Octolitanswers
login_url+token, valid once, for the brand's login link lifetime (default 60 s) - your backendredirects the browser to
login_url- or handstokento your page / the Embed SDK - traderswaps the token for a session and opens;
client.loginis sent to your webhook
-
Make sure the user exists
Users are keyed by your id. The first
PUTcreates the client in the brand named bybrand_id(201); later calls update it (200). Send any subset of the fields - names, e-mail and phone are added, never removed.PUT /api/external/v1/users/cust_10042curl -X PUT "https://api.your-brand.com/api/external/v1/users/cust_10042" \ -H "X-Api-Key: $OCTOLIT_PARTNER_KEY" \ -H "Content-Type: application/json" \ -d '{ "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" }'const res = await fetch("https://api.your-brand.com/api/external/v1/users/cust_10042", { method: "PUT", headers: { "X-Api-Key": process.env.OCTOLIT_PARTNER_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" }), }); if (!res.ok) throw new Error(`PUT failed: ${res.status} ${await res.text()}`); const data = await res.json(); console.log(data);import os, requests r = requests.request( "PUT", "https://api.your-brand.com/api/external/v1/users/cust_10042", headers={"X-Api-Key": os.environ["OCTOLIT_PARTNER_KEY"]}, json={ "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE", }, timeout=10, ) r.raise_for_status() print(r.json())<?php $ch = curl_init("https://api.your-brand.com/api/external/v1/users/cust_10042"); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => "PUT", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "X-Api-Key: " . getenv("OCTOLIT_PARTNER_KEY"), "Content-Type: " . "application/json", ], CURLOPT_POSTFIELDS => json_encode([ "brand_id" => 13, "email" => "anna@example.com", "first_name" => "Anna", "last_name" => "Lindqvist", "country" => "SE", ]), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); print_r($data);using var http = new HttpClient(); using var req = new HttpRequestMessage(HttpMethod.Put, "https://api.your-brand.com/api/external/v1/users/cust_10042"); req.Headers.Add("X-Api-Key", Environment.GetEnvironmentVariable("OCTOLIT_PARTNER_KEY")); req.Content = new StringContent(""" { "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" } """, 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")) .header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY")) .header("Content-Type", "application/json") .method("PUT", HttpRequest.BodyPublishers.ofString(""" { "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" } """)) .build(); var res = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.statusCode() + " " + res.body());body := strings.NewReader(`{ "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" }`) req, err := http.NewRequest("PUT", "https://api.your-brand.com/api/external/v1/users/cust_10042", 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")!) req.httpMethod = "PUT" req.setValue(ProcessInfo.processInfo.environment["OCTOLIT_PARTNER_KEY"], forHTTPHeaderField: "X-Api-Key") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = """ { "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" } """.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") .header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY")) .method("PUT", """{ "brand_id": 13, "email": "anna@example.com", "first_name": "Anna", "last_name": "Lindqvist", "country": "SE" }""".toRequestBody("application/json".toMediaType())) .build() client.newCall(req).execute().use { res -> println("${res.code} ${res.body?.string()}") }A user never moves between brands (
409 brand_fixed) and an e-mail that belongs to another client of your company is refused (409 email_taken). See Provision users & accounts for the full flow. -
Ask for the link
targetistrader(default) ordashboard(the client dashboard). The answer is201.POST /api/external/v1/users/cust_10042/ssocurl -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()}") }201 Created{ "login_url": "https://live.your-brand.com/login?token=<one-time token>", "token": "<one-time token>", "target": "trader", "expires_at": "2026-11-11T09:31:00Z", "client_id": 10042 } -
Send the user there
Three options, pick one:
- Redirect the browser to
login_url. Simplest; works from any server-rendered page. - Hand the token to your page and let it open
https://live.your-brand.com/login?token=…itself (a new tab, or a link the user presses). - Give the token to the Octolit Embed SDK:
controller.start(loginToken:)on iOS,trading.start(token)on Android, thelogincommand in the iframe bridge. See SDKs.
Do it within the lifetime: ask for the link when the user presses the button, not when the page is rendered.
- Redirect the browser to
-
Handle the edge cases
- The link works once. A second open shows the trader's normal sign-in page. Ask for a new link instead of re-using the old one.
- The client's two-factor login, when switched on for the client, still asks for the code after the link.
403 login_disabled: the client may not sign in (blocked in the CRM). Show your own message; do not retry.404: no client with thisexternal_idin your company - go back to step 1.
#Way 2: your signed JWT
Here your platform is the identity provider. No API key is used on the web tier: the signed token is the credential, and Octolit verifies it with the key or secret saved for the brand.
- your siteUser presses "Trade"
- your backendsigns a JWT (
sub= your client id, 120 s life, uniquejti) - browser
302toGET /api/sso/brands/13/login?token=…&target=trader - Octolitverifies signature, issuer, audience, age and
jti; creates the client if the brand allows it - browser
302tohttps://live.your-brand.com/login?token=…- the trader opens signed in
-
Set the brand up once
In the Octolit CRM under Broker login (SSO) save: the issuer and audience your tokens will carry, the algorithm (RS256 with your public key or a JWKS URL, or HS256 with a shared secret), the token max age (default 300 s), the clock skew (default 60 s), and whether unknown clients are created on first sign-in. Prefer RS256: the private key never leaves your side.
-
Sign the token
Payload{ "iss": "https://your-brand.com", "aud": "octolit", "sub": "cust_10042", "iat": 1762853400, "exp": 1762853520, "jti": "9f1d6a1e-6f1c-4d0e-9a5b-2c5f0a8e4b11", "email": "anna@example.com", "given_name": "Anna", "family_name": "Lindqvist" }Claim Rule iss,audExactly the strings saved for the brand. subYour client id - the same value you would use as external_idin the Partner API.iat,expUnix seconds. expat most the max age afteriat; a token older than the max age is refused. 60 - 120 s is plenty.jtiRequired and unique. A token presented twice is refused ( token_replayed), so never cache a signed token.email,name,given_name,family_name,nbfOptional. The names and e-mail fill in a client created on first sign-in. header kidThe key id, when the brand uses a JWKS URL. Sign and redirectimport jwt from 'jsonwebtoken'; import { randomUUID } from 'node:crypto'; export function tradeRedirect(req, res) { const now = Math.floor(Date.now() / 1000); const token = jwt.sign( { iss: 'https://your-brand.com', aud: 'octolit', sub: req.user.id, // sub = your client id iat: now, exp: now + 120, jti: randomUUID(), // short life, unique id email: req.user.email, given_name: req.user.firstName, family_name: req.user.lastName }, process.env.SSO_PRIVATE_KEY, { algorithm: 'RS256', keyid: 'k1' }); res.redirect('https://api.your-brand.com/api/sso/brands/13/login?target=trader&token=' + encodeURIComponent(token)); }import jwt, os, time, uuid from flask import redirect def trade_redirect(user): now = int(time.time()) token = jwt.encode( {"iss": "https://your-brand.com", "aud": "octolit", "sub": user.id, # sub = your client id "iat": now, "exp": now + 120, "jti": str(uuid.uuid4()), # short life, unique id "email": user.email, "given_name": user.first_name, "family_name": user.last_name}, os.environ["SSO_PRIVATE_KEY"], algorithm="RS256", headers={"kid": "k1"}) return redirect("https://api.your-brand.com/api/sso/brands/13/login?target=trader&token=" + token)use Firebase\JWT\JWT; $now = time(); $token = JWT::encode([ 'iss' => 'https://your-brand.com', 'aud' => 'octolit', 'sub' => $user->id, // sub = your client id 'iat' => $now, 'exp' => $now + 120, 'jti' => bin2hex(random_bytes(16)), // short life, unique id 'email' => $user->email, 'given_name' => $user->firstName, 'family_name' => $user->lastName, ], getenv('SSO_PRIVATE_KEY'), 'RS256', 'k1'); header('Location: https://api.your-brand.com/api/sso/brands/13/login?target=trader&token=' . rawurlencode($token)); exit;using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Microsoft.IdentityModel.Tokens; var now = DateTimeOffset.UtcNow; var key = new RsaSecurityKey(rsa) { KeyId = "k1" }; var jwt = new JwtSecurityToken( issuer: "https://your-brand.com", audience: "octolit", claims: new[] { new Claim("sub", user.Id), // sub = your client id new Claim("iat", now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64), new Claim("jti", Guid.NewGuid().ToString()), // unique id new Claim("email", user.Email), new Claim("given_name", user.FirstName), new Claim("family_name", user.LastName), }, notBefore: now.UtcDateTime, expires: now.AddSeconds(120).UtcDateTime, // short life signingCredentials: new SigningCredentials(key, SecurityAlgorithms.RsaSha256)); var token = new JwtSecurityTokenHandler().WriteToken(jwt); return Redirect("https://api.your-brand.com/api/sso/brands/13/login?target=trader&token=" + Uri.EscapeDataString(token));import io.jsonwebtoken.Jwts; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.UUID; Instant now = Instant.now(); String token = Jwts.builder() .header().keyId("k1").and() .issuer("https://your-brand.com").audience().add("octolit").and() .subject(user.getId()) // sub = your client id .issuedAt(Date.from(now)).expiration(Date.from(now.plusSeconds(120))) // short life .id(UUID.randomUUID().toString()) // unique id .claim("email", user.getEmail()).claim("given_name", user.getFirstName()).claim("family_name", user.getLastName()) .signWith(privateKey, Jwts.SIG.RS256) .compact(); response.sendRedirect("https://api.your-brand.com/api/sso/brands/13/login?target=trader&token=" + URLEncoder.encode(token, StandardCharsets.UTF_8));import ( "net/http" "net/url" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) func tradeRedirect(w http.ResponseWriter, r *http.Request, user User) { now := time.Now() t := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "iss": "https://your-brand.com", "aud": "octolit", "sub": user.ID, // sub = your client id "iat": now.Unix(), "exp": now.Add(120 * time.Second).Unix(), // short life "jti": uuid.NewString(), // unique id "email": user.Email, "given_name": user.FirstName, "family_name": user.LastName, }) t.Header["kid"] = "k1" token, err := t.SignedString(privateKey) if err != nil { http.Error(w, "sign failed", 500) return } http.Redirect(w, r, "https://api.your-brand.com/api/sso/brands/13/login?target=trader&token="+url.QueryEscape(token), http.StatusFound) } -
Send the browser - or call from the server
The redirect form above is the usual one. If you would rather keep the hop server-side (for example to open the trader in a popup your script controls), post the token as JSON and get the same
login_url/tokenanswer as in way 1, pluscreated: truewhen the client was just created.POST /api/sso/brands/13/logincurl -X POST "https://api.your-brand.com/api/sso/brands/13/login" \ -H "Content-Type: application/json" \ -d '{ "token": "<your signed JWT>", "target": "trader" }'const res = await fetch("https://api.your-brand.com/api/sso/brands/13/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ "token": "<your signed JWT>", "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/sso/brands/13/login", headers={}, json={ "token": "<your signed JWT>", "target": "trader", }, timeout=10, ) r.raise_for_status() print(r.json())<?php $ch = curl_init("https://api.your-brand.com/api/sso/brands/13/login"); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Content-Type: " . "application/json", ], CURLOPT_POSTFIELDS => json_encode([ "token" => "<your signed JWT>", "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/sso/brands/13/login"); req.Content = new StringContent(""" { "token": "<your signed JWT>", "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/sso/brands/13/login")) .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "token": "<your signed JWT>", "target": "trader" } """)) .build(); var res = client.send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.statusCode() + " " + res.body());body := strings.NewReader(`{ "token": "<your signed JWT>", "target": "trader" }`) req, err := http.NewRequest("POST", "https://api.your-brand.com/api/sso/brands/13/login", body) if err != nil { log.Fatal(err) } 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/sso/brands/13/login")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = """ { "token": "<your signed JWT>", "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/sso/brands/13/login") .method("POST", """{ "token": "<your signed JWT>", "target": "trader" }""".toRequestBody("application/json".toMediaType())) .build() client.newCall(req).execute().use { res -> println("${res.code} ${res.body?.string()}") }200 OK{ "login_url": "https://live.your-brand.com/login?token=<one-time token>", "token": "<one-time token>", "target": "trader", "expires_at": "2026-11-11T09:31:00Z", "client_id": 10042, "created": false } -
Handle a refusal
With the redirect form the browser lands on
https://live.your-brand.com/login?sso_error=<code>; add&format=jsonto get the error as JSON instead. With the POST form the error comes back as the usual{ "error": { "code", "message" } }. The codes are listed below.
#After sign-in: the session
The one-time token becomes a trader session. You can see every live session of a user and when it ends; agent_session: true marks a session a CRM agent opened as the client.
curl "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions" \
-H "X-Api-Key: $OCTOLIT_PARTNER_KEY"const res = await fetch("https://api.your-brand.com/api/external/v1/users/cust_10042/sessions", {
method: "GET",
headers: {
"X-Api-Key": process.env.OCTOLIT_PARTNER_KEY,
},
});
if (!res.ok) throw new Error(`GET failed: ${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);import os, requests
r = requests.request(
"GET",
"https://api.your-brand.com/api/external/v1/users/cust_10042/sessions",
headers={"X-Api-Key": os.environ["OCTOLIT_PARTNER_KEY"]},
timeout=10,
)
r.raise_for_status()
print(r.json())<?php
$ch = curl_init("https://api.your-brand.com/api/external/v1/users/cust_10042/sessions");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-Api-Key: " . getenv("OCTOLIT_PARTNER_KEY"),
],
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($data);using var http = new HttpClient();
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions");
req.Headers.Add("X-Api-Key", Environment.GetEnvironmentVariable("OCTOLIT_PARTNER_KEY"));
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/sessions"))
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());var body io.Reader
req, err := http.NewRequest("GET", "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions", body)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Api-Key", os.Getenv("OCTOLIT_PARTNER_KEY"))
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/sessions")!)
req.httpMethod = "GET"
req.setValue(ProcessInfo.processInfo.environment["OCTOLIT_PARTNER_KEY"], forHTTPHeaderField: "X-Api-Key")
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/sessions")
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.get()
.build()
client.newCall(req).execute().use { res ->
println("${res.code} ${res.body?.string()}")
} {
"sessions": [
{
"id": 9031,
"started_at": "2026-11-11T09:30:12Z",
"agent_session": false,
"expires_at": "2026-11-12T09:30:12Z"
}
]
}- Lifetime: each session carries its own
expires_at. When it passes, the trader asks for a new sign-in. Inside the Embed SDK this arrives as thesession_expiredevent; your app then asks your backend for a fresh one-time token and callslogin- no page reload. - Several devices: a user may have more than one session (newest first, at most 100 are listed).
- Records: every sign-in is kept in the client's history for 365 days and sent as
client.loginwith the method.
#Signing out
When the user signs out of your site, end their trader sessions too. Otherwise the trader stays open on a shared computer.
curl -X DELETE "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions" \
-H "X-Api-Key: $OCTOLIT_PARTNER_KEY"const res = await fetch("https://api.your-brand.com/api/external/v1/users/cust_10042/sessions", {
method: "DELETE",
headers: {
"X-Api-Key": process.env.OCTOLIT_PARTNER_KEY,
},
});
if (!res.ok) throw new Error(`DELETE failed: ${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);import os, requests
r = requests.request(
"DELETE",
"https://api.your-brand.com/api/external/v1/users/cust_10042/sessions",
headers={"X-Api-Key": os.environ["OCTOLIT_PARTNER_KEY"]},
timeout=10,
)
r.raise_for_status()
print(r.json())<?php
$ch = curl_init("https://api.your-brand.com/api/external/v1/users/cust_10042/sessions");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "DELETE",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-Api-Key: " . getenv("OCTOLIT_PARTNER_KEY"),
],
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($data);using var http = new HttpClient();
using var req = new HttpRequestMessage(HttpMethod.Delete, "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions");
req.Headers.Add("X-Api-Key", Environment.GetEnvironmentVariable("OCTOLIT_PARTNER_KEY"));
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/sessions"))
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.method("DELETE", HttpRequest.BodyPublishers.noBody())
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());var body io.Reader
req, err := http.NewRequest("DELETE", "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions", body)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Api-Key", os.Getenv("OCTOLIT_PARTNER_KEY"))
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/sessions")!)
req.httpMethod = "DELETE"
req.setValue(ProcessInfo.processInfo.environment["OCTOLIT_PARTNER_KEY"], forHTTPHeaderField: "X-Api-Key")
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/sessions")
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.method("DELETE", null)
.build()
client.newCall(req).execute().use { res ->
println("${res.code} ${res.body?.string()}")
} {
"ended": 2
}To end one session only (for example "sign out this device" in your settings page) use DELETE …/sessions/{session_id}:
curl -X DELETE "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031" \
-H "X-Api-Key: $OCTOLIT_PARTNER_KEY"const res = await fetch("https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031", {
method: "DELETE",
headers: {
"X-Api-Key": process.env.OCTOLIT_PARTNER_KEY,
},
});
if (!res.ok) throw new Error(`DELETE failed: ${res.status} ${await res.text()}`);
const data = await res.json();
console.log(data);import os, requests
r = requests.request(
"DELETE",
"https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031",
headers={"X-Api-Key": os.environ["OCTOLIT_PARTNER_KEY"]},
timeout=10,
)
r.raise_for_status()
print(r.json())<?php
$ch = curl_init("https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "DELETE",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-Api-Key: " . getenv("OCTOLIT_PARTNER_KEY"),
],
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
print_r($data);using var http = new HttpClient();
using var req = new HttpRequestMessage(HttpMethod.Delete, "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031");
req.Headers.Add("X-Api-Key", Environment.GetEnvironmentVariable("OCTOLIT_PARTNER_KEY"));
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/sessions/9031"))
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.method("DELETE", HttpRequest.BodyPublishers.noBody())
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());var body io.Reader
req, err := http.NewRequest("DELETE", "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031", body)
if err != nil {
log.Fatal(err)
}
req.Header.Set("X-Api-Key", os.Getenv("OCTOLIT_PARTNER_KEY"))
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/sessions/9031")!)
req.httpMethod = "DELETE"
req.setValue(ProcessInfo.processInfo.environment["OCTOLIT_PARTNER_KEY"], forHTTPHeaderField: "X-Api-Key")
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/sessions/9031")
.header("X-Api-Key", System.getenv("OCTOLIT_PARTNER_KEY"))
.method("DELETE", null)
.build()
client.newCall(req).execute().use { res ->
println("${res.code} ${res.body?.string()}")
} In the Embed SDK, logout() clears the stored session on the device and signs the module out. Pair it with the call above so the server side is ended as well.
#Errors
Way 1 (login link) answers the usual Partner API errors: 403 sso_disabled, 403 login_disabled, 403 insufficient_scope, 404 unknown user, 409 no_brand / 409 no_app_domain. Way 2 (JWT) uses the codes below - as ?sso_error= on the redirect or in the JSON error.
token_invalidNot a JWT, or a bad header / claims.token_signatureThe signature does not verify with the configured key or secret.token_algThe algorithm is not the one configured for the brand (RS256 / HS256).token_issueriss differs from the issuer saved for the brand.token_audienceaud differs from the audience saved for the brand.token_expiredexp is in the past.token_ageIssued more than max_age_seconds ago, or not yet valid (iat / nbf outside the window).token_jtijti missing.token_subjectsub missing.token_keyNo key found for the kid in the token header (JWKS).token_replayedThe jti was already used - every token works once.sso_disabledThe brand has single sign-on switched off.sso_not_configuredNo issuer / audience / key saved for the brand.login_disabledThe user may not sign in.user_other_brandThe user belongs to another brand.company_inactiveThe platform is paused.brand_inactiveThe brand is switched off.brand_not_foundNo such brand id.user_not_foundUnknown sub and "create unknown clients" is off.no_app_domainThe brand has no trader / dashboard domain yet.invalid_targettarget must be trader or dashboard.invalid_subjectsub is not a valid external_id (1-191 letters, digits, . _ @ : + -).create_failedThe client could not be auto-created.Debugging a token? The JWT inspector decodes a token in the browser and checks the claims against the rules above.
#Go-live checklist
- The "Trade" button asks for the link (or signs the token) at click time, not at page render.
- Tokens are signed on the server; keys and secrets are not in any client-side code.
- Way 2:
jtiis random per token and the life is 120 s or less. - Your sign-out also calls
DELETE …/sessions. - Your app handles
login_disabledanduser_not_foundwith a message, not a retry loop. - Your webhook consumes
client.loginif you want sign-in records on your side. - Tested with two-factor login switched on for a test client.
Reference: Single sign-on, sessions, end sessions, create or update a user.