---
title: "Nhúng vào hệ thống nội bộ"
description: "SotaAgents có thể chạy ngay bên trong trang web của công ty bạn, dưới dạng bong bóng nổi hoặc panel gắn liền trong trang. Người dùng vẫn đăng nhập bằng hệ thống của bạn và không…"
url: "https://sotaagents.ai/vi/manual/enterprise-integration/embedding-in-your-systems"
generated_by: "sotaagents-ldp"
docs_index: "https://sotaagents.ai/manual/llms.txt"
locale: "vi"
---

# Nhúng vào hệ thống nội bộ

SotaAgents có thể chạy ngay bên trong trang web của công ty bạn, dưới dạng bong bóng nổi hoặc panel gắn liền trong trang. Người dùng vẫn đăng nhập bằng hệ thống _của bạn_ và không bao giờ thấy màn hình đăng nhập SotaAgents: backend của bạn xác nhận họ là ai, và nền tảng tin vào chữ ký đó chứ không tin bất cứ thứ gì người dùng gõ vào.

[Phim mười lăm giây: ký ticket, gắn panel, và trợ lý chạy bên trong cổng thông tin của đối tác](/manual/assets/video/enterprise-embed-1080p.mp4)
_Mười lăm giây, không tiếng. Backend của bạn ký request và nhận ticket; frontend gắn panel bằng một lời gọi; trợ lý xuất hiện ngay trong cổng thông tin của chính đối tác — dạng inline, hoặc bong bóng nổi._

### Hai cách nhúng

| Chế độ | Người truy cập là ai | Dùng cho | Ai thiết lập |
| --- | --- | --- | --- |
| Public Deployment (_Configure Embed_) | Khách ẩn danh, dùng pool Guest Credit riêng | Website công khai, trang marketing, chat trước bán hàng | Chủ sở hữu/quản trị viên tổ chức — không cần code |
| Embed SDK (handshake có chữ ký) | Người dùng đích danh, đã là thành viên tổ chức | Intranet, cổng khách hàng, công cụ nội bộ | Chủ sở hữu/quản trị viên tổ chức và đội phát triển của bạn |

Trang này nói về cách thứ hai. Cách thứ nhất xem tại [Workspace](/vi/manual/admin-console/workspaces).

### Chuẩn bị trước khi bắt đầu

- **API key và secret** tạo tại _Console → Tổ chức của bạn → API Keys_. Secret chỉ hiện một lần — xem [API key](/vi/manual/admin-console/api-keys).
- Mọi người sẽ dùng embed đều phải là **thành viên đang hoạt động** của tổ chức. Hệ thống **không** tự tạo tài khoản: người lạ bị từ chối chứ không được tạo mới. Hãy [mời họ](/vi/manual/admin-console/inviting-people) trước, hoặc cấp tài khoản qua SSO.
- Một backend do bạn kiểm soát. Secret ký request ở phía server và tuyệt đối không được lộ ra trình duyệt.

### Luồng handshake

![Sơ đồ tuần tự: giao diện doanh nghiệp xin ticket từ backend doanh nghiệp, backend ký request gửi tới embed API của SotaAgents, ticket trả về và được iframe đổi lấy cookie phiên, và thao tác đăng xuất thu hồi phiên đó](/manual/assets/developer/embed-flow.svg?v=2)
_Bắt tay có chữ ký giữa ba bên. API secret không bao giờ rời cột giữa; trình duyệt chỉ giữ một ticket sống 60 giây, rồi tới cookie phiên đã phân vùng._

1. #### Trang của bạn xin ticket từ backend của bạn

   SDK không hề nói chuyện với hệ thống định danh của bạn. Nó gọi hàm `getTicket` do bạn cung cấp, hàm này gọi endpoint của bạn kèm cookie phiên của bạn.

2. #### Backend của bạn ký và tạo ticket

   Backend đọc danh tính người dùng từ session, ký request bằng API secret, và nhận về một ticket dùng một lần, hiệu lực 60 giây.

3. #### Iframe đổi ticket lấy phiên

   SDK chuyển ticket cho khung SotaAgents; khung này đổi ticket lấy phiên riêng của nó. Ticket không thể dùng lại.

4. #### SDK tự động xin ticket mới

   Gần hết hạn, hoặc sau bất kỳ lỗi 401 nào, SDK tự lặp lại bước 1. Phiên của bạn là vòng đời duy nhất có ý nghĩa — không có gì phải đồng bộ.

### Backend của bạn cần làm gì

Ba việc, và hai việc đầu là chỗ các tích hợp hay sai nhất.

