> ## Documentation Index
> Fetch the complete documentation index at: https://docs-agents.fpt.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Tích hợp API

> Dùng khi bạn đã có sẵn giao diện chat với khách hàng: nhận tin qua webhook, ký HMAC, nhận câu trả lời bằng callback và đọc lại lịch sử hội thoại

Kênh API dành cho bên đã có sẵn kênh trò chuyện với khách hàng: ứng dụng riêng, Zalo OA tự vận hành, tổng đài, CRM. Hệ thống của bạn chuyển tiếp tin nhắn của khách tới nền tảng qua webhook; câu trả lời của Agent được gửi ngược lại webhook của bạn bằng callback.

<Info icon="key">
  Hai chiều dùng chung một khóa bí mật và chung một cách ký, nên bạn chỉ phải viết hàm xác thực chữ ký một lần và dùng lại cho cả chiều gửi lẫn chiều nhận.
</Info>

## Chọn kênh tích hợp

| Tiêu chí | Live Chat | Kênh API |
| - | - | - |
| Giao diện chat | Do FPT.AI cung cấp | Do bạn tự xây |
| Công sức tích hợp | Dán một thẻ script | Viết webhook nhận và một client gửi |
| Trả lời | Phát theo từng token, thời gian thực (SSE) | Một callback cho mỗi lượt trả lời hoàn chỉnh |
| Xác thực | `visitorToken` cấp tự động cho từng khách | Chữ ký HMAC-SHA256 trên mọi gói tin |
| Danh tính khách hàng | Do nền tảng sinh (khách ẩn danh) | Do bạn cung cấp qua `user.id` |
| Đính kèm gửi lên | Có | Có, nếu quản trị viên đã bật |
| Nút bấm, carousel | Có | Có |

<Note>
  Hai kênh không loại trừ nhau. Cùng một Agent có thể vừa phục vụ widget trên website, vừa phục vụ hệ thống của bạn qua kênh API. Mỗi kênh là một kết nối riêng với khóa riêng.
</Note>

## Môi trường và tên miền

| Môi trường | Webhook kênh API | API đọc lịch sử |
| - | - | - |
| Production | `https://console-agents.fpt.ai` | `https://console-agents.fpt.ai` |
| Staging | `https://console-agents-staging.fpt.ai` | `https://console-agents-staging.fpt.ai` |

<Note>
  Mọi thành phần nằm chung một tên miền ứng dụng, phân biệt bằng đường dẫn: webhook nhận tin nhắn của kênh API là `https://console-agents.fpt.ai/webhooks/api`, còn API đọc lịch sử là `https://console-agents.fpt.ai/direct-bff/…`. Hãy sao chép đúng địa chỉ webhook từ Console thay vì tự suy ra.
</Note>

## Kết nối kênh API và lấy khóa

<Steps>
  <Step title="Đăng nhập FPT AI Agent Platform">
    Mở Console và đăng nhập bằng tài khoản của bạn.
  </Step>

  <Step title="Chọn Agent bạn muốn cấu hình">
    Agent này sẽ trả lời tin nhắn mà hệ thống của bạn chuyển tới.
  </Step>

  <Step title="Mở tab Channels">
    Tab **Channels** (Kênh triển khai) nằm trên thanh điều hướng của Agent.
  </Step>

  <Step title="Chọn ô API">
    Bảng cấu hình kênh API mở ra.
  </Step>

  <Step title="Điền cấu hình của kênh">
    Hai trường, xem bảng ngay dưới.
  </Step>

  <Step title="Bấm Save configuration để tạo kênh">
    Ô API trong tab Channels chuyển sang **Đã cấu hình**.
  </Step>

  <Step title="Bấm Tạo khóa mới">
    Sao chép **Khóa định tuyến** và **Khóa ký** trước khi đóng hộp thoại.
  </Step>
</Steps>

<img src="https://mintcdn.com/fpt-62e894b4/PvROln1h1DujzWBQ/images/api_config.jpg?fit=max&auto=format&n=PvROln1h1DujzWBQ&q=85&s=36f0007f357f2b353f8c043bf6f2cf0a" alt="Bảng cấu hình kênh API" width="1568" height="661" data-path="images/api_config.jpg" />

| Trường | Mô tả | Bắt buộc |
| - | - | - |
| Callback URL | Nơi nền tảng gửi câu trả lời của Agent về cho bạn. Bắt buộc dùng `https`, tối đa 2048 ký tự, và phải là địa chỉ công khai trên Internet. Để trống được nếu bạn chưa dựng xong endpoint; khi đó kênh vẫn nhận tin nhắn nhưng chưa có câu trả lời nào được gửi về | Tùy chọn |
| Cửa sổ nghỉ (giờ) | Hội thoại im lặng quá số giờ này thì tin nhắn kế tiếp của khách mở một hội thoại mới. Từ 1 đến 720 giờ, mặc định 24 | Tùy chọn |

<Warning>
  Khóa ký chỉ hiển thị đúng một lần, ngay tại thời điểm tạo. Hệ thống chỉ lưu bản băm nên không có cách nào xem lại. Hãy sao chép và cất vào kho bí mật của bạn trước khi đóng hộp thoại.
</Warning>

### Các giá trị bạn nhận được

