Single sign-on
Your users never see a second login form. Either your platform signs a short JWT and sends the browser to the platform, or your backend asks for a one-time login link.
#Two directions
Inbound - your JWT
Best when your platform is the identity provider. No API call per login; the platform verifies your signature. Unknown users can be created on first sign-in.
Outbound - login link
Best when you already call the Partner API. POST /users/{external_id}/sso returns a one-time URL; redirect the browser to it.
Both need the brand switch Single sign-on. Every sign-in is recorded (method sso or link) and sent as the client.login event.
#Inbound: your JWT
Set up once per brand in the CRM, Settings › API & Webhooks › Broker login (SSO): issuer, audience, algorithm RS256 (public key or JWKS URL) or HS256 (shared secret), token max age (default 300 s), clock skew (60 s), login link lifetime, Create the client on first sign-in and Reduced data.
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
const token = jwt.sign(
{ sub: 'cust_10042', email: 'anna@example.com', given_name: 'Anna', family_name: 'Lindqvist', jti: randomUUID() },
process.env.SSO_PRIVATE_KEY, // RS256 private key (PEM); or the HS256 shared secret
{ algorithm: 'RS256', issuer: 'https://id.your-platform.com', audience: 'octolit-brand-13', expiresIn: '5m', keyid: 'k2026-1' },
);
res.redirect(`https://api.your-brand.com/api/sso/brands/13/login?token=${encodeURIComponent(token)}&target=trader`);import jwt, os, time, uuid
from flask import redirect
from urllib.parse import urlencode
claims = {
"iss": "https://id.your-platform.com", "aud": "octolit-brand-13", "sub": "cust_10042",
"iat": int(time.time()), "exp": int(time.time()) + 300, "jti": str(uuid.uuid4()),
"email": "anna@example.com", "given_name": "Anna", "family_name": "Lindqvist",
}
token = jwt.encode(claims, os.environ["SSO_PRIVATE_KEY"], algorithm="RS256", headers={"kid": "k2026-1"})
return redirect("https://api.your-brand.com/api/sso/brands/13/login?" + urlencode({"token": token, "target": "trader"}))<?php
use Firebase\JWT\JWT;
$claims = [
'iss' => 'https://id.your-platform.com', 'aud' => 'octolit-brand-13', 'sub' => 'cust_10042',
'iat' => time(), 'exp' => time() + 300, 'jti' => bin2hex(random_bytes(16)),
'email' => 'anna@example.com', 'given_name' => 'Anna', 'family_name' => 'Lindqvist',
];
$token = JWT::encode($claims, getenv('SSO_PRIVATE_KEY'), 'RS256', 'k2026-1');
header('Location: https://api.your-brand.com/api/sso/brands/13/login?' . http_build_query(['token' => $token, 'target' => 'trader']));var rsa = RSA.Create();
rsa.ImportFromPem(Environment.GetEnvironmentVariable("SSO_PRIVATE_KEY"));
var creds = new SigningCredentials(new RsaSecurityKey(rsa) { KeyId = "k2026-1" }, SecurityAlgorithms.RsaSha256);
var token = new JwtSecurityToken(
issuer: "https://id.your-platform.com", audience: "octolit-brand-13",
claims: new[] { new Claim("sub", "cust_10042"), new Claim("jti", Guid.NewGuid().ToString()), new Claim("email", "anna@example.com"), new Claim("given_name", "Anna"), new Claim("family_name", "Lindqvist") },
notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddMinutes(5), signingCredentials: creds);
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
return Redirect($"https://api.your-brand.com/api/sso/brands/13/login?token={Uri.EscapeDataString(jwt)}&target=trader");String jwt = Jwts.builder()
.header().keyId("k2026-1").and()
.issuer("https://id.your-platform.com").audience().add("octolit-brand-13").and()
.subject("cust_10042").id(UUID.randomUUID().toString())
.issuedAt(new Date()).expiration(Date.from(Instant.now().plusSeconds(300)))
.claim("email", "anna@example.com").claim("given_name", "Anna").claim("family_name", "Lindqvist")
.signWith(privateKey, Jwts.SIG.RS256)
.compact();
return "redirect:https://api.your-brand.com/api/sso/brands/13/login?target=trader&token=" + URLEncoder.encode(jwt, UTF_8);claims := jwt.MapClaims{
"iss": "https://id.your-platform.com", "aud": "octolit-brand-13", "sub": "cust_10042",
"iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(), "jti": uuid.NewString(),
"email": "anna@example.com", "given_name": "Anna", "family_name": "Lindqvist",
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tok.Header["kid"] = "k2026-1"
signed, err := tok.SignedString(privateKey)
if err != nil { return err }
http.Redirect(w, r, "https://api.your-brand.com/api/sso/brands/13/login?target=trader&token="+url.QueryEscape(signed), http.StatusFound) #Claims
{
"iss": "https://id.your-platform.com",
"aud": "octolit-brand-13",
"sub": "cust_10042",
"iat": 1762853400,
"exp": 1762853700,
"jti": "3f9c2b1e-8d44-4c0a-9a1b-7e2f5c6d8a90",
"email": "anna@example.com",
"given_name": "Anna",
"family_name": "Lindqvist"
}| Claim | |
|---|---|
iss | Required. Exactly the issuer saved in the CRM. |
aud | Required. The audience saved in the CRM (string or array containing it). |
sub | Required. Your client id = external_id (1-191 chars: letters, digits, . _ @ : + -). |
iat, exp | Required. exp - iat at most the brand's token max age (default 300 s); 60 s clock skew allowed. |
jti | Required. Unique per token - a token works exactly once (token_replayed otherwise). |
nbf | Optional. Not valid before. |
email, name, given_name, family_name | Optional. Used when the client is created on first sign-in (ignored in reduced-data mode). |
header kid | Key id when the brand uses a JWKS URL; the platform picks the matching key and caches the set. |
header alg | RS256 or HS256, matching the brand setting. Nothing else is accepted. |
#Login endpoint
GET https://api.your-brand.com/api/sso/brands/{brand_id}/login?token=<jwt>&target=trader|dashboard
POST https://api.your-brand.com/api/sso/brands/{brand_id}/login { "token": "<jwt>", "target": "trader" }target:trader(default) ordashboard.- Success:
302tohttps://<brand app>/login?token=…, which opens a live session. The browser must follow redirects and accept cookies for the brand's domain. - Failure:
302tohttps://<brand app>/login?sso_error=<code>, or JSON{ "error": … }when you add&format=json(useful for tests and native apps). - Unknown
sub: created when Create the client on first sign-in is on, else404 user_not_found.
Full endpoint details: GET · POST. Build and inspect tokens in the JWT builder.
#Outbound: login links
With a Partner key carrying sso:issue, ask for a link and redirect the browser to it. The link is one-time and expires after the brand's login link lifetime (minutes). token in the answer can be passed to the embed SDK instead of a redirect.
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()}")
} {
"login_url": "https://trade.your-brand.com/login?token=otp_...",
"token": "otp_8f3c...",
"target": "trader",
"expires_at": "2026-11-11T09:31:00Z",
"client_id": 1042
}#Sessions
GET /users/{external_id}/sessions lists live sessions (app, IP, device, last seen); DELETE …/sessions ends all of them, DELETE …/sessions/{session_id} one. End sessions when the user logs out of your platform or changes their password.
#Error codes
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.jti per token · RS256 with a JWKS URL so you can rotate keys without a CRM change · clocks in sync (NTP) · test a replay and an expired token before go-live.