1. #### Xác định người dùng ở phía server

   Dùng đúng thứ cổng thông tin của bạn đang có — SSO, OIDC, hay bảng session riêng. Gửi kèm `externalUserId` **bất biến theo từng người**: ánh xạ từ id này sang người dùng SotaAgents chỉ tạo một lần và không bao giờ được cập nhật. Hãy coi đó là ràng buộc _của bạn_, không phải của chúng tôi — chúng tôi không ép nó. Một id mới cho người mà hệ thống đã biết **không** bị từ chối: chúng tôi khớp người đó theo email rồi âm thầm tạo thêm một ánh xạ thứ hai, ánh xạ cũ nằm lại ở chỗ mà thu hồi danh tính không chạm tới. Thứ _bị_ từ chối là chiều ngược lại — một id đã tồn tại nhưng đi kèm email khác.

2. #### Mở một endpoint cấp ticket

   Đọc danh tính từ **session**, tuyệt đối không đọc từ body của request — đọc từ body đồng nghĩa mọi nhân viên đã đăng nhập đều có thể tạo ticket mạo danh đồng nghiệp. Ký lời gọi theo cách mô tả bên dưới rồi trả ticket về cho trình duyệt.

3. #### Trả về mã trạng thái mà SDK xử lý được

   SDK coi **4xx là lỗi vĩnh viễn** và dừng; **5xx là lỗi tạm thời** và thử lại có giãn cách. Trả `401` khi phiên của bạn đã hết, `409` khi người dùng chưa là thành viên đang hoạt động, và `502` khi không gọi được SotaAgents.

> [!WARNING]
> Secret chỉ nằm trên server của bạn
>
> Trình duyệt chỉ nhận ticket ngắn hạn, dùng một lần. API secret lọt xuống front-end đồng nghĩa với việc tạo được ticket cho bất kỳ danh tính nào trong tổ chức. Hãy lưu trong secret manager, không đưa vào source control.

### Ký request

Công thức giống nhau ở mọi ngôn ngữ: dựng chuỗi canonical 5 dòng, HMAC-SHA256 bằng API secret, rồi gửi kết quả dưới dạng `v1=<hex chữ thường>`. Năm dòng gồm: method, path, timestamp Unix tính bằng **giây**, nonce, và SHA-256 của body — nối bằng `\n`, không có newline ở cuối.

Code

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

> [!WARNING]
> Hash đúng chuỗi byte mà bạn gửi đi
>
> Serialize body **một lần** và dùng lại đúng chuỗi đó cho cả phần hash lẫn phần gửi. Serialize lại lần hai là nguyên nhân phổ biến nhất khiến chữ ký không khớp ở đâu cả — hai bộ mã hoá JSON, hoặc cùng một bộ gọi hai lần, có thể xếp key hay khoảng trắng khác nhau.

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

### Request thực tế trên đường truyền

Dù ký bằng ngôn ngữ nào, đây là thứ SotaAgents nhận được. Header timestamp và nonce phải mang đúng giá trị đã đưa vào chuỗi canonical; mỗi nonce chỉ được chấp nhận một lần, và request lặp lại bị từ chối trong vòng 5 phút.

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 | Giá trị |
| --- | --- |
| `x-sota-key` | API key của bạn (`sota_ek_…`) — không phải secret |
| `x-sota-timestamp` | Unix giây, đúng giá trị đã ký. Request cũ quá 5 phút bị từ chối |
| `x-sota-nonce` | Duy nhất theo từng request; trùng lặp bị coi là replay |
| `x-sota-signature` | `v1=` nối với HMAC dạng hex chữ thường |

SotaAgents trả `201` kèm bao ngoài chuẩn — `{ success: true, data: { ticket, expiresAt }, timestamp }`. **Hãy bóc lớp bao:** trả object `data` bên trong về cho trình duyệt, vì SDK đọc `ticket` ở cấp cao nhất và coi cả bao ngoài là ticket không hợp lệ — lỗi này biểu hiện thành `200` trên đường truyền và một khung chat quay mãi không lên. Response lỗi thì _không_ được bọc; chúng về phẳng dạng `{ statusCode, code, message }`, nên đọc `code` ở cấp cao nhất. Ticket sống 60 giây và chỉ đổi được một lần.