| Giá trị | Ví dụ | Ý nghĩa |
| - | - | - |
| Khóa định tuyến | `api_7Kd2xQ9mPz4vR8nLcJt3Aw` | Định danh kết nối của bạn. Gửi nó trong trường `integrationKey` của phần thân, không đặt trên URL |
| URL webhook | `https://console-agents.fpt.ai/webhooks/api` | Nơi hệ thống của bạn gửi tin nhắn của khách hàng tới. Dùng chung cho mọi tổ chức |
| Khóa ký | (hiển thị một lần) | Khóa bí mật dùng để ký và xác thực chữ ký ở cả hai chiều |
| Callback URL | `https://your-domain.com/agent-replies` | Webhook của bạn, do bạn điền ở bước 5 |

### Kiểm tra Callback URL

Nút **Kiểm tra callback** bên cạnh ô nhập gửi một yêu cầu đã ký tới địa chỉ của bạn và yêu cầu bạn trả lời đúng. Xem cách hiện thực ở mục Kiểm chứng điểm cuối webhook. Nút này giới hạn 10 lần kiểm tra mỗi phút cho mỗi tổ chức.

### Xoay vòng khóa bí mật

<Steps>
  <Step title="Bấm Tạo khóa mới">
    Kể từ lúc này, cả khóa cũ lẫn khóa mới đều được chấp nhận.
  </Step>

  <Step title="Cập nhật khóa mới lên toàn bộ máy chủ của bạn">
    Kiểm tra lại luồng gửi và luồng nhận.
  </Step>

  <Step title="Bấm Kết thúc vòng đổi khóa">
    Khóa cũ bị vô hiệu hóa. Từ thời điểm này chỉ khóa mới còn giá trị.
  </Step>
</Steps>

<Warning>
  Đừng bỏ qua bước cuối. Khóa cũ vẫn dùng được cho tới khi bạn kết thúc vòng đổi khóa, nên nếu quên, bạn đã không thực sự đổi khóa.
</Warning>

## Xác thực nội dung sự kiện

Mọi gói tin ở cả hai chiều đều được ký bằng HMAC-SHA256 với khóa bí mật của kênh. Chữ ký đi trong header `X-Hub-Signature-256` với tiền tố `sha256=`, kèm header `X-Hub-Timestamp` mang thời điểm gửi.

Chuỗi được ký là `timestamp` + dấu chấm + phần thân nguyên bản:

```text theme={null}
mac = HMAC-SHA256(secret, timestamp + "." + rawRequestBody)
```

* Ký trên đúng chuỗi byte đã gửi đi. Chuyển JSON thành đối tượng rồi tuần tự hóa lại, kể cả khi chỉ đổi thứ tự khóa, sẽ cho ra chữ ký khác.
* Timestamp nằm trong chữ ký, nên kẻ bắt được gói tin không thể sửa nó. Gửi thời gian Unix hiện tại tính bằng giây.
* Timestamp lệch quá 5 phút so với giờ máy chủ, theo cả hai hướng, đều bị từ chối. Hãy đồng bộ đồng hồ máy chủ bằng NTP.
* Cơ chế không có ngoại lệ. Không có chế độ bỏ qua chữ ký, và không có miễn trừ cho kết nối chưa tạo khóa.

### Ví dụ tạo chữ ký với Go

```go theme={null}
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strconv"
	"time"
)

func sign(secret string, body []byte, timestamp string) string {
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(timestamp))
	h.Write([]byte("."))
	h.Write(body)
	return "sha256=" + hex.EncodeToString(h.Sum(nil))
}

func main() {
	// Phan than nguyen ban - chinh chuoi byte se duoc gui di.
	body := []byte(`{"integrationKey":"api_7Kd2xQ9mPz4vR8nLcJt3Aw",` +
		`"messageId":"msg-1001","user":{"id":"cust-42"},` +
		`"text":"Xin chao"}`)
	secretKey := "my_secret_key"
	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
	fmt.Println("X-Hub-Timestamp:", timestamp)
	fmt.Println("X-Hub-Signature-256:", sign(secretKey, body, timestamp))
}
```

### Ví dụ tạo chữ ký với Node.js

```js theme={null}
const crypto = require('crypto');

function sign(secret, body, timestamp) {
  return 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + body)
    .digest('hex');
}

// Phan than nguyen ban - chinh chuoi se duoc gui di.
const body = JSON.stringify({
  integrationKey: 'api_7Kd2xQ9mPz4vR8nLcJt3Aw',
  messageId: 'msg-1001',
  user: { id: 'cust-42' },
  text: 'Xin chao'
});

const secretKey = 'my_secret_key';
const timestamp = Math.floor(Date.now() / 1000).toString();
console.log('X-Hub-Timestamp:', timestamp);
console.log('X-Hub-Signature-256:', sign(secretKey, body, timestamp));
```

### Xác thực chữ ký nền tảng gửi tới

Dùng lại đúng hàm trên, rồi so sánh bằng hàm so sánh thời gian hằng số để không rò rỉ thông tin qua thời gian xử lý:

