ChatVeo — Tài liệu kỹ thuật

AI qua API — Nối AI của công ty bạn vào ChatVeo

Dành cho lập trình viên. ChatVeo gửi tin nhắn của khách sang backend AI của bạn qua webhook đã ký HMAC; AI của bạn gọi REST API trả lời, tin nhắn hiện trong widget và Hộp thư y như nhân viên thật.

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

  1. Khách gửi tin nhắn (widget web, Telegram, Facebook, Zalo).
  2. ChatVeo lưu tin nhắn, phát cho các nhân viên đang online.
  3. ChatVeo kiểm tra trạng thái hội thoại. Chỉ gọi AI khi hội thoại đang open và 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.
  4. 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).
  5. 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ằng POST /api/v1/ai/conversations/:id/reply.
  6. 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.
  7. 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 /reply sau đó bị từ chối bằng HTTP 409 human_assigned — AI không thể nói chen vào người thật.
  8. 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.
AI trả lời được ghi nhận với 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.

  1. Đăng nhập app.chatveo.com, chọn đúng site ở đầu trang.
  2. Mở menu bên trái → 🔌 AI qua API.
  3. 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.
  4. 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ạng cvsec_…) — 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.
  5. 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.
  6. 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.
Hai bí mật, hai chiều, đừng nhầm:
• 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.
Tích hợp chỉ hoạt động khi cả hai điều kiện đúng: đã bật ở mục 1 và đã lưu Webhook URL ở mục 2. Nếu tích hợp bị tắt, API key cũng ngừng hoạt động (mọi lời gọi trả về 401).

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

HeaderGiá trị
X-Chatveo-Signaturesha256=<hex> — HMAC-SHA256 của chuỗi ${timestamp}.${rawBody}, khoá là signing secret của site.
X-Chatveo-TimestampUnix 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-Eventmessage.visitor (khách gửi tin nhắn thật) hoặc test (bấm “Gửi thử” trong dashboard).
X-Chatveo-DeliveryUUID 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

FieldKiểuÝ nghĩa
eventstring"message.visitor" | "test". Payload test dùng id giả (conv_test, msg_test) — đừng gọi /reply với nó.
siteKeystringSite 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.
timestampstring (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.namestringTên site, tiện đưa vào prompt.
data.site.aiPromptstring | nullPrompt 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.idstringId hội thoại — dùng cho tất cả lời gọi REST API sau đó.
data.conversation.statusstringopen | pending_handoff | assigned | closed (chữ thường).
data.conversation.tagIdsstring[]Nhãn đang gắn trên hội thoại.
data.visitor.channelstringwidget | telegram | facebook | zalo — nơi khách đang chat, để AI điều chỉnh độ dài/format câu trả lời.
data.visitor.metadataobject | nullDữ 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.totalConversationsnumberTổng số hội thoại khách này từng có (1 = khách mới hoàn toàn).
data.messageMessageTin nhắn vừa kích hoạt lần gọi này (role: "visitor").
data.historyMessage[]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.replyUrlstringURL 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.urlsobjectTấ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

FieldMethodEndpoint tương ứng
urls.replyPOSTGửi câu trả lời / chuyển nhân viên — /conversations/:id/reply
urls.handoffPOSTChuyển nhân viên mà không nói gì — /conversations/:id/handoff
urls.typingPOSTBật/tắt dấu “đang gõ” — /conversations/:id/typing
urls.conversationGETLấy lại ngữ cảnh hội thoại — /conversations/:id (thêm ?limit=… nếu cần)
urls.meGETKiể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

FieldKiểuÝ nghĩa
idstringId tin nhắn.
conversationIdstringHội thoại chứa tin nhắn.
rolestringvisitor (khách) | agent (nhân viên) | ai | system.
contentstringNội dung text.
senderNamestring | nullTên nhân viên gửi (chỉ có với role: "agent").
senderAvatarUrlstring | nullẢnh đại diện nhân viên.
metadataobject | nullTuỳ theo nguồn tin nhắn — xem bảng ngay dưới.
createdAtstring (ISO)Thời điểm tạo.

Các khoá có thể xuất hiện trong Message.metadata

KhoáKiểuXuất hiện khi
attachmentobjectKhá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.
confidencenumber 0..1Bạ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

FieldKiểuÝ nghĩa
urlstringURL 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.
namestringTê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.
mimeTypestringChỉ 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.
sizenumberDung 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"
}
Tin nhắn chỉ có file thì 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.
File đến chỉ từ widget web. Khách qua Telegram / Facebook / Zalo hiện chỉ gửi được text sang ChatVeo — ảnh họ gửi trên các kênh đó không tạo 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:

  1. Đọc raw body (chuỗi byte gốc, chưa qua JSON.parse rồi JSON.stringify lại — thứ tự key và khoảng trắng phải giữ nguyên tuyệt đối).
  2. Tính HMAC_SHA256(signingSecret, timestamp + "." + rawBody) với timestamp lấy nguyên văn từ header X-Chatveo-Timestamp, ra hex.
  3. So sánh với phần sau sha256= trong X-Chatveo-Signature bằ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.
