Guide

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 linkWay 2: your signed JWT
Who asksYour backend calls the Partner API with your keyYour platform signs a token; the browser carries it to Octolit
CredentialPartner API key with scope sso:issueYour RS256 private key (or an HS256 shared secret) saved for the brand
EndpointPOST /api/external/v1/users/{external_id}/ssoGET /api/sso/brands/{brand_id}/login (redirect) or POST (JSON)
User must existYes - create it first with PUT /users/{external_id}No, when the brand has "Create the client on first sign-in" on
Best forA server-side "Trade" button, native apps with the Embed SDKIdentity-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 (and users:write to create users).
  • Way 2: issuer, audience and the algorithm with its key are saved for the brand under "Broker login (SSO)". Note the brand_id shown 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.
  1. your siteUser presses "Trade"
  2. your backendPUT …/users/cust_10042 (first time only), then POST …/users/cust_10042/sso
  3. Octolitanswers login_url + token, valid once, for the brand's login link lifetime (default 60 s)
  4. your backendredirects the browser to login_url - or hands token to your page / the Embed SDK
  5. traderswaps the token for a session and opens; client.login is sent to your webhook
  1. Make sure the user exists

    Users are keyed by your id. The first PUT creates the client in the brand named by brand_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_10042
    curl -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"
    }'

    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.

  2. Ask for the link

    target is trader (default) or dashboard (the client dashboard). The answer is 201.

    POST /api/external/v1/users/cust_10042/sso
    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"
    }'
    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
    }
  3. 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, the login command 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.

  4. 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 this external_id in 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.

  1. your siteUser presses "Trade"
  2. your backendsigns a JWT (sub = your client id, 120 s life, unique jti)
  3. browser302 to GET /api/sso/brands/13/login?token=…&target=trader
  4. Octolitverifies signature, issuer, audience, age and jti; creates the client if the brand allows it
  5. browser302 to https://live.your-brand.com/login?token=… - the trader opens signed in
  1. 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.

  2. 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"
    }
    ClaimRule
    iss, audExactly the strings saved for the brand.
    subYour client id - the same value you would use as external_id in the Partner API.
    iat, expUnix seconds. exp at most the max age after iat; 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 redirect
    import 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));
    }
  3. 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 / token answer as in way 1, plus created: true when the client was just created.

    POST /api/sso/brands/13/login
    curl -X POST "https://api.your-brand.com/api/sso/brands/13/login" \
      -H "Content-Type: application/json" \
      -d '{
      "token": "<your signed JWT>",
      "target": "trader"
    }'
    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
    }
  4. Handle a refusal

    With the redirect form the browser lands on https://live.your-brand.com/login?sso_error=<code>; add &format=json to 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.

Sign on the server, never in the browser
The private key or shared secret must stay on your backend. A token signed in the browser means the secret is in the browser.

#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.

GET /api/external/v1/users/cust_10042/sessions
curl "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY"
200 OK
{
  "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 the session_expired event; your app then asks your backend for a fresh one-time token and calls login - 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.login with 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.

DELETE /api/external/v1/users/cust_10042/sessions
curl -X DELETE "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY"
200 OK
{
  "ended": 2
}

To end one session only (for example "sign out this device" in your settings page) use DELETE …/sessions/{session_id}:

DELETE /api/external/v1/users/cust_10042/sessions/9031
curl -X DELETE "https://api.your-brand.com/api/external/v1/users/cust_10042/sessions/9031" \
  -H "X-Api-Key: $OCTOLIT_PARTNER_KEY"

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.

401token_invalidNot a JWT, or a bad header / claims.
401token_signatureThe signature does not verify with the configured key or secret.
401token_algThe algorithm is not the one configured for the brand (RS256 / HS256).
401token_issueriss differs from the issuer saved for the brand.
401token_audienceaud differs from the audience saved for the brand.
401token_expiredexp is in the past.
401token_ageIssued more than max_age_seconds ago, or not yet valid (iat / nbf outside the window).
401token_jtijti missing.
401token_subjectsub missing.
401token_keyNo key found for the kid in the token header (JWKS).
401token_replayedThe jti was already used - every token works once.
403sso_disabledThe brand has single sign-on switched off.
403sso_not_configuredNo issuer / audience / key saved for the brand.
403login_disabledThe user may not sign in.
403user_other_brandThe user belongs to another brand.
403company_inactiveThe platform is paused.
403brand_inactiveThe brand is switched off.
404brand_not_foundNo such brand id.
404user_not_foundUnknown sub and "create unknown clients" is off.
409no_app_domainThe brand has no trader / dashboard domain yet.
422invalid_targettarget must be trader or dashboard.
422invalid_subjectsub is not a valid external_id (1-191 letters, digits, . _ @ : + -).
422create_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: jti is random per token and the life is 120 s or less.
  • Your sign-out also calls DELETE …/sessions.
  • Your app handles login_disabled and user_not_found with a message, not a retry loop.
  • Your webhook consumes client.login if 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.