# Giao thức realtime của chat (v1)

Mọi client chat (widget web và các SDK Flutter, React Native, iOS, Android) nói cùng một giao thức WebSocket với gateway `chat.hatang.dev`. Đọc trang này nếu bạn muốn tự viết client hoặc muốn hiểu SDK làm gì bên dưới. Các bảng khung ở phần sau được sinh từ JSON Schema đi kèm gói `@hatang/chat-protocol`, nên luôn khớp với gateway.

Bản máy đọc được: [`chat.hatang.dev/protocol.json`](https://chat.hatang.dev/protocol.json) (phiên bản, giới hạn, mã đóng, mã lỗi) và các tệp JSON Schema [`visitor-frames.json`](https://hatang.dev/vi/docs/schema/chat-protocol-v1/visitor-frames.json), [`server-frames.json`](https://hatang.dev/vi/docs/schema/chat-protocol-v1/server-frames.json), [`http.json`](https://hatang.dev/vi/docs/schema/chat-protocol-v1/http.json), [`protocol.json`](https://hatang.dev/vi/docs/schema/chat-protocol-v1/protocol.json). Những gì một SDK native phải làm trên nền giao thức này (vòng đời phiên, lịch sử, đính kèm, thông báo đẩy) được kiểm bằng bộ kịch bản tuân thủ C01–C12 mà mọi SDK phải chạy qua trước khi phát hành.

## Phong bì {#envelope}

1. Gọi `POST /widget/v1/sites/{siteId}/bootstrap`. Lệnh này tạo hoặc làm mới token khách và trả về `ws_url`. Trình duyệt cần Origin được phép cho site; ứng dụng native thì gửi khoá ứng dụng trong header `x-hatang-app-key`, và token trả về mang id của ứng dụng.
2. Mở `ws_url`: `wss://chat.hatang.dev/ws?site=<site_id>`, thêm `&app=<app_id>&install=<install_id>` với ứng dụng. Dùng đúng như được trả về; socket của ứng dụng không cần Origin.
3. Gửi `auth` làm khung đầu tiên, trong vòng 5 giây.

Mỗi khung là một object JSON:

```json
{ "t": "message.send", "id": "req_1", "v": 1, "d": { "client_id": "c_9f2", "content": "Shop còn hàng không ạ?" } }
```

| Khoá | Ý nghĩa |
|---|---|
| `t` | Loại khung |
| `d` | Dữ liệu |
| `v` | Phiên bản giao thức (`1`) |
| `id` | Id yêu cầu, tuỳ chọn; được trả lại trong `message.ack` hoặc `error.for` để bạn ghép câu trả lời với yêu cầu |

Cả hai phía gửi `ping` mỗi 25 giây và trả lời bằng `pong`. Với các endpoint HTTP, gửi token khách trong header `x-visitor-token`. Trong khung `identify`, trường user id tên là `user_id`; mọi SDK native gửi đúng tên này, còn API `$hatang` của widget web nhận cả `userId` và chuyển thành `user_id`.

## Thứ tự và resume {#ordering-and-resume}

Mọi tin nhắn và sự kiện trong một hội thoại mang `seq` tăng dần từng đơn vị. Lưu `seq` lớn nhất đã thấy. Sau khi kết nối lại (hoặc ngay sau `ready` nếu đã có `seq`), gửi `resume { conversation_id, after_seq }`; server phát lại mọi thứ sau đó từ cơ sở dữ liệu, nên khung bị lỡ lúc socket đứt không bao giờ mất hẳn. Tải lịch sử cũ hơn qua HTTP với `before_seq`, và bắt kịp với `after_seq`.

Gửi một tin:

1. Sinh `client_id` cho tin và hiện bong bóng ở trạng thái đang gửi.
2. Gửi `message.send` và chờ `message.ack` tối đa 10 giây.
3. Hết hạn hoặc gặp lỗi thì giữ bong bóng ở trạng thái lỗi và thử lại với **cùng** `client_id`. Server trả lời `client_id` trùng bằng tin gốc thay vì tạo tin thứ hai.
4. Khung `message` của chính tin bạn gửi cũng đến; dedupe theo `id`.

## Mã đóng {#close-codes}

| Mã | Ý nghĩa | Client làm gì |
|---|---|---|
| 4001 | Không có `auth` trong 5 giây | Kết nối lại, gửi `auth` trước |
| 4003 | Xác thực bị từ chối hoặc phiên bị thu hồi (ví dụ khoá ứng dụng bị thu hồi) | Dừng; bỏ token, bootstrap một khách mới |
| 4004 | Giao thức của client cũ hơn mức gateway hỗ trợ; khung `reload` đến trước | Trình duyệt tải lại trang; client native dừng kết nối lại và báo người dùng cập nhật ứng dụng |
| 4005 | Khung `session` vừa mang token khách mới (gộp danh tính) | Kết nối lại ngay với token mới |
| khác | Lỗi mạng, gateway khởi động lại | Kết nối lại với backoff `min(30 s, 500 ms · 2^n)` cộng jitter tối đa 500 ms |

## Mã lỗi {#error-codes}

Lỗi HTTP có dạng `{ statusCode, name, message }`; lỗi socket là khung `error { code, message, for? }`.

| Mã | HTTP | Ý nghĩa |
|---|---|---|
| `invalid_app_key` | 401 | Header `x-hatang-app-key` thiếu, sai hoặc thuộc site khác |
| `app_key_revoked` | 403 | Khoá đã bị thu hồi trong dashboard; ứng dụng cần bản cập nhật với khoá mới |
| `invalid_visitor_token` | 401 | Token khách hết hạn, bị reset hoặc bị thu hồi; bootstrap lại |
| `unauthenticated` | 401 | Thiếu token |
| `origin_not_allowed` | 403 | Origin của trình duyệt không được phép cho site này |
| `site_disabled` | 403 | Site đã bị tắt |
| `product_disabled` | 403 | Doanh nghiệp đang tắt module Hỗ trợ khách hàng ([Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules)). Widget ẩn nút chat; SDK báo lỗi khi khởi tạo chat còn phần còn lại của ứng dụng vẫn chạy. Bật lại module là kết nối lại được |
| `validation_error` | 422 | Dữ liệu sai dạng hoặc vượt giới hạn |
| `rate_limit_exceeded` | 429 | Xem giới hạn tần suất bên dưới; thử lại sau |
| `conversation_closed` | 409 | Gửi vào hội thoại đã đóng |
| `file_too_large`, `unsupported_file_type` | 413, 422 | Tải tệp: tối đa 10 MB; ảnh (kể cả HEIC), PDF, tệp văn phòng, zip, mp4 và m4a |
| `not_found` | 404 | Hội thoại, tin hoặc tệp không thuộc khách này |
| `bad_frame` | — | Khung không phải JSON hợp lệ hoặc thiếu `t` |

Giới hạn tần suất:

| Loại | Giới hạn |
|---|---|
| Bootstrap | 30 lần / phút cho mỗi lượt cài ứng dụng, hoặc mỗi địa chỉ IP với trình duyệt |
| Socket mở cùng lúc | 3 cho mỗi lượt cài ứng dụng |
| Tin của khách | 5 tin / 10 giây và 60 tin / phút cho mỗi khách |
| Tải tệp | 10 lần / phút cho mỗi khách |
| Đăng ký thiết bị | 10 lần / phút cho mỗi khách |

## Vẽ thẻ {#card-rendering}

Tin có `metadata.kind` là `product`, `order`, `payment`, `quick_replies` hoặc `csat` mang dữ liệu có cấu trúc để client vẽ thành thẻ: sản phẩm, đơn hàng, nút thanh toán, lựa chọn nhanh hoặc lời mời đánh giá. `content` của tin luôn là chữ thuần, nên kênh không có thẻ vẫn đọc được. Bấm một lựa chọn nhanh là gửi nhãn đó như tin của khách. `csat` mời khách đánh giá (sao trên widget, lựa chọn nhanh ở nơi khác), và client trả lời bằng khung `rate`. Kind lạ hiện như tin thường. Dạng của từng thẻ ở mục [Thẻ trong `metadata` của tin nhắn](#cards).

Khi tự viết client:

- **Chữ phía trên thẻ.** Với thẻ `product`, `order` và `payment`, `content` là bản tóm tắt đầy đủ dành cho email, thông báo đẩy và các kênh khác; đừng hiện nó trong bong bóng cạnh thẻ. Chỉ hiện câu dẫn `metadata.intro` nếu có, không có thì bỏ bong bóng. `quick_replies`, `csat` và kind lạ vẫn hiện `content`.
- **Mã VietQR.** `qr_image_url` là ảnh SVG (`/pay/{id}/qr.svg`). Trình duyệt vẽ được, nhưng nhiều thư viện ảnh di động thì không. Hãy tự vẽ mã từ `qr_payload` (chuỗi EMVCo); thẻ cũ không có trường này thì đọc `qr.payload` từ `GET /pay/{id}` công khai.
- **Đã thanh toán.** Tin chứa thẻ thanh toán không được cập nhật khi khách trả tiền. Coi thẻ là đã thanh toán khi `status` là `paid` hoặc `succeeded`, khi dòng thời gian có tin `metadata.kind = "order_paid"` cùng `order_id`, hoặc khi có sự kiện `order_paid` với `data.payment_id` hay `data.order_id` khớp. Khi đó ẩn mã QR, nội dung chuyển khoản và nút thanh toán.
- **Ứng dụng bán hàng số.** Trên iOS và Android, hàng số (khoá học, gói hội viên…) phải bán qua in-app purchase. Các SDK di động có hook `HatangCommerce` để chuyển nút mua sang mã in-app purchase của bạn; hàng vật lý vẫn dùng VietQR.

## Endpoint HTTP {#http-endpoints}

Mọi đường dẫn dưới đây nằm sau `/widget/v1/sites/{siteId}/`. OpenAPI đầy đủ ở [Tham chiếu gateway chat](https://hatang.dev/vi/docs/reference/chat-gateway.md).

| Endpoint | Tác dụng |
|---|---|
| `POST bootstrap` | Tạo hoặc làm mới token khách. Trả về `visitor`, `conversation`, `timeline`, `availability`, `ws_url` và cấu hình site. SDK native gửi `app { install_id, version, sdk, os, os_version, device_model, locale }` và `screen` |
| `GET conversations?status=all\|open\|resolved&limit&after` | Các hội thoại của khách, kể cả đã giải quyết, cho hộp thư trong ứng dụng |
| `POST conversations` | Trả về hội thoại đang mở của khách, hoặc mở hội thoại mới |
| `GET conversations/{id}/messages?before_seq&limit` hoặc `?after_seq` | Một trang lịch sử cũ hơn, hoặc phần bắt kịp |
| `POST conversations/{id}/messages` | Gửi tin qua HTTP khi không có socket |
| `POST uploads` | Multipart `file`; trả về `{ id, name, size, type }`. Sau đó gửi `message.send { content: "", attachments: [id] }` |
| `PUT devices`, `DELETE devices` | Đăng ký hoặc gỡ token đẩy ([Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md)) |
| `POST leads`, `POST offline-message` | Lead từ một form; tin nhắn để lại ngoài giờ làm việc |
| `POST reset` | Quên khách; token cũ ngừng hoạt động và một token mới được trả về |
| `POST sessions/restore` | Đổi token trong link email "chúng tôi đã trả lời" (`/c/<token>`) lấy phiên của khách đó |

URL tệp đính kèm trong `attachments[].url` là link có chữ ký tới `GET /files/{id}?exp&sig`, dùng được trong 24 giờ. Khi link hết hạn, tải lại tin để có link mới.

## Giới hạn và thời gian

Phiên bản giao thức: **1**.

| Giới hạn | Giá trị |
|---|---|
| Kích thước một khung | 16384 B |
| Tin nhắn của khách | 4000 ký tự |
| Tin nhắn của nhân viên | 10000 ký tự |
| Tệp đính kèm mỗi tin | 5 |
| Kích thước một tệp | 10 MB |
| Dữ liệu tuỳ chỉnh (`identify.data`, `setData`) | 8 KB |
| Hạn gửi `auth` sau khi mở socket | 5 giây |
| Chu kỳ `ping` (cả hai phía) | 25 giây |
| Chờ `message.ack` / trả lời một yêu cầu | 10 giây |
| Kết nối lại | `min(30 s, 500 ms · 2^n)` + jitter ≤ 500 ms; ngay lập tức sau mã 4005 |

Mã đóng socket: `4001` auth_timeout · `4003` auth_failed · `4004` protocol_outdated · `4005` session_replaced.

Mã lỗi: `validation_error`, `rate_limit_exceeded`, `not_found`, `unauthenticated`, `invalid_visitor_token`, `origin_not_allowed`, `site_disabled`, `invalid_app_key`, `app_key_revoked`, `conversation_closed`, `file_too_large`, `unsupported_file_type`, `bad_frame`, `error`.

## Khung visitor → server

### `auth`

Khung đầu tiên, trong 5 giây sau khi mở socket. Trình duyệt gửi `page`; SDK native gửi `screen`, `app` và `client`. `app_state` cho phép socket mở từ nền không bị tính là đang online.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `token` | `string` | có | ≤ 2048 ký tự |
| `site_id` | `string` | có | 1–64 ký tự |
| `page` | `object {url, title?, referrer?}` | không |  |
| `screen` | `object {name, params?}` | không |  |
| `app` | `object {install_id, version?, sdk?, os?, os_version?, device_model?, locale?}` | không |  |
| `client` | `object {sdk, version}` | không |  |
| `locale` | `string` | không | ≤ 10 ký tự |
| `app_state` | `object {foreground}` | không |  |

### `page`

Khách chuyển trang (trình duyệt); ghi vào hội thoại đang mở, hoặc vào hành trình trước khi nhắn.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `url` | `string` | có | ≤ 2048 ký tự |
| `title` | `string` | không | ≤ 300 ký tự |
| `referrer` | `string` | không | ≤ 2048 ký tự |

### `screen`

Khách chuyển màn hình (SDK native); thay cho `page`, và được ghi như `page`.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `name` | `string` | có | 1–200 ký tự |
| `params` | `object<string, string>` | không | khoá ≤ 60, giá trị ≤ 500 ký tự |

### `app.state`

Ứng dụng vào nền hoặc trở lại. Ứng dụng ở nền được coi là offline: trả lời đi qua thông báo đẩy hoặc email; màn hình đang mở (`conversation_id`) không nhận đẩy.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `foreground` | `boolean` | có |  |
| `conversation_id` | `string \| null` | không | 1–64 ký tự |

### `message.send`

Gửi tin. `client_id` do client sinh, dùng lại khi thử lại; server dedupe. `content` rỗng cần ít nhất một `attachments`. Giới hạn 5 tin / 10 giây và 60 tin / phút.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | không | 1–64 ký tự |
| `client_id` | `string` | có | 1–64 ký tự |
| `content` | `string` | có | ≤ 4000 ký tự |
| `attachments` | `string[]` | không | ≤ 5 phần tử; mỗi phần tử ≤ 64 ký tự |

### `typing`

Khách đang gõ.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `on` | `boolean` | có |  |

### `read`

Khách đã đọc tới `up_to_seq`; chỉ gửi khi hội thoại đang hiển thị và ứng dụng ở tiền cảnh.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `up_to_seq` | `integer` | có | ≥ 0; ≤ 2147483647 |

### `identify`

Gắn danh tính. `hash` là HMAC-SHA256 hex của `user_id` với identity secret của site (xem [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md)). Danh tính có chữ ký đã thuộc khách khác sẽ được gộp và trả về khung `session`.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `user_id` | `string` | không | ≤ 200 ký tự |
| `email` | `string` | không | ≤ 320 ký tự; khớp `^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`; email |
| `name` | `string` | không | ≤ 200 ký tự |
| `phone` | `string` | không | ≤ 40 ký tự |
| `hash` | `string` | không | ≤ 128 ký tự |
| `data` | `object` | không |  |

### `event`

Sự kiện tuỳ ý từ ứng dụng (`pushEvent`), ghi vào hội thoại đang mở. Khi khách chưa có hội thoại, các trang, màn hình và sự kiện được giữ làm hành trình của khách (20 bước gần nhất, trong 24 giờ) và gắn vào hội thoại khách mở, chỉ nhân viên thấy. Giá trị dưới khoá trông như bí mật (password, token, secret, card, cvv, otp) được thay bằng `[redacted]`.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `name` | `string` | có | ≤ 100 ký tự |
| `data` | `object` | không |  |

### `resume`

Phát lại các mục sau `after_seq` của một hội thoại thuộc khách, sau khi kết nối lại.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `after_seq` | `integer` | có | ≥ 0; ≤ 2147483647 |

### `rate`

Đánh giá hội thoại (1–5) sau khi được giải quyết.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `score` | `integer` | có | ≥ 1; ≤ 5 |
| `comment` | `string` | không | ≤ 1000 ký tự |

### `campaign`

Tương tác với chiến dịch (đã hiện, bấm, mở, gửi form).

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `campaign_id` | `string` | có | 1–64 ký tự |
| `action` | `"shown" \| "clicked" \| "open" \| "submit"` | có |  |
| `button` | `string` | không | ≤ 40 ký tự |
| `fields` | `object {name?, phone?, email?}` | không |  |

### `ping`

Giữ kết nối, mỗi 25 giây; server trả `pong`.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

_Không có dữ liệu (`d` là `null`)._

## Khung server → visitor

### `ready`

Xác thực xong: khách, hội thoại đang mở (hoặc `null`), 100 mục gần nhất và trạng thái trực tuyến của đội.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `visitor` | `Visitor` ([Visitor](#visitor)) | có |  |
| `conversation` | `Conversation \| null` ([Conversation](#conversation)) | có |  |
| `timeline` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | có |  |
| `timeline_has_more` | `boolean` | không |  |
| `availability` | `Availability` ([Availability](#availability)) | có |  |

### `message`

Tin nhắn mới trong hội thoại (kể cả bản echo tin của chính khách; dedupe theo `id`).

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `conversation_id` | `string` | có | 1–64 ký tự |
| `seq` | `integer` | có |  |
| `sender_type` | `"visitor" \| "agent" \| "bot" \| "system"` | có |  |
| `sender_id` | `string \| null` | có |  |
| `sender_name` | `string \| null` | không |  |
| `visibility` | `"public" \| "private"` | có |  |
| `content_type` | `"text" \| "file"` | có |  |
| `content` | `string` | có |  |
| `attachments` | `Attachment[]` ([Attachment](#attachment)) | có |  |
| `client_id` | `string \| null` | không |  |
| `metadata` | `object` | không |  |
| `delivery_status` | `"pending" \| "sent" \| "failed" \| null` | không |  |
| `delivery_error` | `string \| null` | không |  |
| `read_at` | `string \| null` | không |  |
| `created_at` | `string` | có |  |

### `message.ack`

Xác nhận `message.send` với `id`, `seq` và `conversation_id` thật.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `client_id` | `string` | có |  |
| `id` | `string` | có | 1–64 ký tự |
| `seq` | `integer` | có |  |
| `conversation_id` | `string` | có | 1–64 ký tự |

### `event`

Sự kiện trong hội thoại. Khách chỉ nhận `status_changed`, `handoff`, `bot_resumed`, `rated`, `order_paid`, `ticket_created`, `ticket_status`, `notice`; bỏ qua loại lạ.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `conversation_id` | `string` | có | 1–64 ký tự |
| `seq` | `integer` | có |  |
| `type` | `string` | có |  |
| `actor_type` | `"visitor" \| "agent" \| "bot" \| "system"` | có |  |
| `actor_id` | `string \| null` | có |  |
| `data` | `object` | có |  |
| `created_at` | `string` | có |  |

### `typing`

Nhân viên đang gõ.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `side` | `"visitor" \| "agent"` | có |  |
| `on` | `boolean` | có |  |
| `name` | `string` | không |  |

### `availability`

Đội có trực tuyến không, có trong giờ làm việc không, giờ mở lại và số nhân viên online.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `online` | `boolean` | có |  |
| `within_hours` | `boolean` | có |  |
| `next_open_at` | `string \| null` | có |  |
| `agents_online` | `integer` | có |  |

### `read`

Bên kia đã đọc tới `up_to_seq`.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `side` | `"visitor" \| "agent"` | có |  |
| `up_to_seq` | `integer` | có |  |

### `conversation.update`

Một hội thoại của khách đổi trạng thái, số chưa đọc hoặc người phụ trách (view an toàn cho khách).

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `site_id` | `string` | có | 1–64 ký tự |
| `status` | `"open" \| "pending" \| "resolved" \| "closed"` | có |  |
| `seq` | `integer` | có |  |
| `visitor_unread` | `integer` | có |  |
| `last_message_at` | `string \| null` | có |  |
| `last_message_preview` | `string \| null` | có |  |
| `last_message_sender_type` | `"visitor" \| "agent" \| "bot" \| "system" \| null` | có |  |
| `first_response_at` | `string \| null` | có |  |
| `resolved_at` | `string \| null` | có |  |
| `subject` | `string \| null` | có |  |
| `created_at` | `string` | có |  |
| `updated_at` | `string` | có |  |
| `agent` | `object {name, avatar_url} \| null` | có |  |

### `resume`

Trả lời `resume`: các mục sau `after_seq`, `has_more` khi còn tiếp.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation_id` | `string` | có | 1–64 ký tự |
| `items` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | có |  |
| `has_more` | `boolean` | có |  |

### `conversation.start`

Đội chủ động nhắn trước; `open` cho biết có nên mở màn hình chat ngay không.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `conversation` | `Conversation` ([Conversation](#conversation)) | có |  |
| `open` | `boolean` | có |  |

### `session`

Phiên đổi chủ (gộp danh tính hoặc reset): lưu `visitor_token` mới, bootstrap lại. Socket web nhận `reload` ngay sau; socket ứng dụng bị đóng với mã 4005.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `visitor_token` | `string` | có |  |
| `visitor_id` | `string` | có | 1–64 ký tự |
| `reason` | `"identity_merged" \| "reset"` | có |  |

### `identified`

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `visitor_id` | `string` | có | 1–64 ký tự |
| `verified` | `boolean` | có |  |

### `reload`

Client web tải lại trang; client native bootstrap lại (kèm mã đóng 4004 thì dừng kết nối lại và báo cần cập nhật ứng dụng).

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `reason` | `string` | có |  |

### `error`

Lỗi cho một yêu cầu (`for` là `id` của khung gây lỗi) hoặc lỗi chung.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `code` | `string` | có |  |
| `message` | `string` | có |  |
| `for` | `string` | không |  |

### `pong`

Trả lời `ping`.

`id`: tuỳ chọn (đối chiếu với `message.ack` / `error`).

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `now` | `string` | có |  |

## HTTP

### Phản hồi `POST bootstrap` {#bootstrap}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `visitor_token` | `string` | có |  |
| `visitor` | `Visitor` ([Visitor](#visitor)) | có |  |
| `config` | `object {site_id, name, color, position, locale_default, greeting, away_message, offline_capture, hide_when_offline, theme, bot_enabled, agent_avatars, launcher?, header?, greeting_options?, contact_buttons?, layout?, prechat?, visibility?, notifications?, allow_in_iframe?}` | có |  |
| `availability` | `Availability` ([Availability](#availability)) | có |  |
| `conversation` | `Conversation \| null` ([Conversation](#conversation)) | có |  |
| `timeline` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | có |  |
| `timeline_has_more` | `boolean` | không |  |
| `bundle` | `string` | có |  |
| `ws_url` | `string` | có |  |
| `protocol_version` | `integer` | có |  |
| `app` | `object {id, platform} \| null` | có |  |

### Thẻ trong `metadata` của tin nhắn {#cards}

Một tin có `metadata.kind` thuộc danh sách dưới mang dữ liệu có cấu trúc; `content` luôn là bản chữ thuần để email, xem trước và các kênh không có mẫu vẫn đọc được. Kind lạ coi như tin thường.

#### `product`

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `kind` | `"product"` | có |  |
| `products` | `(object {id, name, price, compare_at_price?, currency, image_url?, url?, sku?, store_product_ids?, base_plan_id?, offer_id?, billing_period?, trial_days?})[]` | có |  |
| `intro` | `string` | không |  |

#### `order`

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `kind` | `"order"` | có |  |
| `order` | `object {id, code, status, items, subtotal, discount, shipping, total, currency, payment_status}` | có |  |
| `intro` | `string` | không |  |

#### `payment`

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `kind` | `"payment"` | có |  |
| `payment` | `object {id, order_id, order_code, amount, currency, checkout_url, qr_image_url, qr_payload?, code, expires_at, status}` | có |  |
| `intro` | `string` | không |  |

#### `quick_replies`

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `kind` | `"quick_replies"` | có |  |
| `options` | `string[]` | có |  |

#### `csat`

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `kind` | `"csat"` | có |  |
| `options` | `string[]` | có |  |
| `answered` | `boolean` | không |  |

## Kiểu dùng chung

Các đối tượng xuất hiện ở nhiều khung. Trong `timeline` của `ready` và `items` của `resume`, mỗi phần tử có thêm `kind: "message" | "event"` để phân biệt.

### Visitor {#visitor}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `site_id` | `string` | có | 1–64 ký tự |
| `channel` | `string` | có |  |
| `email` | `string \| null` | có |  |
| `name` | `string \| null` | có |  |
| `phone` | `string \| null` | có |  |
| `avatar_url` | `string \| null` | có |  |
| `external_id` | `string \| null` | có |  |
| `custom_data` | `object` | có |  |
| `locale` | `"vi" \| "en" \| null` | có |  |
| `browser` | `string \| null` | có |  |
| `os` | `string \| null` | có |  |
| `device` | `string \| null` | có |  |
| `country` | `string \| null` | có |  |
| `city` | `string \| null` | có |  |
| `current_url` | `string \| null` | có |  |
| `current_title` | `string \| null` | có |  |
| `referrer` | `string \| null` | có |  |
| `first_seen_at` | `string` | có |  |
| `last_seen_at` | `string` | có |  |
| `identified_at` | `string \| null` | có |  |
| `utm` | `object<string, string>` | có |  |
| `visits` | `integer` | có |  |
| `customer_id` | `string \| null` | có |  |
| `client` | `"web" \| "app"` | có |  |
| `app` | `object {id, platform, version, sdk} \| null` | không |  |
| `current_screen` | `string \| null` | không |  |

### Conversation {#conversation}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `site_id` | `string` | có | 1–64 ký tự |
| `visitor_id` | `string` | có | 1–64 ký tự |
| `channel` | `string` | có |  |
| `channel_account_id` | `string \| null` | không |  |
| `status` | `"open" \| "pending" \| "resolved" \| "closed"` | có |  |
| `assignee_kind` | `"user" \| "bot" \| null` | có |  |
| `assignee_id` | `string \| null` | có |  |
| `seq` | `integer` | có |  |
| `visitor_unread` | `integer` | có |  |
| `agent_unread` | `integer` | có |  |
| `last_message_at` | `string \| null` | có |  |
| `last_message_preview` | `string \| null` | có |  |
| `last_message_sender_type` | `"visitor" \| "agent" \| "bot" \| "system" \| null` | có |  |
| `last_visitor_message_at` | `string \| null` | không |  |
| `first_response_at` | `string \| null` | có |  |
| `resolved_at` | `string \| null` | có |  |
| `tags` | `string[]` | có |  |
| `member_ids` | `string[]` | có |  |
| `flag` | `"hidden" \| "spam" \| null` | có |  |
| `snoozed_until` | `string` | không |  |
| `muted_by` | `string[]` | không |  |
| `subject` | `string \| null` | có |  |
| `created_at` | `string` | có |  |
| `updated_at` | `string` | có |  |

### Message {#message}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `conversation_id` | `string` | có | 1–64 ký tự |
| `seq` | `integer` | có |  |
| `sender_type` | `"visitor" \| "agent" \| "bot" \| "system"` | có |  |
| `sender_id` | `string \| null` | có |  |
| `sender_name` | `string \| null` | không |  |
| `visibility` | `"public" \| "private"` | có |  |
| `content_type` | `"text" \| "file"` | có |  |
| `content` | `string` | có |  |
| `attachments` | `Attachment[]` ([Attachment](#attachment)) | có |  |
| `client_id` | `string \| null` | không |  |
| `metadata` | `object` | không |  |
| `delivery_status` | `"pending" \| "sent" \| "failed" \| null` | không |  |
| `delivery_error` | `string \| null` | không |  |
| `read_at` | `string \| null` | không |  |
| `created_at` | `string` | có |  |
| `kind` | `"message"` | có |  |

### Event {#event}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `conversation_id` | `string` | có | 1–64 ký tự |
| `seq` | `integer` | có |  |
| `type` | `string` | có |  |
| `actor_type` | `"visitor" \| "agent" \| "bot" \| "system"` | có |  |
| `actor_id` | `string \| null` | có |  |
| `data` | `object` | có |  |
| `created_at` | `string` | có |  |
| `kind` | `"event"` | có |  |

### Attachment {#attachment}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `id` | `string` | có | 1–64 ký tự |
| `name` | `string` | có |  |
| `size` | `integer` | có |  |
| `type` | `string` | có |  |
| `url` | `string` | không |  |

### Availability {#availability}

| Trường | Kiểu | Bắt buộc | Ràng buộc |
|---|---|---|---|
| `online` | `boolean` | có |  |
| `within_hours` | `boolean` | có |  |
| `next_open_at` | `string \| null` | có |  |
| `agents_online` | `integer` | có |  |

Source: https://hatang.dev/vi/docs/reference/realtime-protocol