**Giới hạn tần suất.** Tạo ticket bị giới hạn **60 request mỗi phút cho mỗi API key** và **120 request mỗi phút cho mỗi IP nguồn**; bước đổi ticket bên trong iframe bị giới hạn **10 lần mỗi phút**, tính theo cả IP người dùng cuối lẫn theo ticket. Vượt ngưỡng sẽ nhận `429` kèm `Retry-After` — hãy chuyển nguyên nó cho SDK để SDK tự giãn cách và thử lại, đừng gộp vào một lỗi 4xx chung. Toàn bộ backend của bạn dùng chung một IP nguồn, và mọi người sau cùng một NAT văn phòng dùng chung hạn mức đổi ticket, nên hãy ước lượng đợt đăng nhập cao điểm theo các con số này và báo trước cho chúng tôi nếu không đủ.

### Frontend của bạn cần làm gì

Nạp `<script src="https://app.sotaagents.ai/embed/sdk.js"></script>` rồi khởi tạo. Hai điểm quan trọng: lời gọi xin ticket phải kèm cookie phiên của bạn, và khi đăng xuất phải thu hồi phiên SotaAgents trước.

Origin trong các ví dụ dưới đây chỉ là minh hoạ. Người phụ trách SotaAgents sẽ cấp origin embed riêng cho tenant của bạn, và production khác staging — hãy để origin đó, URL script SDK và base URL của API cấp ticket trong cấu hình chứ không hard-code. SDK từ chối mọi `embedUrl` không phải 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]
> Thu hồi phiên trước khi đăng xuất người dùng
>
> Phiên SotaAgents nằm ở origin của embed, nên việc xoá phiên của bạn không đụng tới nó. Bỏ qua `logout()` thì người đăng nhập kế tiếp trên máy đó sẽ thừa hưởng một phiên còn sống — và rơi thẳng vào cuộc trò chuyện của người trước. Hãy gọi nó trước, rồi mới xoá phiên của bạn. Nó trả về `false` khi không xác nhận được việc thu hồi — vẫn cứ đăng xuất khỏi portal của bạn, nhưng trên máy dùng chung hoặc kiosk thì hãy báo cho người dùng biết phiên SotaAgents có thể vẫn còn sống.

### Tuỳ biến theo thương hiệu của bạn

Mọi tuỳ chọn ở đây đều không bắt buộc và mặc định đều là bản gốc của SotaAgents — không đặt gì thì giao diện y như hiện tại. Đặt vào thì khung chat mang tên bạn thay vì tên chúng tôi.

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

**Hai kiểu gắn.** `mode` chỉ nhận đúng hai giá trị. `'bubble'` là mặc định: SDK tự vẽ nút nổi và panel của nó lên trang bạn, còn `container` bị bỏ qua. `'inline'` đặt thẳng iframe vào phần tử bạn chỉ định — không nút nổi, không panel — và ở đó `container` là bắt buộc, thiếu thì lệnh khởi tạo ném lỗi. Một khác biệt nên tính trước: bubble tạo iframe theo kiểu lười, tới lần bấm đầu tiên mới tạo, nên chưa ai mở chat thì chưa có gì xác thực; inline tạo ngay lúc tải trang, nên endpoint cấp ticket hỏng sẽ lộ ra lập tức và mỗi lượt xem trang tiêu một ticket.

**Bên trong khung chat.** Các giá trị này đi kèm URL của iframe chứ không chờ đăng nhập xong mới tới, nên khung hình đầu tiên người dùng thấy đã là của bạn — không có cảnh loé lên logo SotaAgents rồi mới đổi.

| Tuỳ chọn | Mặc định | Tác dụng |
| --- | --- | --- |
| `language` | Theo trình duyệt | `en`, `vi` hoặc `ja` (`vn` được chấp nhận như `vi`). Đây là điểm khởi đầu chứ không phải khoá cứng: người dùng đổi ngôn ngữ ngay trong khung chat thì giữ lựa chọn đó suốt phiên |
| `branding.logoUrl` | Wordmark SotaAgents | Logo của bạn ở sidebar và cạnh lời chào. Phải là URL `http(s)` tuyệt đối — khác đi thì bị bỏ qua và mặc định quay lại |
| `branding.showLogo` | `true` | `false` không hiển thị logo nào cả, và thắng `logoUrl` nếu đặt cả hai |
| `branding.showUser` | `true` | `false` ẩn khối người dùng đang đăng nhập ở chân sidebar — hợp lý khi trang của bạn đã hiển thị ai đang đăng nhập |
| `branding.greeting` | "How can I help you today?" | Dòng chữ trên màn hình chat mới |
| `branding.placeholder` | "Ask anything" | Placeholder của ô soạn tin |

Greeting và placeholder bị cắt còn 120 ký tự — đủ cho một câu, và đủ ngắn để không đẩy ô soạn tin ra khỏi màn hình.