```js theme={null}
const express = require('express');
const crypto = require('crypto');
const app = express();

// Bat buoc: giu lai phan than NGUYEN BAN de tinh chu ky.
app.use(express.raw({ type: 'application/json' }));

app.post('/agent-replies', (req, res) => {
  const ts  = req.get('X-Hub-Timestamp') || '';
  const got = req.get('X-Hub-Signature-256') || '';
  const raw = req.body;                       // Buffer, chua qua JSON.parse

  // 1. Kiem tra do lech thoi gian truoc (toi da 5 phut).
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
  if (!Number.isFinite(skew) || skew > 300) return res.sendStatus(401);

  // 2. Doi chieu chu ky.
  const want = sign(process.env.CHANNEL_SECRET, raw, ts);
  const a = Buffer.from(got), b = Buffer.from(want);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(raw.toString('utf8'));

  // 3. Tra 200 NGAY, roi xu ly bat dong bo.
  res.sendStatus(200);
  handleAsync(event);
});
```

<Warning>
  Hầu hết lỗi chữ ký đến từ việc framework web đã tự chuyển phần thân thành đối tượng trước khi bạn kịp đọc. Hãy cấu hình để giữ lại chuỗi byte nguyên bản (`express.raw`, `bodyParser.raw`, hoặc đọc trực tiếp từ luồng đầu vào) rồi mới phân tích JSON sau khi đã xác thực xong.
</Warning>

## Luồng nhận tin nhắn từ khách hàng

Tin nhắn khách hàng gửi tới hệ thống của bạn được chuyển tiếp tới nền tảng qua webhook. Sự kiện gồm hai phần: nội dung sự kiện và chữ ký xác thực nội dung sự kiện.

```mermaid theme={null}
sequenceDiagram
  participant C as Core App của bạn
  participant S as FPT AI Agents Webhook Server
  C->>S: POST /webhooks/api - sự kiện người dùng gửi tin nhắn
  Note over C,S: Headers X-Hub-Timestamp và X-Hub-Signature-256
  S->>S: Xác thực HMAC-SHA256
  S->>S: Chống phát lại theo messageId
  S->>S: Xử lý sự kiện, chuyển tới AI Agents
  S-->>C: 200 OK
```

| HTTP Method | URI |
| - | - |
| POST | `/webhooks/api` |

| HTTP Header | Giá trị |
| - | - |
| `Content-Type` | `application/json` |
| `X-Hub-Timestamp` | Thời gian Unix tính bằng giây |
| `X-Hub-Signature-256` | `sha256=…` |

### Tin nhắn văn bản

```json theme={null}
{
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "messageId": "msg-1001",
  "user": {
    "id": "cust-42",
    "name": "Nguyen Van A",
    "avatar": "https://example.com/avatar.png",
    "locale": "vi"
  },
  "text": "Đơn hàng của tôi đang ở đâu?",
  "metadata": { "orderId": "SO-7781" }
}
```

### Tin nhắn khi khách hàng bấm nút hoặc quick reply

```json theme={null}
{
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "messageId": "msg-1002",
  "user": { "id": "cust-42" },
  "text": "Theo dõi đơn",
  "postback": "track:SO-7781"
}
```

### Tin nhắn có tệp đính kèm

```json theme={null}
{
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "messageId": "msg-1003",
  "user": { "id": "cust-42" },
  "text": "Đây là ảnh sản phẩm bị lỗi",
  "attachments": [
    {
      "url": "https://example.com/files/loi-san-pham.jpg",
      "name": "loi-san-pham.jpg",
      "mimeType": "image/jpeg"
    }
  ]
}
```

### Bảng tham số

| Tham số | Giá trị mẫu | Mô tả | Bắt buộc |
| - | - | - | - |
| `integrationKey` | `api_7Kd2xQ9…` | Khóa định tuyến của kênh, lấy trên Console. Tối đa 256 ký tự | Bắt buộc |
| `messageId` | `msg-1001` | ID tin nhắn do bạn sinh ra. Là khóa chống trùng đầu-cuối. Tối đa 256 ký tự | Bắt buộc |
| `user` | | Đối tượng chứa thông tin người gửi | Bắt buộc |
| `user.id` | `cust-42` | ID của khách hàng trong hệ thống của bạn. Tối đa 256 ký tự | Bắt buộc |
| `user.name` | `Nguyen Van A` | Tên khách hàng. Tối đa 256 ký tự | Tùy chọn |
| `user.avatar` | `https://…/a.png` | Ảnh đại diện khách hàng. Tối đa 2048 ký tự | Tùy chọn |
| `user.locale` | `vi` | Ngôn ngữ của khách hàng. Tối đa 256 ký tự | Tùy chọn |
| `text` | `Đơn hàng của tôi…` | Nội dung tin nhắn. Tối đa 16384 ký tự. Luôn bắt buộc, kể cả khi có `postback` | Bắt buộc |
| `postback` | `track:SO-7781` | Dữ liệu ẩn của nút hoặc quick reply khách vừa bấm. Tối đa 1024 ký tự | Tùy chọn |
| `metadata` | `{"orderId":"SO-7781"}` | Dữ liệu bổ sung của riêng bạn. Tối đa 32 khóa và 8192 byte sau khi mã hóa JSON | Tùy chọn |
| `attachments` | | Danh sách tệp đính kèm. Tối đa 5 mục | Tùy chọn |
| `attachments[].url` | `https://…/a.jpg` | Đường dẫn tệp; nền tảng tải về. Tối đa 2048 ký tự | Bắt buộc |
| `attachments[].name` | `anh.jpg` | Tên tệp. Tối đa 256 ký tự | Tùy chọn |
| `attachments[].mimeType` | `image/jpeg` | Kiểu nội dung của tệp | Tùy chọn |

