Embedding in your systems
On this page
SotaAgents can run inside a page your company already owns, as a floating bubble or an inline panel. Your users stay signed in to your system and never see a SotaAgents login screen: your backend vouches for who they are, and the platform trusts that signature rather than anything the user typed.
Two ways to embed#
| Mode | Who the visitor is | Use it for | Set up by |
|---|---|---|---|
| Public Deployment (Configure Embed) | Anonymous guest, drawing on a separate Guest Credit pool | Public website, marketing page, pre-sales chat | Org owner or admin — no code |
| Embed SDK (signed handshake) | A named person who is already a member of your organization | Intranet, customer portal, internal tools | Org owner or admin, plus your development team |
This page covers the second one. For the first, see Workspaces.
Before you start#
- An API key and secret from Console → your organization → API Keys. The secret is shown once — see API keys.
- Every person who will use the embed must already be an active member of the organization. There is no just-in-time account creation: an unknown user is refused, not created. Invite them first, or provision them through SSO.
- A backend you control. The secret signs requests server-side and must never reach a browser.
How the handshake works#
Your page asks your backend for a ticket
The SDK never talks to your identity system. It calls a getTicket function you supply, which calls your own endpoint with your own session cookie.
Your backend signs and mints
It reads the user identity from its session, signs the request with the API secret, and receives a single-use ticket valid for 60 seconds.
The iframe exchanges the ticket
The SDK hands the ticket to the SotaAgents frame, which exchanges it for its own session. The ticket cannot be reused.
The SDK re-tickets on its own
Near expiry, and after any 401, it repeats step 1 without being asked. Your session is the only lifetime that matters — there is nothing to keep in sync.
What your backend does#
Three duties, and the first two are where integrations go wrong.
Identify the user server-side
Whatever your portal already does — SSO, OIDC, its own session table. Send an externalUserId that is stable for that person forever: the mapping from it to a SotaAgents user is created once and never updated. Treat that as your invariant, not ours — we do not enforce it. A new id for someone we already know is not rejected: we match the person by email and silently add a second mapping, leaving the old one behind where identity revocation will not find it. What is refused is the reverse — an existing id arriving with a different email.
Expose a ticket endpoint
Read the identity from the session, never from the request body — reading it from the body would let any signed-in employee mint a ticket impersonating a colleague. Sign the call as described below and return the ticket to the browser.
Answer with a status the SDK can act on
The SDK treats 4xx as permanent and stops; 5xx as retryable and backs off. Answer 401 when your own session has expired, 409 when the user has no active membership, and 502 when SotaAgents is unreachable.
The browser only ever receives short-lived, single-use tickets. An API secret shipped to the front end can mint a ticket for any identity in your organization. Store it in your secret manager, never in source control.
Signing the request#
The recipe is the same in every language: build a five-line canonical string, HMAC-SHA256 it with your API secret, and send the result as v1=<lowercase hex>. The five lines are the method, the path, a Unix timestamp in seconds, a nonce, and the SHA-256 of the request body — joined by \n, with no trailing newline.
POST
/api/embed/auth/tickets
1786800000
3f1c9a7e-5b02-4d6f-9a11-0c8e2d4b7f30
9b2a4c1d8e3f5a70b6c9d2e4f8a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f3a5Serialize the body once and reuse that exact string for both the hash and the request. Re-serializing the object for the second use is the single most common cause of a signature that verifies nowhere — two JSON encoders, or the same one called twice, can order keys or spacing differently.
import { createHash, createHmac, randomUUID } from 'node:crypto';
const body = JSON.stringify({ externalUserId, email, name }); // serialize ONCE
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomUUID();
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');
const canonical = ['POST', PATH, timestamp, nonce, bodyHash].join('\n');
const signature = 'v1=' + createHmac('sha256', apiSecret).update(canonical).digest('hex');import hashlib, hmac, json, time, uuid
body = json.dumps({"externalUserId": external_user_id, "email": email}) # serialize ONCE
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical = "\n".join(["POST", PATH, timestamp, nonce, body_hash])
signature = "v1=" + hmac.new(
api_secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
).hexdigest()String body = objectMapper.writeValueAsString(payload); // serialize ONCE
String timestamp = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString();
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)));
String canonical = String.join("\n", "POST", PATH, timestamp, nonce, bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = "v1=" + HexFormat.of().formatHex(
mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));var body = JsonSerializer.Serialize(payload); // serialize ONCE
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString();
var bodyHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
var canonical = string.Join("\n", "POST", Path, timestamp, nonce, bodyHash);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret));
var signature = "v1=" + Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();body, err := json.Marshal(payload) // serialize ONCE
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sum := sha256.Sum256(body)
bodyHash := hex.EncodeToString(sum[:])
canonical := strings.Join([]string{"POST", path, timestamp, nonce, bodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(canonical))
signature := "v1=" + hex.EncodeToString(mac.Sum(nil))$body = json_encode($payload); // serialize ONCE
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$bodyHash = hash('sha256', $body);
$canonical = implode("\n", ['POST', $path, $timestamp, $nonce, $bodyHash]);
$signature = 'v1=' . hash_hmac('sha256', $canonical, $apiSecret);The request on the wire#
Whatever language produced the signature, this is what SotaAgents receives. The timestamp and nonce headers must carry the same values that went into the canonical string; a nonce is accepted once and replays are rejected for five minutes.
POST https://app.sotaagents.ai/api/embed/auth/tickets
content-type: application/json
x-sota-key: sota_ek_...
x-sota-timestamp: 1786800000
x-sota-nonce: 3f1c9a7e-5b02-4d6f-9a11-0c8e2d4b7f30
x-sota-signature: v1=4d5e...
{"externalUserId":"e-1042","email":"an.tran@company.com"}| Header | Value |
|---|---|
x-sota-key | Your API key (sota_ek_…) — not the secret |
x-sota-timestamp | Unix seconds, the same value you signed. Requests more than five minutes old are rejected |
x-sota-nonce | Unique per request; a repeat is treated as a replay |
x-sota-signature | v1= followed by the lowercase hex HMAC |
SotaAgents answers 201 with the core envelope — { success: true, data: { ticket, expiresAt }, timestamp }. Unwrap it: return the inner data object to the browser, because the SDK reads ticket from the top level and treats an envelope as an invalid ticket — a failure that looks like 200 on the wire and a panel that never fills in. Error responses are not wrapped; they arrive flat as { statusCode, code, message }, so read code from the top level. The ticket is valid for 60 seconds and can be exchanged once.
Rate limits. Minting is capped at 60 requests per minute per API key and 120 per minute per source IP; the ticket exchange inside the iframe is capped at 10 per minute, counted against the end user's IP as well as the ticket. Over the limit you get 429 with Retry-After — pass it through to the SDK, which backs off and retries, rather than folding it into a generic 4xx. Your whole backend shares one source IP and everyone behind a single office NAT shares the exchange budget, so size your peak sign-in burst against these numbers and talk to us before go-live if it does not fit.
What your frontend does#
Load <script src="https://app.sotaagents.ai/embed/sdk.js"></script>, then mount it. Two things matter: the ticket relay must send your session cookie, and sign-out must revoke the SotaAgents session first.
The origin in these samples is an example. Your SotaAgents contact gives you the embed origin for your tenant, and production and staging differ — keep it, the SDK script URL and your ticket API base in configuration rather than in code. The SDK rejects any embedUrl that is not HTTPS.
const embed = window.SotaEmbedSDK.createSotaEmbed({
embedUrl: 'https://app.sotaagents.ai/embed',
mode: 'bubble',
getTicket: async () => {
const response = await fetch('/api/sota/embed-ticket', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
// Status rides along so the SDK can fast-fail a 401 or 409 instead of
// retrying a permanent failure four times.
throw Object.assign(new Error('ticket_failed'), { status: response.status });
}
return response.json(); // { ticket, expiresAt }
},
});
// On sign-out: revoke the SotaAgents session BEFORE clearing your own.
const revoked = await embed.logout();
if (!revoked) console.warn('SotaAgents session may still be active');
await fetch('/api/logout', { method: 'POST', credentials: 'include' });The SotaAgents session lives on the embed origin, so clearing your own session does not touch it. Skip logout() and the next person to sign in on that browser inherits a live session — landing in the conversations of whoever used it before. Call it first, then clear your own. It resolves false when the revoke could not be confirmed — sign the user out of your own portal anyway, but on a shared or kiosk machine tell them the SotaAgents session may still be live.
Making it look like yours#
Everything here is optional and every default is the stock SotaAgents one, so an integration that sets none of it looks exactly as it does today. Set them and the chat carries your name instead of ours.
createSotaEmbed({
embedUrl: 'https://app.sotaagents.ai/embed',
getTicket,
// Inside the iframe
language: 'vi', // 'en' | 'vi' | 'ja'
branding: {
logoUrl: 'https://intranet.acme.com/logo.svg',
showUser: false,
greeting: 'How can the Acme assistant help?',
placeholder: 'Ask about policies, invoices, anything',
},
// The bubble chrome, drawn on your own page
mode: 'bubble',
title: 'Acme Assistant',
primaryColor: '#0b5cff',
position: 'bottom-left',
width: 420,
height: 640,
openOnLoad: false,
});Two mount shapes. mode takes exactly two values. 'bubble' is the default: the SDK draws its own floating launcher and panel on your page, and container is ignored. 'inline' puts the iframe straight into the element you name — no launcher, no panel — and container is required there, the call throwing without it. One difference worth planning around: bubble builds the iframe lazily, on the first click, so nothing authenticates until someone opens the chat; inline builds it on load, so a broken ticket endpoint shows up immediately and every page view spends a ticket.
Inside the chat. These ride on the iframe URL rather than arriving after sign-in, so the first frame the user sees is already yours — nothing flashes the SotaAgents logo and then swaps it.
| Option | Default | What it does |
|---|---|---|
language | Follows the browser | en, vi or ja (vn is accepted for vi). A starting point, not a lock: a user who switches language inside the chat keeps their choice for that session |
branding.logoUrl | SotaAgents wordmark | Your logo in the sidebar and beside the greeting. Must be an absolute http(s) URL — anything else is ignored and the default comes back |
branding.showLogo | true | false renders no logo at all, and wins over logoUrl if you set both |
branding.showUser | true | false hides the signed-in user block at the foot of the sidebar — the right choice when your own page already shows who is signed in |
branding.greeting | "How can I help you today?" | The line on the new-chat screen |
branding.placeholder | "Ask anything" | The composer's placeholder |
Greeting and placeholder are trimmed to 120 characters — long enough for a sentence, short enough that it cannot push the composer off screen.
The bubble chrome. The launcher and panel the SDK draws on your page, outside the iframe. Everything here but title is ignored in mode: 'inline', where the container you pass decides the size and your page decides the surroundings.
| Option | Default | What it does |
|---|---|---|
title | SotaAgents | Text in the panel header — left unset, the header shows only its controls. It also names the iframe for assistive technology, and that half applies in mode: 'inline' too, which makes it the one row here that is not bubble-only. Leave it unset and a screen reader announces the frame as SotaAgents; set it if you white-label |
primaryColor | #2f6bff | Launcher and panel header |
position | bottom-right | Or bottom-left |
width / height | 400 / 620 | Panel size in px. Expand grows beyond it |
zIndex | 2147483000 | Lower it if the panel has to sit under something of your own |
openOnLoad | false | true opens the panel on load instead of waiting for a launcher click |
Two more govern the iframe itself. allowMicrophone is off by default; voice input needs it. sandbox replaces the default allow-scripts allow-same-origin allow-forms allow-popups — and here it inverts the HTML convention you are used to: sandbox: '' is not the strictest policy, it removes the attribute altogether and takes the iframe's isolation with it. To tighten rather than remove, pass a narrower list of tokens; never an empty string. Leave both alone unless you have a reason.
The chat runs over HTTPS, so the browser silently upgrades an http:// logo and then blocks it: your mark disappears, the SotaAgents one returns, and no failed request appears in the network tab to explain why. It is the most common branding surprise — check it on a page served the way production will serve it.
Third-party cookies#
When the embed runs on a different site from your portal, its session cookie is a third-party cookie.
| Browser | Behaviour | Result |
|---|---|---|
| Chrome, Edge | Cookie kept, partitioned per site | Full session |
| Firefox | Third-party cookies partitioned by default | Full session |
| Safari | Third-party cookies blocked | Falls back to a 10-minute token and re-authenticates automatically |
The fallback works, but it re-authenticates every few minutes. Serving the embed from a subdomain of your own site through a reverse proxy — ai.yourcompany.com in front of SotaAgents — makes the cookie first-party and removes the question for every browser. Two rules if you do: do not let the proxy rewrite Set-Cookie, and turn response buffering off, because chat answers stream.
Before you go live#
- The secret is in your secret manager, not in source control.
- Every intended user is an active member of the organization.
- The ticket endpoint reads identity from the session only.
logout()runs before your own sign-out, on every path that ends a session.- The workspace has enough credit allocation — embedded conversations draw on it like any other.
- Ask support to restrict which origins may frame the embed to your own domains.
Every option, callback and error code is in the Embed SDK integration guide, along with a runnable reference integration. Ask your SotaAgents contact for it.