Cửa sổ 5 phút do endpoint của BẠN thực thi, ChatVeo không làm thay được. Ở chiều này ChatVeo là bên gửi chữ ký, nên bên duy nhất có thể từ chối một bản giao đã cũ là bên nhận. Nếu bạn bỏ bước kiểm tra timestamp ở bước 3, một request bị bắt được có thể bị phát lại về endpoint của bạn vô hạn lần và chữ ký vẫn hợp lệ — vì chữ ký chỉ chứng minh “ChatVeo đã tạo payload này”, không chứng minh “vừa mới gửi”. Chính vì timestamp nằm trong chuỗi được ký nên kẻ tấn công không thể làm mới nó, và phép so sánh của bạn mới có giá trị. 5 phút là con số ChatVeo dùng trong tài liệu và sample code — chọn hẹp hơn cũng được, miễn là chịu được lệch đồng hồ giữa hai máy chủ.
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);
}
Lỗi thường gặp nhất: dùng body đã được framework parse thành object rồi stringify lại để tính HMAC → chữ ký luôn sai. Với Express phải lấy raw body, ví dụ express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }) hoặc express.raw({ type: 'application/json' }).
Muốn xoay signing secret: bấm tạo lại ở mục 2 trong dashboard, rồi cập nhật biến môi trường phía bạn. Trong lúc chuyển đổi, endpoint của bạn có thể tạm chấp nhận cả secret cũ và mới.

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à:

Vì vậy, thứ cần bảo vệ nghiêm ngặt nhất ở chiều này là bản thân API key: giữ trong secret manager, không đưa vào code phía client / app mobile, không log ra, và thu hồi ngay ở dashboard nếu nghi ngờ bị lộ.

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
}
FieldKiểuÝ nghĩa
replystring (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.
handoffboolean (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.
handoffReasonstring (tuỳ chọn)Lý do chuyển, hiện cho nhân viên.
confidencenumber 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.
Phải trả về trong 20 giây (timeout dispatch của ChatVeo). LLM chậm hơn thế thì bắt buộc dùng Cách B.

Cách B — trả lời sau qua REST API

  1. 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õ”).
  2. Tuỳ chọn: gọi POST /api/v1/ai/conversations/:id/typing với {"state":"start"} để khách thấy dấu “đang gõ” trong lúc AI suy nghĩ.
  3. Khi có câu trả lời, gọi POST /api/v1/ai/conversations/:id/reply (hoặc chính data.replyUrl) với Authorization: Bearer cvai_….
Cả hai cách có thể dùng lẫn: trả lời nhanh một câu “Em kiểm tra giúp anh/chị ngay” ở Cách A, rồi bổ sung kết quả chi tiết bằng Cách B.

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>

Base URL ở trên chỉ để bạn đọc tài liệu và thử bằng 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

FieldKiểuRàng buộc
contentstringTuỳ chọn. Sau khi trim phải ≥ 1 ký tự, tối đa 8000 ký tự.
handoffbooleanTuỳ chọn. true = chuyển cho nhân viên.
handoffReasonstringTuỳ chọn, tối đa 200 ký tự.
confidencenumberTuỳ chọn, trong khoảng 0–1.
metadataobjectTuỳ 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

HTTPcodeNguyên nhân & cách xử lý
400invalid_bodyBody sai schema (content quá 8000 ký tự, confidence ngoài 0–1…). Sửa request, không retry nguyên trạng.
400empty_replyThiếu cả content và handoff. Lỗi logic phía bạn.
401missing_api_keyThiếu header Authorization: Bearer ….
401invalid_api_keyKey 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.
404conversation_not_foundKhô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.
409human_assignedNhâ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.
409conversation_closedHội thoại đã đóng. Không retry.
429conversation_rate_limitedVượ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.
429rate_limitedVượ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'));
Kiểm tra nhanh sau khi deploy: bấm Gửi thử ở mục 2 trong dashboard (không cần khách thật), rồi chat thử từ widget trên website của bạn.

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:

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ạiKhách nhắn tiếp → webhook AI?
open, chưa ai nhậnCó — đâ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.
closedKhô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”.

Khách quay lại lần sau (phiên chat mới) sẽ tạo hội thoại mới với 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.
Đây là thay đổi so với bản tài liệu đầu tiên. Trước đây điều kiện gửi webhook là “trạng thái khác 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ụcGiá trịGhi chú
Timeout webhook20 giâyQuá hạn là request bị abort và tính là giao thất bại.
Retry webhookKhô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 API300 request / phútTí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 AI12 tin / phút / hội thoạiChố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 content8000 ký tựDài hơn → 400 invalid_body.
Lịch sử gửi kèm webhook1–50 tin nhắnCấ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ỗifallbackBuiltinBậ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 keyhash SHA-256Chỉ hiện plaintext đúng một lần lúc tạo. Tạo key mới = key cũ chết ngay.
Vì không có retry, mọi log giao webhook đều quan trọng. Dashboard hiển thị lần giao gần nhất kèm thông báo lỗi nguyên văn (ví dụ 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ể:

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

AI không trả lời khách — kiểm tra theo thứ tự

  1. 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.
  2. Mục 2: xem lần giao gần nhất — thất bại kèm lỗi gì? HTTP 401 thường là bạn đang từ chối vì HMAC sai.
  3. 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ần handoff trước đó đã dừng AI cho hội thoại này vĩnh viễn.
  4. Chữ ký sai: gần như luôn do tính HMAC trên body đã parse lại thay vì raw body.
  5. 401 invalid_api_key khi gọi /reply: key sai, đã bị thu hồi, hoặc tích hợp đang tắt. Gọi GET /me để xác nhận.
  6. 404 conversation_not_found: đang dùng key của site khác, hoặc dùng id giả conv_test từ payload “Gửi thử”.
Cần hỗ trợ: [email protected] — gửi kèm siteKey, thời điểm, và X-Chatveo-Delivery của lần giao gặp vấn đề để tra log nhanh nhất.