<Warning>
  `text` luôn bắt buộc, kể cả với tin nhắn chỉ có `postback`. Lượt nói của khách hàng được lưu lại và hiển thị cho nhân viên khi họ tiếp quản hội thoại, nên một `postback` không kèm chữ sẽ để lại một bong bóng trống trong hộp thư của họ. Hãy gửi nhãn của nút làm `text`.
</Warning>

<Note>
  Trường `postback` tới Agent như ngữ cảnh riêng, không bao giờ bị coi là lời khách hàng nói. Nhờ đó Agent phân biệt được điều gì do người gõ và điều gì do giao diện của bạn mang theo. Một trường duy nhất dùng cho cả nút bấm lẫn quick reply.
</Note>

### Ví dụ gọi bằng cURL

```bash theme={null}
curl --location 'https://console-agents.fpt.ai/webhooks/api' \
  --header 'Content-Type: application/json' \
  --header 'X-Hub-Timestamp: 1755763200' \
  --header 'X-Hub-Signature-256: sha256=c22b7f7c1957fb301d6181c5fbf4dc841d735af48ddc14e9a4dade1658fdc3e5' \
  --data '{
    "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
    "messageId": "msg-1001",
    "user": { "id": "cust-42", "name": "Nguyen Duc Minh" },
    "metadata": { "customer_id": "2449956338403583" },
    "text": "hello"
  }'
```

### Mã phản hồi

| Status | Ý nghĩa | Bạn cần làm gì |
| - | - | - |
| 200 | Đã tiếp nhận | Không cần làm gì. Agent trả lời bất đồng bộ qua callback |
| 400 | Phần thân không hợp lệ: JSON sai, thiếu trường bắt buộc, hoặc một trường vượt giới hạn | Sửa lại yêu cầu. Gửi lại cũng không giúp gì |
| 401 | Chữ ký hoặc timestamp không hợp lệ | Kiểm tra khóa bí mật và đồng hồ máy chủ. Phần thân phản hồi để trống có chủ đích |
| 405 | Sai phương thức | Chỉ chấp nhận POST |
| 503 | Phía nền tảng chưa tiếp nhận được | Gửi lại với thời gian chờ tăng dần. Đây không phải tin nhắn bị từ chối |

<Note>
  200 nghĩa là **đã tiếp nhận**, không phải đã trả lời. Agent xử lý bất đồng bộ và câu trả lời tới webhook của bạn sau đó.
</Note>

### Chống trùng lặp

`messageId` là khóa chống trùng đầu-cuối. Một tin nhắn cùng cặp (`integrationKey`, `messageId`) gửi lại trong vòng 5 phút sẽ được nhận ra và bỏ qua, nên việc gửi lại sau khi hết thời gian chờ không làm khách hàng của bạn nhận hai câu trả lời.

<Warning>
  Hãy dùng mã tin nhắn ổn định của chính bạn. Đừng sinh mã mới cho mỗi lần thử lại, làm vậy là tự vô hiệu hóa cơ chế chống trùng.
</Warning>

### Tệp đính kèm gửi lên

Mỗi mục trong `attachments` cần một `url` mà nền tảng tải được. Nội dung tệp được sao chép vào kho lưu trữ của tổ chức bạn rồi đưa cho Agent, nên đường dẫn bạn cung cấp không cần sống lâu sau đó.

<Note>
  Tính năng tải tệp đính kèm tắt cho tới khi quản trị viên bật nó. Khi tắt, và với mọi tệp vượt giới hạn hoặc tải thất bại, tệp bị bỏ qua nhưng `text` vẫn tới được Agent. Vì vậy tin nhắn có đính kèm không bao giờ là lỗi, và bạn không thể biết từ mã phản hồi là tệp đã được nhận hay chưa.
</Note>

### Dữ liệu bổ sung

Đối tượng `metadata` của bạn tới Agent ở dạng lồng bên dưới `client_metadata`. Nó không bao giờ được trộn vào tầng trên cùng, vì các khóa ở đó, nhất là định danh người nhận, là thứ quyết định câu trả lời được gửi cho ai.

Các trường lạ ở tầng trên cùng của phần thân được bỏ qua, nên bạn có thể thêm trường riêng mà không sợ bị từ chối.

## Luồng callback tin nhắn từ Agent

Câu trả lời của Agent được gửi tới webhook của bạn bằng một lệnh POST đã ký. Sự kiện gồm hai phần giống chiều gửi lên: nội dung sự kiện và chữ ký xác thực nội dung sự kiện.

```mermaid theme={null}
sequenceDiagram
  participant M as Message Dispatching
  participant W as API Webhook của bạn
  M->>W: POST callback_url - câu trả lời của AI Agent
  Note over M,W: Header X-Hub-Signature-256
  W->>W: Xác thực HMAC-SHA256
  W->>W: Chống trùng theo eventId
  W->>W: Gửi trả lời tới người dùng
  W-->>M: 200 OK
  Note over M,W: Không phải 2xx thì thử lại tối đa 3 lần (1s, 3s, 9s) rồi chuyển vào DLQ
```

### Kiểm chứng điểm cuối webhook

Trước khi tin nhắn thật đầu tiên tới, bạn có thể yêu cầu nền tảng chứng minh điểm cuối của bạn đúng là điểm cuối nó tưởng. Nút **Kiểm tra callback** trên Console gửi một yêu cầu đã ký:

```mermaid theme={null}
sequenceDiagram
  participant P as FPT AI Agents Platform
  participant W as Webhook Server của bạn
  P->>W: POST callback_url - type verification, challenge 6326e43c...
  W->>W: Xác thực chữ ký X-Hub-Signature-256
  W-->>P: 200 OK kèm challenge 6326e43c...
  P->>P: Đối chiếu challenge
  Note over P,W: Khớp thì kết quả là ok, không khớp thì challenge_mismatch
```

```json theme={null}
{
  "type": "verification",
  "challenge": "6326e43c9f0b4a1d8e77c0b511a2d3f4",
  "occurredAt": "2026-09-07T11:08:40Z"
}
```

Để vượt qua, hãy trả về mã 2xx kèm một phần thân JSON lặp lại đúng giá trị `challenge`:

```js theme={null}
app.post('/agent-replies', (req, res) => {
  // ... xac thuc chu ky nhu o muc tren ...
  const event = JSON.parse(raw.toString('utf8'));

  if (event.type === 'verification') {
    return res.json({ challenge: event.challenge });
  }

  res.sendStatus(200);
  handleAsync(event);
});
```

* Yêu cầu kiểm chứng được ký bằng đúng cách ký như mọi gói tin khác, nên hàm xác thực bạn đã viết dùng được ngay.
* Trường `type` chỉ xuất hiện trên yêu cầu kiểm chứng, không bao giờ có trên tin nhắn thật.
* Bắt buộc phải lặp lại `challenge`. Trả 200 suông là chưa đủ: một tên miền bỏ trống, một trang báo lỗi của CDN hay một bộ cân bằng tải đều trả về 200.
* Mỗi lần kiểm chứng dùng một `challenge` khác nhau.
* Nền tảng không đi theo chuyển hướng. Mã 301, 302, 303 làm mất phần thân yêu cầu nên không bao giờ lặp lại đúng được.
* Không có gì được lưu lại. Kết quả đạt nghĩa là điểm cuối của bạn trả lời đúng tại thời điểm đó.

| Lý do thất bại | Ý nghĩa và cách xử lý |
| - | - |
| `no_secret` | Chưa có khóa bí mật nên nền tảng không ký được. Hãy tạo khóa trước |
| `url_forbidden` | Không phải URL `https` tuyệt đối, hoặc phân giải ra địa chỉ không công khai |
| `unreachable` | Không kết nối được. Kiểm tra dịch vụ đã chạy và mở ra Internet chưa |
| `http_status` | Điểm cuối trả về mã không thuộc 2xx; mã cụ thể nằm ở trường `httpStatus` |
| `challenge_mismatch` | Trả về 2xx nhưng không lặp lại đúng `challenge` |

### Cấu trúc sự kiện callback

```http theme={null}
POST <callback_url>
Content-Type: application/json
User-Agent: fpt-agent-platform-channel-gateway/1
X-Hub-Timestamp: 1755763260
X-Hub-Signature-256: sha256=…
```

| Trường | Kiểu | Mô tả |
| - | - | - |
| `eventId` | string | ID sự kiện, ổn định qua các lần gửi lại. Dùng làm khóa chống trùng |
| `integrationKey` | string | Khóa định tuyến của kênh |
| `conversationId` | string | ID cuộc hội thoại. Là giá trị bạn truyền vào API đọc lại lịch sử. Hãy lưu lại giá trị này |
| `runId` | string | ID của một lượt trả lời. Mỗi lượt một giá trị |
| `from` | `bot` hoặc `operator` | Lượt nói này do Agent trả lời hay do nhân viên tiếp quản |
| `user.id` | string | ID khách hàng, đúng giá trị bạn đã gửi lên |
| `text` | string | Nội dung văn bản. Luôn có mặt, kể cả khi có nút bấm hay carousel |
| `buttons` | Array\<Button> | Tùy chọn: danh sách nút bấm |
| `quick_replies` | Array\<QuickReply> | Tùy chọn: danh sách quick reply |
| `carousels` | Array\<Carousel> | Tùy chọn: danh sách thẻ carousel |
| `references` | Array\<Reference> | Tùy chọn: các nguồn mà câu trả lời đã trích dẫn |
| `attachments` | Array\<Attachment> | Tùy chọn. Xem lưu ý ở cuối mục |
| `occurredAt` | string | Thời điểm phát sinh, định dạng RFC 3339 UTC |

<Note>
  **Về cách đặt tên**: Các trường ở tầng ngoài dùng kiểu lowerCamelCase (`eventId`, `integrationKey`, `conversationId`, `occurredAt`), còn các khối nội dung và trường bên trong chúng dùng kiểu snake\_case (`quick_replies`, `sub_title`, `image_url`, `file_name`). Đây là chủ ý, không phải nhầm lẫn: các khối nội dung dùng chung một bộ từ vựng với widget chat và với lịch sử hội thoại.
</Note>

#### Button

| Trường | Kiểu | Mô tả |
| - | - | - |
| `type` | `postback`, `url`, `phone_call`, `webview` | Loại nút |
| `title` | string | Tiêu đề nút |
| `data` | string | Tùy chọn: dữ liệu ẩn của nút, khi `type` là `postback`. Gửi lại giá trị này trong trường `postback` khi khách bấm nút |
| `url` | string | Tùy chọn: đường dẫn, khi `type` là `url` hoặc `webview` |
| `phone` | string | Tùy chọn: số điện thoại, khi `type` là `phone_call` |

#### QuickReply

