---
title: "Embedding in your systems"
description: "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 sc…"
url: "https://sotaagents.ai/manual/enterprise-integration/embedding-in-your-systems"
generated_by: "sotaagents-ldp"
docs_index: "https://sotaagents.ai/manual/llms.txt"
locale: "en"
---

# Embedding in your systems

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.

[Fifteen-second film: signing a ticket, mounting the panel, and the assistant running inside a partner portal](/manual/assets/video/enterprise-embed-1080p.mp4)
_Fifteen seconds, no sound. Your backend signs a request and receives a ticket; your frontend mounts the panel with one call; the assistant appears inside the partner's own portal — inline, or as a floating bubble._

### 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](/manual/admin-console/workspaces).

### Before you start

- An **API key and secret** from _Console → your organization → API Keys_. The secret is shown once — see [API keys](/manual/admin-console/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](/manual/admin-console/inviting-people) 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

![Sequence diagram: the enterprise frontend asks the enterprise backend for a ticket, the backend signs a request to the SotaAgents embed API, the ticket returns and is exchanged inside the iframe for a session cookie, and sign-out revokes that session](/manual/assets/developer/embed-flow.svg?v=2)
_The signed handshake between three parties. The API secret never leaves the middle column; the browser holds only a 60-second ticket, then a partitioned session cookie._

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

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

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

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

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

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

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

> [!WARNING]
> The secret stays on your server
>
> 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.

Code

```
POST
/api/embed/auth/tickets
1786800000
3f1c9a7e-5b02-4d6f-9a11-0c8e2d4b7f30
9b2a4c1d8e3f5a70b6c9d2e4f8a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f3a5
```

> [!WARNING]
> Hash the bytes you actually send
>
> Serialize 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.

#### Node.js

```javascript
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');
```

#### Python

```python
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()
```

#### Java

```java
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)));
```

#### C#

```csharp
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();
```

#### Go

```go
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))
```

#### PHP

```php
$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.

HTTP

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

TypeScript

```
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' });
```

> [!WARNING]
> Revoke before you sign the user out
>
> 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.

Code

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

> [!WARNING]
> Serve the logo over HTTPS
>
> 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.

> [!NOTE]
> Full SDK reference
>
> 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.