**Khung bubble.** Nút nổi và panel do SDK vẽ trên trang của bạn, nằm ngoài iframe. Mọi thứ ở đây trừ `title` đều bị bỏ qua ở `mode: 'inline'`, nơi `container` bạn truyền vào quyết định kích thước và trang của bạn quyết định phần xung quanh.

| Tuỳ chọn | Mặc định | Tác dụng |
| --- | --- | --- |
| `title` | `SotaAgents` | Chữ trên header của panel — bỏ trống thì header chỉ còn các nút điều khiển. Nó cũng đặt tên iframe cho công nghệ trợ giúp, và nửa tác dụng này vẫn còn ở `mode: 'inline'`, nên đây là hàng duy nhất trong bảng không chỉ dành cho bubble. Bỏ trống thì trình đọc màn hình đọc khung là _SotaAgents_; hãy đặt lại nếu bạn white-label |
| `primaryColor` | `#2f6bff` | Nút nổi và header panel |
| `position` | `bottom-right` | Hoặc `bottom-left` |
| `width` / `height` | `400` / `620` | Kích thước panel tính bằng px. _Expand_ sẽ vượt qua mức này |
| `zIndex` | `2147483000` | Hạ xuống nếu panel cần nằm dưới một thành phần nào đó của bạn |
| `openOnLoad` | `false` | `true` mở panel ngay khi tải trang thay vì chờ người dùng bấm nút nổi |

Hai tuỳ chọn nữa thuộc về chính iframe. `allowMicrophone` mặc định tắt; nhập bằng giọng nói cần bật. `sandbox` thay cho mặc định `allow-scripts allow-same-origin allow-forms allow-popups` — và ở đây nó ngược với quy ước HTML quen thuộc: **`sandbox: ''` không phải mức hạn chế chặt nhất, mà gỡ bỏ hẳn thuộc tính**, kéo theo mất luôn lớp cách ly của iframe. Muốn siết chặt hơn thì truyền danh sách token hẹp hơn, tuyệt đối không truyền chuỗi rỗng. Đừng động vào cả hai trừ khi có lý do.

> [!WARNING]
> Phục vụ logo qua HTTPS
>
> Khung chat chạy trên HTTPS, nên trình duyệt tự nâng cấp logo `http://` rồi chặn luôn: logo của bạn biến mất, logo SotaAgents quay lại, và không có request lỗi nào trong network tab để giải thích. Đây là cái bẫy branding hay gặp nhất — hãy kiểm tra trên một trang được phục vụ đúng như cách production sẽ phục vụ.

### Cookie bên thứ ba

Khi embed chạy trên một site khác với cổng thông tin của bạn, cookie phiên của nó là cookie bên thứ ba.

| Trình duyệt | Hành vi | Kết quả |
| --- | --- | --- |
| Chrome, Edge | Giữ cookie, phân vùng theo từng site | Phiên đầy đủ |
| Firefox | Mặc định phân vùng cookie bên thứ ba | Phiên đầy đủ |
| Safari | Chặn cookie bên thứ ba | Chuyển sang token 10 phút và tự xác thực lại |

Cơ chế dự phòng vẫn chạy được, nhưng cứ vài phút lại phải xác thực lại. Phục vụ embed từ một subdomain của chính site bạn qua reverse proxy — ví dụ `ai.congty.com` đứng trước SotaAgents — sẽ biến cookie thành first-party và xoá bỏ vấn đề này trên mọi trình duyệt. Nếu làm vậy, nhớ hai điều: đừng để proxy sửa `Set-Cookie`, và phải tắt buffering vì câu trả lời của trợ lý được stream.

### Kiểm tra trước khi go-live

- Secret nằm trong secret manager, không nằm trong source control.
- Mọi người dùng dự kiến đều là thành viên đang hoạt động của tổ chức.
- Endpoint cấp ticket chỉ đọc danh tính từ session.
- `logout()` được gọi trước khi đăng xuất, trên mọi luồng kết thúc phiên.
- Workspace còn đủ credit — hội thoại nhúng vẫn tiêu credit như mọi hội thoại khác.
- Liên hệ bộ phận hỗ trợ để giới hạn những origin được phép nhúng embed về đúng tên miền của bạn.

> [!NOTE]
> Tài liệu SDK đầy đủ
>
> Toàn bộ tuỳ chọn, callback và mã lỗi nằm trong Embed SDK integration guide, kèm một bản tích hợp mẫu chạy được. Liên hệ đầu mối SotaAgents của bạn để nhận tài liệu.