| Trường | Kiểu | Mô tả |
| - | - | - |
| `type` | `text` hoặc `phone_call` | Loại quick reply |
| `title` | string | Tiêu đề quick reply |
| `data` | string | Tùy chọn: dữ liệu ẩn, khi `type` là `text` |
| `phone` | string | Tùy chọn: số điện thoại, khi `type` là `phone_call` |

#### Carousel

| Trường | Kiểu | Mô tả |
| - | - | - |
| `title` | string | Tiêu đề của carousel |
| `sub_title` | string | Tùy chọn: tiêu đề con |
| `image_url` | string | Tùy chọn: đường dẫn ảnh |
| `buttons` | Array\<Button> | Tùy chọn: danh sách nút của carousel |

<Note>
  Ảnh kèm nút bấm được gửi dưới dạng carousel có 1 item.
</Note>

#### Reference

| Trường | Kiểu | Mô tả |
| - | - | - |
| `id` | string | Định danh của nguồn trong lượt trả lời này |
| `type` | `knowledge` hoặc `web_search` | Nguồn từ kho tri thức hay từ tìm kiếm web. Giá trị lạ vẫn hiển thị được như một thẻ thường |
| `title` | string | Tùy chọn: tiêu đề của nguồn |
| `url` | string | Tùy chọn: đường dẫn, với nguồn từ web |
| `file_name` | string | Tùy chọn: tên tệp, với nguồn từ kho tri thức |
| `uri` | string | Tùy chọn: định danh tài liệu trong kho tri thức |
| `page` | number | Tùy chọn: số trang trong tài liệu |
| `metadata` | object | Tùy chọn: dữ liệu riêng của từng loại nguồn |

#### Attachment

| Trường | Kiểu | Mô tả |
| - | - | - |
| `name` | string | Tên tệp |
| `mime` | string | Kiểu nội dung của tệp |
| `size` | number | Kích thước tệp, tính bằng byte |
| `objectKey` | string | Khóa đối tượng trong kho lưu trữ. Không phải đường dẫn tải. Đổi nó lấy một đường dẫn ngắn hạn qua API presign |

<Note>
  Hiện chưa có tính năng nào của nền tảng đặt tệp lên lượt trả lời của kênh API, nên trường `attachments` chưa bao giờ xuất hiện trên callback. Trường được giữ chỗ sẵn để tương thích về sau. Tệp đính kèm gửi lên thì hoạt động bình thường.
</Note>

### Ví dụ nội dung sự kiện

**Tin nhắn văn bản không có nút**

```json theme={null}
{
  "eventId": "out:run-8891:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8891",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Đơn hàng của bạn sẽ được giao vào ngày mai.",
  "occurredAt": "2026-09-14T09:14:20Z"
}
```

**Tin nhắn có nút**

```json theme={null}
{
  "eventId": "out:run-8892:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8892",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Bạn muốn làm gì tiếp theo?",
  "buttons": [
    { "type": "postback",   "title": "Theo dõi đơn", "data": "track:SO-7781" },
    { "type": "phone_call", "title": "Gọi điện",     "phone": "+84982123456" },
    { "type": "url",        "title": "Website",      "url": "https://example.com" }
  ],
  "occurredAt": "2026-09-14T09:15:02Z"
}
```

**Quick reply**

```json theme={null}
{
  "eventId": "out:run-8893:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8893",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Bạn cần hỗ trợ gì ạ?",
  "quick_replies": [
    { "type": "text",       "title": "Đổi trả",  "data": "intent:return" },
    { "type": "phone_call", "title": "Gọi điện", "phone": "+84982123456" }
  ],
  "occurredAt": "2026-09-14T09:16:30Z"
}
```

**Carousel**

```json theme={null}
{
  "eventId": "out:run-8894:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8894",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Đây là các sản phẩm phù hợp với bạn.",
  "carousels": [
    {
      "title": "Nón bảo hiểm A",
      "sub_title": "Đạt chuẩn QCVN 2:2008",
      "image_url": "https://example.com/img/non-a.jpg",
      "buttons": [
        { "type": "postback", "title": "Đặt mua", "data": "buy:NON-A" },
        { "type": "url", "title": "Chi tiết", "url": "https://example.com/non-a" }
      ]
    },
    {
      "title": "Nón bảo hiểm B",
      "image_url": "https://example.com/img/non-b.jpg",
      "buttons": [
        { "type": "postback", "title": "Đặt mua", "data": "buy:NON-B" }
      ]
    }
  ],
  "occurredAt": "2026-09-14T09:18:11Z"
}
```

**Câu trả lời có trích dẫn nguồn**

```json theme={null}
{
  "eventId": "out:run-8895:0",
  "integrationKey": "api_7Kd2xQ9mPz4vR8nLcJt3Aw",
  "conversationId": "7412",
  "runId": "run-8895",
  "from": "bot",
  "user": { "id": "cust-42" },
  "text": "Chính sách đổi trả áp dụng trong 30 ngày kể từ ngày nhận hàng.",
  "references": [
    { "id": "r1", "type": "knowledge", "title": "Chính sách đổi trả",
      "file_name": "chinh-sach-2026.pdf", "page": 4 },
    { "id": "r2", "type": "web_search", "title": "Quy định bảo hành",
      "url": "https://example.com/bao-hanh" }
  ],
  "occurredAt": "2026-09-14T09:20:45Z"
}
```

