1. Tích hợp này là gì, khi nào dùng
Mã tính năng trong ChatVeo: “AI qua API”.
“AI qua API” cho phép mỗi công ty cắm AI backend của riêng mình (chatbot nội bộ, RAG trên tài liệu sản phẩm, agent LLM tự xây…) vào ChatVeo để tự động trả lời khách hàng, thay cho bộ trả lời OpenAI mặc định của ChatVeo. Khi một khách gửi tin nhắn mà chưa có nhân viên nào tiếp nhận hội thoại, ChatVeo ký và POST toàn bộ ngữ cảnh (tin nhắn mới, lịch sử hội thoại, thông tin khách) sang URL webhook bạn cấu hình. AI của bạn trả lời — ngay trong response của webhook đó, hoặc gọi lại REST API sau khi suy nghĩ xong. Câu trả lời đi qua đúng đường mà tin nhắn của nhân viên thật đi qua, nên nó hiện trong widget trên website, trong Hộp thư của dashboard, trong app mobile, và được chuyển tiếp ra Telegram / Facebook / Zalo nếu khách đến từ các kênh đó.
Dùng khi: bạn đã có AI riêng hiểu sản phẩm/dữ liệu của mình và muốn nó trực tiếp phục vụ khách trên kênh chat của ChatVeo, đồng thời vẫn giữ nguyên khả năng nhân viên nhảy vào tiếp quản bất cứ lúc nào.
Cấu hình theo từng site (mỗi siteKey một bộ webhook URL / API key / signing secret riêng), không dùng chung toàn tài khoản.
2. Luồng hoạt động
- Khách gửi tin nhắn (widget web, Telegram, Facebook, Zalo).
- ChatVeo lưu tin nhắn, phát cho các nhân viên đang online.
- ChatVeo kiểm tra trạng thái hội thoại. Chỉ gọi AI khi hội thoại đang
openvà chưa từng có nhân viên nào tiếp nhận. Ba trạng thái còn lại (pending_handoff,assigned,closed) đều không sinh webhook — xem mục 9. - Nếu site có “AI qua API” đang bật và đã có Webhook URL → ChatVeo hiện dấu “đang gõ” cho khách, ký payload bằng HMAC-SHA256 và POST một lần duy nhất sang webhook của bạn (timeout 20 giây).
- Cách A: webhook của bạn trả HTTP 2xx kèm JSON
{"reply": "..."}→ ChatVeo đăng ngay câu đó vào chat.
Cách B: webhook trả 2xx với body rỗng → ChatVeo tắt dấu “đang gõ”, bạn trả lời sau bằngPOST /api/v1/ai/conversations/:id/reply. - Nếu webhook lỗi/timeout: tuỳ tuỳ chọn “Dùng AI mặc định của ChatVeo khi AI của bạn lỗi” (
fallbackBuiltin) — bật thì ChatVeo trả lời bằng AI nội bộ, tắt thì khách không nhận câu trả lời tự động nào. - Bất cứ lúc nào nhân viên tiếp nhận hội thoại (trạng thái
assigned), mọi lời gọi/replysau đó bị từ chối bằng HTTP 409human_assigned— AI không thể nói chen vào người thật. - Sau khi hội thoại đã được chuyển cho nhân viên (
pending_handoff) hoặc đã có nhân viên nhận (assigned), hội thoại đó không bao giờ sinh webhook AI nữa, kể cả khi khách tiếp tục nhắn. Lần chat sau của khách là một hội thoại mới và AI phục vụ lại bình thường.
role: "ai" và metadata.source = "external_ai", nên báo cáo phân biệt được
câu trả lời của AI công ty bạn với AI nội bộ của ChatVeo.
3. Bật tích hợp & lấy API key trong dashboard
Cần quyền Admin trên site. Toàn bộ nằm ở một trang duy nhất.
- Đăng nhập app.chatveo.com, chọn đúng site ở đầu trang.
- Mở menu bên trái → 🔌 AI qua API.
- Mục “1. Bật tích hợp” — tick ô bật tích hợp AI qua API. Ở đây cũng có tuỳ chọn dùng AI mặc định của ChatVeo làm phương án dự phòng khi endpoint của bạn lỗi.
- Mục “2. Webhook URL (ChatVeo → AI của bạn)” — dán URL HTTPS công khai mà backend bạn dùng để nhận webhook
(ví dụ
https://ai.congtyban.com/chatveo/webhook), đặt Số tin nhắn lịch sử gửi kèm (1–50, mặc định dùng cho ngữ cảnh), rồi Lưu. Ngay dưới đó là Signing secret (dạngcvsec_…) — bạn cần nó để xác thực chữ ký; có thể xem, copy, hoặc tạo lại. Nút Gửi thử gửi một payload mẫu (event: "test") đã ký vào endpoint của bạn và báo lại kết quả thật. - Mục “3. API key (AI của bạn → ChatVeo)” — bấm tạo key. Key dạng
cvai_…và chỉ hiện đúng một lần (hệ thống chỉ lưu bản hash SHA-256, không lưu key gốc). Copy và cất vào secret manager ngay. Tạo key mới sẽ vô hiệu hoá key cũ lập tức. Cũng có thể thu hồi key để cắt truy cập. - Mục “4. Cách nối AI của bạn” — có snippet curl sẵn để copy, và trang này hiển thị thời điểm giao webhook gần nhất, thành công/thất bại kèm thông báo lỗi nguyên văn — dùng để debug rất nhanh.
• Signing secret (
cvsec_…) — ChatVeo dùng để ký webhook gửi cho bạn; bạn dùng để xác thực. Không bao giờ gửi nó đi đâu.• API key (
cvai_…) — bạn dùng làm Authorization: Bearer khi gọi ngược vào ChatVeo.
4. Webhook ChatVeo → AI của bạn
POST tới Webhook URL bạn đã cấu hình · Content-Type: application/json
Headers ChatVeo gửi kèm
| Header | Giá trị |
|---|---|
X-Chatveo-Signature | sha256=<hex> — HMAC-SHA256 của chuỗi ${timestamp}.${rawBody}, khoá là signing secret của site. |
X-Chatveo-Timestamp | Unix milliseconds lúc gửi. Nằm trong chuỗi được ký, nên kẻ bắt được request không thể sửa nó sang giờ mới mà chữ ký vẫn đúng — nhờ vậy việc bạn từ chối timestamp quá cũ là một biện pháp chống replay đáng tin. Việc kiểm tra đó nằm ở phía bạn (xem mục 5). |
X-Chatveo-Event | message.visitor (khách gửi tin nhắn thật) hoặc test (bấm “Gửi thử” trong dashboard). |
X-Chatveo-Delivery | UUID duy nhất cho mỗi lần giao — dùng làm idempotency key phía bạn. |
Payload
{
"event": "message.visitor",
"siteKey": "site_abc123",
"timestamp": "2026-09-30T08:41:12.004Z",
"data": {
"site": {
"name": "Công Ty ABC",
"aiPrompt": "Bạn là trợ lý bán hàng của ABC..."
},
"conversation": {
"id": "cnv_01J8XYZ...",
"siteKey": "site_abc123",
"status": "open",
"createdAt": "2026-09-30T08:39:50.120Z",
"tagIds": ["tag_vip"]
},
"visitor": {
"id": "vst_01J8ABC...",
"name": "Nguyễn Văn A",
"email": "a@example.com",
"channel": "widget",
"metadata": { "phone": "0901234567", "page": "/san-pham/x" },
"totalConversations": 3
},
"message": {
"id": "msg_01J8QRS...",
"conversationId": "cnv_01J8XYZ...",
"role": "visitor",
"content": "Sản phẩm X còn hàng không ạ?",
"metadata": null,
"createdAt": "2026-09-30T08:41:11.980Z"
},
"urls": {
"reply": "https://api.chatveo.com/api/v1/ai/conversations/cnv_01J8XYZ.../reply",
"handoff": "https://api.chatveo.com/api/v1/ai/conversations/cnv_01J8XYZ.../handoff",
"typing": "https://api.chatveo.com/api/v1/ai/conversations/cnv_01J8XYZ.../typing",
"conversation": "https://api.chatveo.com/api/v1/ai/conversations/cnv_01J8XYZ...",
"me": "https://api.chatveo.com/api/v1/ai/me"
},
"history": [
{
"id": "msg_01J8AAA...",
"conversationId": "cnv_01J8XYZ...",
"role": "visitor",
"content": "Chào shop",
"createdAt": "2026-09-30T08:39:55.000Z"
},
{
"id": "msg_01J8BBB...",
"conversationId": "cnv_01J8XYZ...",
"role": "ai",
"content": "Chào anh/chị, em có thể giúp gì?",
"createdAt": "2026-09-30T08:39:56.400Z"
}
],
"replyUrl": "https://api.chatveo.com/api/v1/ai/conversations/cnv_01J8XYZ.../reply"
}
}
Mô tả từng field
| Field | Kiểu | Ý nghĩa |
|---|---|---|
event | string | "message.visitor" | "test". Payload test dùng id giả (conv_test, msg_test) — đừng gọi /reply với nó. |
siteKey | string | Site nào trong ChatVeo. Nếu bạn phục vụ nhiều site, dùng field này để chọn cấu hình/knowledge base. |
timestamp | string (ISO) | Thời điểm dispatch. Lưu ý: chữ ký được ký bằng header X-Chatveo-Timestamp (ms), không phải field này. |
data.site.name | string | Tên site, tiện đưa vào prompt. |
data.site.aiPrompt | string | null | Prompt persona mà admin đã cấu hình trong ChatVeo cho site này. Bạn có thể dùng, hoặc bỏ qua và dùng prompt riêng. |
data.conversation.id | string | Id hội thoại — dùng cho tất cả lời gọi REST API sau đó. |
data.conversation.status | string | open | pending_handoff | assigned | closed (chữ thường). |
data.conversation.tagIds | string[] | Nhãn đang gắn trên hội thoại. |
data.visitor.channel | string | widget | telegram | facebook | zalo — nơi khách đang chat, để AI điều chỉnh độ dài/format câu trả lời. |
data.visitor.metadata | object | null | Dữ liệu tự do gắn trên khách (đáp án form trước chat, URL trang, v.v.) — đúng phần mà nhân viên thấy ở thanh bên. |
data.visitor.totalConversations | number | Tổng số hội thoại khách này từng có (1 = khách mới hoàn toàn). |
data.message | Message | Tin nhắn vừa kích hoạt lần gọi này (role: "visitor"). |
data.history | Message[] | Các tin nhắn trước đó, cũ nhất trước, không bao gồm data.message. Số lượng tối đa = “Số tin nhắn lịch sử gửi kèm” trong dashboard (1–50). |
data.replyUrl | string | URL tuyệt đối để POST câu trả lời bất đồng bộ. Giống hệt data.urls.reply, giữ lại cho tương thích. |
data.urls | object | Tất cả endpoint bạn có thể cần, dạng URL tuyệt đối — xem bảng dưới. Hãy dùng các field này thay vì hardcode https://api.chatveo.com trong code của bạn. |
Kiểu data.urls
| Field | Method | Endpoint tương ứng |
|---|---|---|
urls.reply | POST | Gửi câu trả lời / chuyển nhân viên — /conversations/:id/reply |
urls.handoff | POST | Chuyển nhân viên mà không nói gì — /conversations/:id/handoff |
urls.typing | POST | Bật/tắt dấu “đang gõ” — /conversations/:id/typing |
urls.conversation | GET | Lấy lại ngữ cảnh hội thoại — /conversations/:id (thêm ?limit=… nếu cần) |
urls.me | GET | Kiểm tra API key — /me. Giống nhau ở mọi hội thoại. |
data.urls cũng có trong response của GET /conversations/:id, nên một AI vừa restart chỉ cần nhớ
conversationId là lấy lại được đủ URL. Base URL là chi tiết triển khai của ChatVeo và có thể thay đổi —
hardcode nó trong code của bạn là nguồn lỗi âm thầm duy nhất mà bạn hoàn toàn tránh được.
Kiểu Message
| Field | Kiểu | Ý nghĩa |
|---|---|---|
id | string | Id tin nhắn. |
conversationId | string | Hội thoại chứa tin nhắn. |
role | string | visitor (khách) | agent (nhân viên) | ai | system. |
content | string | Nội dung text. |
senderName | string | null | Tên nhân viên gửi (chỉ có với role: "agent"). |
senderAvatarUrl | string | null | Ảnh đại diện nhân viên. |
metadata | object | null | Tuỳ theo nguồn tin nhắn — xem bảng ngay dưới. |
createdAt | string (ISO) | Thời điểm tạo. |
Các khoá có thể xuất hiện trong Message.metadata
| Khoá | Kiểu | Xuất hiện khi |
|---|---|---|
attachment | object | Khách gửi ảnh/file từ widget web, hoặc nhân viên gửi file từ dashboard. Xem bảng field bên dưới. |
source | "external_ai" | Tin nhắn do AI của bạn gửi (qua /reply hoặc trả lời đồng bộ). Không có khoá này = AI nội bộ của ChatVeo. |
confidence | number 0..1 | Bạn (hoặc AI nội bộ) có gửi confidence kèm câu trả lời. |
| (khoá của bạn) | bất kỳ | Mọi field bạn đặt trong metadata của /reply được lưu nguyên vẹn và trả lại ở đây. |
Kiểu metadata.attachment
| Field | Kiểu | Ý nghĩa |
|---|---|---|
url | string | URL tuyệt đối, công khai, không cần xác thực — tải trực tiếp bằng GET, không cần API key. Ví dụ https://api.chatveo.com/uploads/3f2b7c10-8a11-4e2f-9c44-1d0a77e1b5c2.png. |
name | string | Tên file gốc do người gửi đặt, chưa qua kiểm duyệt — escape trước khi hiển thị hoặc ghi vào prompt. |
mimeType | string | Chỉ có thể là một trong: image/png, image/jpeg, image/gif, image/webp, application/pdf. ChatVeo từ chối mọi định dạng khác ngay lúc upload. |
size | number | Dung lượng theo byte. Trần cứng 10 MB (10 485 760 byte). |
{
"id": "msg_01J8QRS...",
"conversationId": "cnv_01J8XYZ...",
"role": "visitor",
"content": "",
"metadata": {
"attachment": {
"url": "https://api.chatveo.com/uploads/3f2b7c10-8a11-4e2f-9c44-1d0a77e1b5c2.png",
"name": "hoa-don-thang-9.png",
"mimeType": "image/png",
"size": 284915
}
},
"createdAt": "2026-09-30T08:41:11.980Z"
}
content là chuỗi rỗng (""), không phải null. Widget cho phép gửi file
không kèm chữ, và webhook vẫn được gọi như bình thường. AI của bạn phải xử lý trường hợp này — nếu chỉ đọc
data.message.content thì bạn sẽ gửi chuỗi rỗng vào LLM và nhận lại câu trả lời vô nghĩa.
Kiểm tra data.message.metadata?.attachment trước.
attachment. Và vì url là công khai, đừng log nó ra nơi ai cũng đọc được.
5. Xác thực chữ ký HMAC & chống replay
Endpoint webhook của bạn là public, nên bắt buộc kiểm tra chữ ký trước khi tin payload. Ba bước:
- Đọc raw body (chuỗi byte gốc, chưa qua
JSON.parserồiJSON.stringifylại — thứ tự key và khoảng trắng phải giữ nguyên tuyệt đối). - Tính
HMAC_SHA256(signingSecret, timestamp + "." + rawBody)vớitimestamplấy nguyên văn từ headerX-Chatveo-Timestamp, ra hex. - So sánh với phần sau
sha256=trongX-Chatveo-Signaturebằng hàm so sánh thời gian hằng (crypto.timingSafeEqual), và từ chối nếu timestamp lệch quá 5 phút so với hiện tại.
import crypto from 'node:crypto';
const SIGNING_SECRET = process.env.CHATVEO_SIGNING_SECRET; // cvsec_...
const MAX_SKEW_MS = 5 * 60 * 1000; // chống replay
/** rawBody: Buffer hoặc string đúng nguyên văn body đã nhận. */
export function verifyChatveoWebhook(rawBody, headers) {
const signature = headers['x-chatveo-signature']; // "sha256=abc123..."
const timestamp = headers['x-chatveo-timestamp']; // "1790000000123"
if (!signature || !timestamp) return false;
// 1) Chống replay: timestamp phải còn mới.
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > MAX_SKEW_MS) return false;
// 2) Tính lại HMAC trên `${timestamp}.${rawBody}`.
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// 3) So sánh thời gian hằng.
const received = signature.startsWith('sha256=') ? signature.slice(7) : signature;
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
hoặc express.raw({ type: 'application/json' }).
Còn chiều ngược lại (AI của bạn → ChatVeo) thì sao?
Chiều gọi vào /api/v1/ai/* không dùng chữ ký HMAC và không có header timestamp — chỉ cần
Authorization: Bearer cvai_…. ChatVeo không kiểm tra X-Chatveo-Timestamp ở các endpoint này
(nếu bạn có gửi kèm thì nó bị bỏ qua). Bốn lớp bảo vệ thật sự của chiều này là:
- HTTPS bắt buộc — không ai đọc hay phát lại được gói tin trên đường truyền.
- API key dạng hash — ChatVeo chỉ lưu SHA-256, và key chết ngay khi bạn thu hồi hoặc tạo key mới.
- Giới hạn tần suất — 300 request/phút/key và 12 tin nhắn AI/phút/hội thoại (mục 10): kể cả key bị lộ cũng không làm ngập được chat của khách.
- Khoá theo site — mỗi key chỉ thấy hội thoại của đúng site đó; id của site khác trả về
404.
6. Trả lời: đồng bộ (Cách A) hoặc bất đồng bộ (Cách B)
Cách A — trả lời ngay trong response của webhook
Đơn giản nhất, không cần API key. Trả HTTP 2xx kèm JSON:
{
"reply": "Dạ sản phẩm X còn hàng ạ, em gửi anh/chị link đặt hàng nhé.",
"confidence": 0.86,
"handoff": false
}
| Field | Kiểu | Ý nghĩa |
|---|---|---|
reply | string (tuỳ chọn) | Câu trả lời. Bỏ trống hoặc rỗng = “chưa nói gì bây giờ”, sẽ trả lời sau qua replyUrl. |
handoff | boolean (tuỳ chọn) | true → ChatVeo chuyển hội thoại sang trạng thái Chờ nhân viên (pending_handoff) và đẩy thông báo cho nhân viên. |
handoffReason | string (tuỳ chọn) | Lý do chuyển, hiện cho nhân viên. |
confidence | number 0..1 (tuỳ chọn) | Lưu vào metadata tin nhắn, phục vụ báo cáo/đánh giá chất lượng AI. |
Cách B — trả lời sau qua REST API
- Webhook trả HTTP 200 với body rỗng ngay lập tức (ChatVeo coi đó là “tôi sẽ trả lời sau”, và tắt dấu “đang gõ”).
- Tuỳ chọn: gọi
POST /api/v1/ai/conversations/:id/typingvới{"state":"start"}để khách thấy dấu “đang gõ” trong lúc AI suy nghĩ. - Khi có câu trả lời, gọi
POST /api/v1/ai/conversations/:id/reply(hoặc chínhdata.replyUrl) vớiAuthorization: Bearer cvai_….
7. REST API đầy đủ
Base URL: https://api.chatveo.com/api/v1/ai · mọi endpoint yêu cầu header Authorization: Bearer <API key>
curl. Trong code, hãy lấy URL từ data.urls
của payload webhook (hoặc từ response GET /conversations/:id) — mọi endpoint dưới đây đều có sẵn ở đó dưới dạng
URL tuyệt đối, nên bạn không cần ghép host bằng tay. Xem mục 4.
POST /conversations/:id/reply
Gửi câu trả lời của AI vào hội thoại. Tin nhắn đi qua đúng pipeline của tin nhắn nhân viên: hiện realtime trong widget, Hộp thư, app mobile, và chuyển tiếp ra Telegram/Facebook/Zalo nếu cần.
Request body
| Field | Kiểu | Ràng buộc |
|---|---|---|
content | string | Tuỳ chọn. Sau khi trim phải ≥ 1 ký tự, tối đa 8000 ký tự. |
handoff | boolean | Tuỳ chọn. true = chuyển cho nhân viên. |
handoffReason | string | Tuỳ chọn, tối đa 200 ký tự. |
confidence | number | Tuỳ chọn, trong khoảng 0–1. |
metadata | object | Tuỳ chọn. Dữ liệu tự do của bạn, lưu kèm tin nhắn (ví dụ id truy vết, tên model). |
content và handoff đều tuỳ chọn nhưng phải có ít nhất một trong hai, nếu không nhận 400 empty_reply.
Gửi cả hai trong một lời gọi là hợp lệ và hữu ích: nói câu cuối rồi chuyển máy cho nhân viên.
Response 200
{
"success": true,
"data": {
"message": {
"id": "msg_01J8ZZZ...",
"conversationId": "cnv_01J8XYZ...",
"role": "ai",
"content": "Dạ sản phẩm X còn hàng ạ.",
"metadata": { "source": "external_ai", "confidence": 0.86 },
"createdAt": "2026-09-30T08:41:14.221Z"
},
"handoff": false
}
}
data.message là null nếu bạn chỉ gửi handoff: true mà không có content.
Các mã lỗi
| HTTP | code | Nguyên nhân & cách xử lý |
|---|---|---|
| 400 | invalid_body | Body sai schema (content quá 8000 ký tự, confidence ngoài 0–1…). Sửa request, không retry nguyên trạng. |
| 400 | empty_reply | Thiếu cả content và handoff. Lỗi logic phía bạn. |
| 401 | missing_api_key | Thiếu header Authorization: Bearer …. |
| 401 | invalid_api_key | Key sai, key đã bị thu hồi, hoặc tích hợp “AI qua API” của site đang tắt. Ba trường hợp này trả cùng một thông báo (cố ý, để không tiết lộ thông tin cho kẻ dò key). Kiểm tra lại dashboard. |
| 404 | conversation_not_found | Không có hội thoại đó, hoặc hội thoại thuộc site khác — không phân biệt được hai trường hợp (cố ý, chống dò). Kiểm tra bạn đang dùng đúng key của đúng site. |
| 409 | human_assigned | Nhân viên đã tiếp nhận hội thoại. AI dừng hẳn, xem mục 9. Khớp đúng field assignedToHuman của GET /conversations/:id. |
| 409 | conversation_closed | Hội thoại đã đóng. Không retry. |
| 429 | conversation_rate_limited | Vượt 12 tin nhắn AI / phút / hội thoại (ngân sách dùng chung cho mọi tin AI trong hội thoại — xem mục 10). Chờ rồi thử lại, hoặc gộp nội dung. |
| 429 | rate_limited | Vượt 300 request / phút / API key trên toàn bộ /api/v1/ai. Tôn trọng header RateLimit-*. |
POST /conversations/:id/handoff
Chuyển cho nhân viên mà không nói gì. Body: {"reason": "..."} (tuỳ chọn, tối đa 200 ký tự). Tương đương handoff: true trên /reply.
{
"success": true,
"data": {
"handoff": true, // đã chuyển: hội thoại sang pending_handoff, nhân viên nhận thông báo
"alreadyWithHuman": false
}
}
Endpoint này idempotent. Nếu hội thoại đã thuộc về một người thật (đã ở pending_handoff, hoặc đã có nhân viên
nhận ở assigned) thì lời gọi vẫn trả 200 nhưng không làm gì cả:
{ "success": true, "data": { "handoff": false, "alreadyWithHuman": true } }
Đây là thành công, không phải lỗi — mục đích của bạn (“cho người thật xử lý việc này”) đã đạt rồi, nên ChatVeo
cố tình không trả 409 để bạn không viết code retry một request không bao giờ thay đổi được gì.
Và ChatVeo cố tình không ghi đè trạng thái assigned về pending_handoff: làm vậy sẽ đẩy
một cuộc chat đang có nhân viên phụ trách ra khỏi danh sách của họ và gửi thông báo “Khách cần gặp nhân viên”
cho cả nhóm về một việc đã có người làm.
Lỗi: 404 conversation_not_found, 409 conversation_closed (không hồi sinh hội thoại đã đóng).
POST /conversations/:id/typing
Bật/tắt dấu “đang gõ” của AI cho khách. Body: {"state": "start"} hoặc {"state": "stop"}.
ChatVeo tắt dấu này ngay khi webhook POST trả về, nên AI trả lời bất đồng bộ nên gọi start để khách không tưởng là bị bỏ rơi.
Lỗi: 400 invalid_body (state không phải start/stop), 404 conversation_not_found.
GET /conversations/:id?limit=30
Lấy lại toàn bộ ngữ cảnh hội thoại — dùng khi AI của bạn vừa restart, hoặc cần nhiều lịch sử hơn mức payload webhook mang theo.
limit: 1–100, mặc định 30 (số tin nhắn gần nhất, trả về theo thứ tự cũ nhất trước).
{
"success": true,
"data": {
"conversation": {
"id": "cnv_01J8XYZ...",
"siteKey": "site_abc123",
"status": "open",
"assignedToHuman": false,
"createdAt": "2026-09-30T08:39:50.120Z",
"lastMessageAt": "2026-09-30T08:41:11.980Z",
"tagIds": []
},
"visitor": { "id": "vst_...", "name": "Nguyễn Văn A", "email": null, "metadata": {} },
"messages": [ /* Message[], cũ nhất trước */ ],
"replyUrl": "https://api.chatveo.com/api/v1/ai/conversations/cnv_01J8XYZ.../reply",
"urls": { /* giống data.urls trong payload webhook — xem mục 4 */ }
}
}
assignedToHuman ở endpoint này khớp chính xác điều kiện khiến /reply trả 409 human_assigned,
nên đây là cách rẻ nhất để AI của bạn kiểm tra trước thay vì cứ gọi /reply rồi ăn 409.
Muốn biết hội thoại đã được chuyển cho nhân viên hay chưa (kể cả khi chưa ai nhận) thì xem status:
pending_handoff nghĩa là AI nên im lặng.
GET /me
Kiểm tra key còn sống, và biết key này thuộc site nào. Nên gọi đầu tiên khi mới cấu hình.
curl https://api.chatveo.com/api/v1/ai/me \
-H "Authorization: Bearer cvai_xxxxxxxxxxxxxxxx"
{
"success": true,
"data": {
"siteKey": "site_abc123",
"siteName": "Công Ty ABC",
"enabled": true,
"webhookConfigured": true
}
}
8. Ví dụ hoàn chỉnh bằng Node.js
Một file, đủ vòng tròn: nhận webhook → xác thực HMAC → trả lời (đồng bộ hoặc bất đồng bộ) → xử lý 409. Chỉ cần express.
// server.mjs — npm i express
import express from 'express';
import crypto from 'node:crypto';
const SIGNING_SECRET = process.env.CHATVEO_SIGNING_SECRET; // cvsec_... (mục 2 dashboard)
const API_KEY = process.env.CHATVEO_API_KEY; // cvai_... (mục 3 dashboard)
const MAX_SKEW_MS = 5 * 60 * 1000;
const app = express();
// Giữ raw body để tính HMAC — bắt buộc.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function verify(req) {
const sig = req.get('X-Chatveo-Signature');
const ts = req.get('X-Chatveo-Timestamp');
if (!sig || !ts) return false;
if (Math.abs(Date.now() - Number(ts)) > MAX_SKEW_MS) return false; // chống replay
const expected = crypto.createHmac('sha256', SIGNING_SECRET)
.update(`${ts}.${req.rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from((sig.startsWith('sha256=') ? sig.slice(7) : sig), 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
/** AI của bạn. Thay bằng LLM/RAG thật. */
async function askMyAi({ question, history, site }) {
if (/ho[àa]n ti[eề]n|khi[eế]u n[aạ]i/i.test(question)) {
return { handoff: true, reason: 'Khách hỏi về hoàn tiền / khiếu nại' };
}
return { answer: `Dạ em đã nhận câu hỏi "${question}" của anh/chị.`, confidence: 0.8 };
}
app.post('/chatveo/webhook', async (req, res) => {
if (!verify(req)) return res.status(401).send('bad signature');
const event = req.get('X-Chatveo-Event');
const delivery = req.get('X-Chatveo-Delivery'); // idempotency key
const { data } = req.body;
// Payload "Gửi thử" dùng id giả — chỉ xác nhận, không gọi API.
if (event === 'test') return res.json({ reply: 'Webhook hoạt động ✅' });
const conversationId = data.conversation.id;
// Tin nhắn chỉ có file thì content là "" — đừng đưa chuỗi rỗng vào LLM.
const file = data.message.metadata?.attachment;
const question = data.message.content
|| (file ? `[Khách gửi file: ${file.name} (${file.mimeType})] ${file.url}` : '');
console.log(`[${delivery}] ${data.visitor.channel} #${conversationId}: ${question}`);
// ---------- CÁCH A: trả lời đồng bộ nếu kịp trong 20s ----------
const quick = await Promise.race([
askMyAi({ question, history: data.history, site: data.site }),
new Promise((r) => setTimeout(() => r(null), 12_000)),
]);
if (quick?.answer) {
return res.json({ reply: quick.answer, confidence: quick.confidence });
}
if (quick?.handoff) {
return res.json({ handoff: true, handoffReason: quick.reason });
}
// ---------- CÁCH B: chưa kịp → 200 rỗng, trả lời sau ----------
res.status(200).end();
setTyping(data.urls.typing, 'start').catch(() => {});
const slow = await askMyAi({ question, history: data.history, site: data.site });
await sendReply(data.urls.reply, { // URL lấy từ payload, không hardcode host
content: slow.answer,
handoff: !!slow.handoff,
handoffReason: slow.reason,
confidence: slow.confidence,
metadata: { delivery },
});
});
/** POST câu trả lời vào ChatVeo, xử lý đầy đủ các mã lỗi. */
async function sendReply(replyUrl, body) {
const res = await fetch(replyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (res.ok) return json.data;
switch (json.code) {
case 'human_assigned': // 409 — nhân viên đã vào
case 'conversation_closed': // 409 — hội thoại đã đóng
console.log(`Dừng AI cho hội thoại này: ${json.code}`);
return null; // KHÔNG retry
case 'conversation_rate_limited': // 429 — quá 12 tin/phút/hội thoại
console.warn('Bị giới hạn tần suất, bỏ qua câu này.');
return null;
case 'invalid_api_key': // 401 — key sai/thu hồi/tích hợp đang tắt
console.error('API key không dùng được — kiểm tra dashboard.');
return null;
default:
console.error(`ChatVeo ${res.status}:`, json.error);
return null;
}
}
async function setTyping(typingUrl, state) {
await fetch(typingUrl, { // data.urls.typing
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({ state }),
});
}
app.listen(8080, () => console.log('ChatVeo AI webhook listening on :8080'));
9. Chuyển cho nhân viên & lỗi 409
Khi AI chủ động chuyển
Gửi handoff: true (kèm hoặc không kèm content) trên /reply, hoặc gọi /handoff.
Hội thoại chuyển sang trạng thái Chờ nhân viên (pending_handoff), nhân viên nhận thông báo push
(“Khách cần gặp nhân viên”) và hội thoại được làm nổi bật trong Hộp thư. Dùng khi AI không đủ tự tin, khách yêu cầu gặp người,
hoặc chủ đề nhạy cảm (hoàn tiền, khiếu nại, hợp đồng).
Khi nhân viên tự giành trước — HTTP 409 human_assigned
Nếu một nhân viên tiếp nhận hội thoại (trạng thái sang assigned), mọi lời gọi /reply sau đó
bị từ chối với:
HTTP/1.1 409 Conflict
{
"success": false,
"error": "Nhân viên đã tiếp nhận hội thoại này, AI không trả lời nữa",
"code": "human_assigned"
}
AI của bạn phải phản ứng thế nào:
- Dừng hẳn với hội thoại đó — coi 409 là trạng thái cuối, không phải lỗi tạm thời. Tuyệt đối không retry: người thật đang nói chuyện với khách, AI chen vào là tệ hơn việc mất câu trả lời.
- Huỷ mọi job/queue đang chờ trả lời cho hội thoại này, và tắt dấu “đang gõ” nếu bạn đã bật.
- Không coi đây là sự cố cần báo động — đây là hành vi thiết kế, xảy ra hàng ngày.
- Muốn tránh 409 ngay từ đầu: gọi
GET /conversations/:idvà kiểm traassignedToHumantrước khi gửi.
Sau khi handoff, AI còn được gọi nữa không? — Không.
Cùng cơ chế này bảo vệ ở tầng trên, và nó chặt hơn tầng /reply. ChatVeo chỉ gửi webhook AI khi hội thoại
đang ở open và chưa từng có nhân viên nào tiếp nhận. Nghĩa là:
| Trạng thái hội thoại | Khách nhắn tiếp → webhook AI? |
|---|---|
open, chưa ai nhận | Có — đây là trường hợp bình thường. |
pending_handoff (đã chuyển, chờ nhân viên) | Không. Ngay khi bạn gửi handoff, hội thoại rời khỏi phạm vi của AI. |
assigned (nhân viên đang xử lý) | Không. |
closed | Không. |
Quan trọng: không có đường quay lại. Một hội thoại đã handoff hoặc đã được nhận thì vĩnh viễn không sinh webhook AI nữa — kể cả sau khi nhân viên đóng nó, kể cả khi khách quay lại nhắn tiếp trong cùng hội thoại đó. Đây là lựa chọn thiết kế có chủ ý: “chuyển cho người thật” phải là một chiều, nếu không thì AI có thể nhảy vào giữa lúc nhân viên đang gõ dở. Không có API nào để “chuyển ngược về AI”.
conversationId khác, trạng thái open,
và AI của bạn phục vụ lại hoàn toàn bình thường. Việc “dừng” chỉ áp dụng cho đúng hội thoại đã handoff.
assigned”,
nên handoff (chỉ đưa sang pending_handoff) không thực sự chặn được webhook, và tài liệu này từng khuyên
AI tự kiểm tra data.conversation.status rồi im lặng. Lỗi đó đã được sửa ở phía ChatVeo — bạn không cần
workaround đó nữa. Nếu code của bạn đã có đoạn kiểm tra status === "pending_handoff" thì cứ giữ (nó vô hại và
là defense-in-depth tốt), nhưng đừng coi đó là bắt buộc.
10. Giới hạn, timeout, retry
| Hạng mục | Giá trị | Ghi chú |
|---|---|---|
| Timeout webhook | 20 giây | Quá hạn là request bị abort và tính là giao thất bại. |
| Retry webhook | Không có | Cố ý: một lần gọi duy nhất. Retry một endpoint chậm-nhưng-vẫn-chạy sẽ khiến cùng một câu trả lời được đăng nhiều lần cho khách. Endpoint của bạn nên nhận nhanh (200 rỗng) rồi xử lý nền. |
| Rate limit toàn API | 300 request / phút | Tính theo API key (không có key thì theo IP). Có header RateLimit-* chuẩn. Vượt → 429 rate_limited. |
| Rate limit tin nhắn AI | 12 tin / phút / hội thoại | Chống một key bị lộ làm ngập chat của khách. Vượt → 429 conversation_rate_limited. Là một ngân sách dùng chung — xem ghi chú dưới bảng. |
Độ dài content | 8000 ký tự | Dài hơn → 400 invalid_body. |
| Lịch sử gửi kèm webhook | 1–50 tin nhắn | Cấu hình ở mục 2 dashboard. Cần nhiều hơn thì gọi GET /conversations/:id?limit=100. |
| Dự phòng khi lỗi | fallbackBuiltin | Bật thì khi webhook của bạn lỗi/timeout, ChatVeo trả lời bằng AI nội bộ. Tắt thì khách không có câu trả lời tự động nào. |
| API key | hash SHA-256 | Chỉ hiện plaintext đúng một lần lúc tạo. Tạo key mới = key cũ chết ngay. |
HTTP 502, Timeout sau 20s) ở mục 2 — chỗ đầu tiên nên xem khi “AI im lặng”.
Ngân sách 12 tin/phút/hội thoại được tính thế nào
Bộ đếm đếm mọi tin nhắn role: "ai" trong hội thoại đó trong 60 giây vừa qua — không tách riêng
“AI của bạn” và “AI nội bộ ChatVeo”. Cụ thể:
- Tính vào ngân sách: câu trả lời qua
POST /reply, câu trả lời đồng bộ trong response webhook (Cách A), và câu trả lời của AI nội bộ ChatVeo nếufallbackBuiltinđang bật. - Bị chặn khi vượt: chỉ
POST /reply(trả429). Câu trả lời đồng bộ Cách A vốn tối đa một câu cho mỗi tin nhắn khách nên không cần chặn.
Dùng chung một ngân sách là có chủ ý, không phải thiếu sót: hai AI không bao giờ cùng nói. Khi site đã bật “AI qua API” thì AI nội bộ bị tắt hoàn toàn, chỉ chạy khi webhook của bạn thất bại — mà lúc đó bạn đã không đăng được tin nào để mà tranh ngân sách. Thứ cần giới hạn là tổng số tin nhắn máy mà khách phải nhận trong một phút, và đó đúng là thứ con số này đếm.
11. Checklist & xử lý sự cố
Checklist khi lên production
- Webhook URL là HTTPS công khai, chứng chỉ hợp lệ, không chặn IP của ChatVeo.
- Đã xác thực HMAC và kiểm tra lệch timestamp (≤ 5 phút) trên raw body — bước timestamp là của bạn, ChatVeo không làm thay (mục 5).
- Endpoint trả về nhanh; LLM chậm thì xử lý nền theo Cách B.
- Chống trùng bằng
X-Chatveo-Deliverynếu bạn có hàng đợi có thể gọi lại. - Xử lý
409 human_assignedlà dừng hẳn, không retry. - Gọi URL lấy từ
data.urlstrong payload, không hardcodehttps://api.chatveo.com. - Xử lý tin nhắn chỉ có file (
content: ""+metadata.attachment) — mục 4. cvsec_…vàcvai_…nằm trong secret manager, không commit vào repo.- Đã bấm Gửi thử thành công, và đã chat thử từ widget thật.
AI không trả lời khách — kiểm tra theo thứ tự
- Mục 1 dashboard: tích hợp đã bật chưa? Mục 2: đã lưu Webhook URL chưa? Thiếu một trong hai là tích hợp không chạy.
- Mục 2: xem lần giao gần nhất — thất bại kèm lỗi gì?
HTTP 401thường là bạn đang từ chối vì HMAC sai. - Hội thoại có còn ở trạng thái Mới (
open) không? Nếu đang là Chờ nhân viên (pending_handoff), Nhân viên phụ trách (assigned) hoặc Đã đóng (closed) thì webhook không được gửi — đúng như thiết kế (mục 9). Đây là nguyên nhân phổ biến nhất của “AI đột nhiên im từ giữa cuộc chat”: một lầnhandofftrước đó đã dừng AI cho hội thoại này vĩnh viễn. - Chữ ký sai: gần như luôn do tính HMAC trên body đã parse lại thay vì raw body.
401 invalid_api_keykhi gọi/reply: key sai, đã bị thu hồi, hoặc tích hợp đang tắt. GọiGET /međể xác nhận.404 conversation_not_found: đang dùng key của site khác, hoặc dùng id giảconv_testtừ payload “Gửi thử”.
siteKey, thời điểm, và
X-Chatveo-Delivery của lần giao gặp vấn đề để tra log nhanh nhất.