### Những điều cần lưu ý khi xử lý callback

* `text` luôn có mặt, kể cả khi có `buttons` hay `carousels`. Đó là phương án hiển thị dự phòng, nên một hệ thống chỉ hiển thị văn bản vẫn có một cuộc hội thoại đúng nghĩa.
* Danh sách rỗng thì vắng mặt, không phải `[]`. `buttons`, `quick_replies`, `carousels` và `references` bị lược bỏ hoàn toàn khi lượt trả lời không có.
* `conversationId` và `from` có thể vắng mặt trên callback đến từ phiên bản cũ của nền tảng. Hãy hiểu sự vắng mặt là chưa xác định.
* Chống trùng theo `eventId`, không phải `runId`.
* Hãy nối dữ liệu theo `conversationId`, đừng nối theo `runId`, vì `runId` chỉ là một lượt trả lời.
* Trả 200 ngay rồi xử lý bất đồng bộ. Mỗi lần gọi chỉ có 10 giây; xử lý chậm sẽ bị tính là thất bại và kích hoạt gửi lại.

### Khi điểm cuối của bạn lỗi

| Phản hồi của bạn | Hành vi của nền tảng |
| - | - |
| 2xx | Coi như đã gửi thành công |
| 5xx, 429, 408, hết thời gian chờ, lỗi kết nối | Gửi lại tối đa 3 lần với thời gian chờ tăng dần (khoảng 1s, 3s, 9s). Header `Retry-After` bạn gửi được tôn trọng, tối đa 30 giây |
| Các mã 4xx khác, hoặc chuyển hướng | Không gửi lại. Coi như bị từ chối vĩnh viễn |
| Vẫn lỗi sau 3 lần | Chuyển vào hàng đợi lỗi ở phía nền tảng, kèm một dòng log mức ERROR |

<Warning>
  Khi điểm cuối của bạn gián đoạn kéo dài, cơ chế giao nhận là **nhiều nhất một lần**. Đây là đánh đổi có chủ ý: gửi lại vô hạn sẽ làm nghẽn bộ phận giao nhận dùng chung. Đường phục hồi là API đọc lại lịch sử ở mục dưới; hàng đợi lỗi nằm ở phía nền tảng và không tự động phát lại cho bạn.
</Warning>

### Yêu cầu với Callback URL

* Giao thức `https` (http chỉ dùng được trong môi trường phát triển nội bộ).
* Không nhúng thông tin đăng nhập trong URL. Bạn xác thực nền tảng bằng chữ ký, không bằng bí mật giấu trên đường dẫn.
* Phải phân giải ra địa chỉ công khai trên Internet. Địa chỉ loopback, mạng nội bộ, link-local, unique-local, multicast và dải NAT của nhà mạng đều bị từ chối.
* Phép kiểm tra chạy ngay lúc kết nối, nên một tên miền phân giải ra địa chỉ nội bộ cũng bị từ chối.
* Tối đa 2048 ký tự.

## Đọc lại lịch sử hội thoại

Đây là đường phục hồi cho lượt trả lời bạn không nhận được. Vì callback là cơ chế nhiều nhất một lần, sau 3 lần thất bại lượt trả lời nằm lại trong hàng đợi lỗi; nếu không có API này thì tin nhắn đó mất hẳn với bạn.

```http theme={null}
GET https://console-agents.fpt.ai/direct-bff/v1/conversations/7412/messages?after=9001&limit=30
Authorization: Bearer <khóa API>
```

| Tham số | Mô tả | Bắt buộc |
| - | - | - |
| `conversationId` | Lấy từ trường cùng tên trên callback. Là số nguyên dương | Bắt buộc |
| `after` | Con trỏ phân trang: mã tin nhắn cuối cùng bạn đã có. Bỏ trống để đọc từ đầu hội thoại | Tùy chọn |
| `limit` | Số tin nhắn mỗi trang. Mặc định 30, tối đa 100. Giá trị ngoài khoảng bị kẹp lại chứ không bị từ chối | Tùy chọn |

Phản hồi 200:

```json theme={null}
{
  "conversationId": "7412",
  "messages": [
    { "id": "9001", "role": "user", "from": "customer",
      "text": "Đơn của tôi đâu?", "createdAt": "2026-09-14T10:30:00Z",
      "attachments": [
        { "name": "hoa-don.pdf", "type": "application/pdf", "size": 1024 }
      ] },
    { "id": "9002", "role": "assistant", "from": "bot",
      "text": "Để tôi kiểm tra giúp bạn.", "createdAt": "2026-09-14T10:30:04Z",
      "buttons": [
        { "type": "postback", "title": "Theo dõi đơn", "data": "track:SO-7781" }
      ] },
    { "id": "9003", "role": "assistant", "from": "operator",
      "text": "Đơn của anh đã xử lý xong ạ.", "createdAt": "2026-09-14T10:41:00Z" }
  ],
  "limit": 30,
  "hasMore": false
}
```

* Dùng khóa API của tổ chức, không dùng khóa bí mật của kênh. Khóa bí mật xác thực rằng máy chủ của bạn đang chuyển tiếp một tin nhắn; còn đây là bạn đọc dữ liệu của chính mình. `visitorToken` đọc được đúng phiên của chính nó; đọc phiên khác trả về 403 `SESSION_FORBIDDEN`.
* Phân trang bằng con trỏ, tin cũ nhất trước. Truyền `after` bằng mã tin nhắn cuối cùng bạn đang giữ; `hasMore` cho biết còn trang tiếp theo hay không.
* Mã tin nhắn là chuỗi, vì chúng vượt ngưỡng số nguyên an toàn của JavaScript, nhưng vẫn sắp xếp theo thứ tự số.
* Hãy đọc `from`, đừng đọc `role`. Lượt trả lời của nhân viên được lưu với cùng `role` như của Agent. Ở đây `from` có ba giá trị: `customer`, `bot`, `operator`.
* Giá trị `operator` hiện chưa phát sinh: nền tảng chưa có tính năng cho nhân viên trả lời thay Agent trên kênh này. Trường được giữ chỗ sẵn để tương thích về sau.
* Mỗi dòng trả về là một lượt nói thật sự. Một dòng chỉ xuất hiện khi nó có `text`, nút bấm, quick reply, carousel, thẻ trích dẫn hoặc tệp đính kèm, nên bạn không cần tự lọc bong bóng trống.
* `after` sai định dạng trả về 400, không bị âm thầm bỏ qua.
* Một mã 404 cho ba trường hợp: hội thoại không tồn tại, hội thoại của tổ chức khác, hoặc hội thoại nội bộ của nhân viên. Cố ý không phân biệt.
* Tệp đính kèm chỉ nêu tên, kiểu và kích thước, không kèm khóa đối tượng.
* Đọc lịch sử không tiêu tốn hạn mức lượt chạy. Nó có hạn mức riêng, mặc định 120 lần mỗi phút cho mỗi khóa API.
* Khi vượt hạn mức đọc lịch sử, phản hồi 429 chỉ kèm header `Retry-After`.

<Warning>
  Đây là đường phục hồi, không phải kho lưu trữ. Khi một khách hàng được xóa theo chính sách lưu giữ dữ liệu, toàn bộ tin nhắn của họ bị xóa hẳn. Hãy đồng bộ những gì bạn cần giữ về hệ thống của mình.
</Warning>

## Khóa API và hạn mức sử dụng

### Tạo khóa API

Khóa API của tổ chức dùng cho API đọc lại lịch sử và API lấy đường dẫn tải tệp. Tạo tại trang **API keys** trên Console.

* Khóa có dạng `sk-` kèm 32 ký tự ngẫu nhiên, ví dụ `sk-9Kd2xQ…`.
* Khóa chỉ hiện đúng một lần, ngay khi tạo. Hệ thống chỉ lưu bản băm nên không có cách nào xem lại.
* Danh sách khóa hiển thị 12 ký tự đầu để bạn nhận ra khóa nào là khóa nào.
* Khóa hành động với quyền của người đã tạo ra nó, nên nó không cấp thêm quyền gì mới.

### Thu hồi và xoay vòng khóa

Thu hồi là một thao tác đánh dấu, không phải xóa: mã khóa vẫn tra cứu được sau khi ngừng hoạt động, để dấu vết kiểm toán còn đọc được. Danh sách hiển thị cả khóa đã thu hồi.

<Warning>
  Thu hồi có độ trễ tới 30 giây, vì khóa được lưu đệm. Kết quả trả về trường `revocationDelaySeconds` nói rõ con số này. Nếu một khóa bị lộ, hãy thu hồi ngay và tính tới khoảng trễ đó.
</Warning>

Xoay vòng khóa API là hai bước, theo đúng thứ tự: tạo khóa mới trước, thu hồi khóa cũ sau. Trong khoảng giữa, cả hai khóa đều dùng được.

### Xem hạn mức đã dùng

```http theme={null}
GET https://console-agents.fpt.ai/direct-bff/v1/usage
Authorization: Bearer <khóa API>
```

```json theme={null}
{
  "day": "2026-09-14",
  "runs": { "used": 42, "limit": 2000, "remaining": 1958,
            "resetsAt": "2026-09-15T00:00:00Z" },
  "rate": { "limit": 20, "window": "1m" }
}
```

* `runs` là ngân sách theo ngày UTC của cả tổ chức, dùng chung cho mọi hội thoại và mọi kênh. Nó về lại mức đầy vào `resetsAt`, tức 00:00 UTC.
* `rate.limit` là hạn mức theo phút cho mỗi bên gọi.
* Xem hạn mức không tốn hạn mức, nên bạn có thể gọi thường xuyên.
* Chỉ khóa API gọi được. `visitorToken` bị từ chối với 403 `USAGE_FORBIDDEN`.

Các API có tính vào hạn mức còn gắn thêm ba header trên cả phản hồi thành công lẫn phản hồi 429:

```text theme={null}
X-RateLimit-Limit:     2000
X-RateLimit-Remaining: 1957
X-RateLimit-Reset:     1757894400
```

## Bước tiếp theo

<CardGroup cols={3}>
  <Card title="Kênh Live Chat" icon="comments" href="/kenh-live-chat">
    Dùng khung chat dựng sẵn của nền tảng thay vì tự xây giao diện.
  </Card>

  <Card title="SDK di động" icon="mobile-screen" href="/sdk-di-dong">
    Đưa khung chat vào ứng dụng Android và iOS.
  </Card>

  <Card title="Phụ lục kỹ thuật" icon="table-list" href="/phu-luc-ky-thuat">
    Bảng mã lỗi, bảng giới hạn hệ thống và danh mục kiểm tra trước Production.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.