# Tài liệu cho lập trình viên Hạ Tầng > Hạ Tầng (Ha Tang) là nền tảng chăm sóc khách hàng cho doanh nghiệp Việt Nam: một hộp thư chung cho chat website, ứng dụng di động, Zalo, Messenger, Instagram, TikTok Shop, WhatsApp và email, có trợ lý AI, cùng API gửi email giao dịch, thanh toán VietQR và hóa đơn điện tử. Đây là hướng dẫn và tài liệu tham chiếu API. --- # Contacts, segments và topics Trước khi gửi chiến dịch email, bạn cần biết gửi cho ai. Hạ Tầng tổ chức người nhận giống cách Resend đang làm: - **Contact**: mỗi địa chỉ email là một contact duy nhất trong doanh nghiệp (không phân biệt hoa thường), có họ tên, **thuộc tính tuỳ chỉnh** và cờ "huỷ đăng ký mọi email". - **Segment**: nhóm contact cố định để gửi chiến dịch email. Một contact thuộc được nhiều segment; xoá segment không xoá contact. Trước đây segment gọi là *audience*, và API `/audiences` vẫn chạy. - **Topic**: loại email, như khuyến mãi hay bản tin hằng tuần, mà từng người nhận tự chọn nhận hoặc bỏ trên trang tuỳ chọn. Trong dashboard, mọi thứ nằm ở **Email → Danh sách nhận**, trong các tab **Contacts**, **Segments**, **Topics**, **Thuộc tính** và **Danh sách chặn**. ## Trước khi bắt đầu Các endpoint contact, segment, topic và thuộc tính cần key có `email:write`. ## Thêm contact **cURL** ```bash curl -X POST https://api.hatang.dev/contacts \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "email": "lan@example.vn", "first_name": "Lan", "last_name": "Trần", "properties": { "plan": "pro" }, "segments": [{ "id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10" }], "topics": [{ "id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", "subscription": "opt_in" }] }' # { "object": "contact", "id": "con_…" } ``` **Node.js** ```ts const { data, error } = await hatang.contacts.create({ email: "lan@example.vn", first_name: "Lan", last_name: "Trần", properties: { plan: "pro" }, segments: [{ id: "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10" }], topics: [{ id: "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", subscription: "opt_in" }], }); ``` **Python** ```python requests.post( "https://api.hatang.dev/contacts", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={ "email": "lan@example.vn", "first_name": "Lan", "properties": {"plan": "pro"}, "segments": [{"id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10"}], }, ) ``` **PHP** ```php $client->post('/contacts', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'email' => 'lan@example.vn', 'first_name' => 'Lan', 'properties' => ['plan' => 'pro'], 'segments' => [['id' => 'aud_0199e6c17a2b7f009c1e3f6a2b1d9e10']], ], ]); ``` | Trường | Mô tả | |---|---| | `email` | Bắt buộc. Không trùng trong doanh nghiệp. | | `first_name`, `last_name` | Mỗi trường tối đa 100 ký tự. | | `unsubscribed` | `true` huỷ đăng ký mọi email cho contact. | | `properties` | Giá trị của các thuộc tính đã khai báo. `null` xoá một giá trị. | | `segments` | Tối đa 50 `{ "id" }` để thêm contact vào. | | `topics` | Tối đa 50 cặp `{ "id", "subscription" }`, trong đó `subscription` là `opt_in` hoặc `opt_out`. | `POST /contacts` tạo contact, hoặc cập nhật nếu địa chỉ đã tồn tại, và trả về id. Webhook `contact.created`, `contact.updated` và `contact.deleted` bắn khi contact thay đổi. Contact mới cũng khởi động các automation dùng sự kiện `hatang:contact.created`; xem [Automations](https://hatang.dev/vi/docs/guides/email/automations.md). Trong dashboard bạn cũng có thể **Thêm liên hệ** từng người hoặc **Nhập CSV**. Dòng đầu của CSV là tiêu đề cột, gồm các cột tuỳ chọn: `email`, `first_name`, `last_name`, `unsubscribed` và khoá các thuộc tính của bạn. Cột lạ bị bỏ qua, địa chỉ đã có thì được cập nhật. Khách hàng trong CRM cũng đồng bộ sang được (**Khách hàng → Đồng bộ sang Email**); đồng bộ không bao giờ đăng ký lại cho người đã huỷ đăng ký. ## Thuộc tính tuỳ chỉnh Khai báo thuộc tính trước khi gán cho contact. Thuộc tính có kiểu `string` hoặc `number`, kèm giá trị dự phòng tuỳ chọn: ```bash curl -X POST https://api.hatang.dev/contact-properties \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "key": "plan", "type": "string", "fallback_value": "free" }' ``` - Khoá gồm chữ, số và `_`, tối đa 50 ký tự. Giá trị chuỗi tối đa 2.000 ký tự. - Gán thuộc tính chưa khai báo trả về `422`. Thuộc tính `number` từ chối giá trị không phải số. - Về sau chỉ đổi được giá trị dự phòng (`PATCH /contact-properties/{id}`). Xoá thuộc tính sẽ xoá giá trị đó khỏi mọi contact. Dùng thuộc tính trong chiến dịch email và email của automation dạng `{{{contact.plan}}}`, cùng với `{{{contact.first_name}}}`, `{{{contact.last_name}}}` và `{{{contact.email}}}`. Contact chưa có giá trị thì lấy giá trị mặc định tại chỗ (`{{{contact.plan|free}}}`), rồi tới giá trị dự phòng của thuộc tính. ## Segments ```bash curl -X POST https://api.hatang.dev/segments \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Khách hàng 2026" }' # { "object": "segment", "id": "aud_…", "name": "Khách hàng 2026", "created_at": "…" } curl -X POST https://api.hatang.dev/contacts/lan@example.vn/segments/aud_0199e6c17a2b7f009c1e3f6a2b1d9e10 \ -H "Authorization: Bearer $HATANG_API_KEY" ``` Tên segment tối đa 100 ký tự. Id segment bắt đầu bằng `aud_`, vì segment chính là audience trước đây. ## Topics và trang tuỳ chọn ```bash curl -X POST https://api.hatang.dev/topics \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Khuyến mãi", "description": "Giảm giá và mã ưu đãi", "default_subscription": "opt_in", "visibility": "public" }' ``` | Trường | Mô tả | |---|---| | `name` | Tối đa 50 ký tự. | | `description` | Tối đa 200 ký tự, hiện trên trang tuỳ chọn. | | `default_subscription` | `opt_in`: contact nhận cho tới khi tự bỏ. `opt_out`: contact chỉ nhận sau khi tự đăng ký. Không đổi được sau khi tạo. | | `visibility` | `public` hiện topic cho mọi người trên trang tuỳ chọn. `private` (mặc định) chỉ hiện với contact đang nhận topic đó. | Topic lọc thư như sau: - Chiến dịch email có `topic_id` chỉ tới những contact đang nhận topic đó. - `POST /emails` có `topic_id` bỏ qua người nhận đã rời topic; họ được ghi nhận là bị chặn (suppressed). Địa chỉ chưa là contact thì theo giá trị mặc định của topic. - Lựa chọn của một contact xem ở `GET /contacts/{id}/topics` và đổi bằng `PATCH /contacts/{id}/topics`: ```bash curl -X PATCH https://api.hatang.dev/contacts/lan@example.vn/topics \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '[{ "id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", "subscription": "opt_out" }]' ``` Link `{{{UNSUBSCRIBE_URL}}}` trong chiến dịch email mở **trang tuỳ chọn**, nơi người nhận bật tắt từng topic hoặc huỷ đăng ký mọi email. Chỉ mở trang thì không thay đổi gì, nên các bộ quét link không vô tình huỷ đăng ký ai. Trang hiển thị tiếng Anh nếu trình duyệt ưu tiên tiếng Anh, còn lại là tiếng Việt. Đặt tiêu đề, mô tả, logo và màu ở **Cài đặt email → Trang huỷ đăng ký**. Cách huỷ đăng ký một chạm hoạt động xem ở [Chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md#unsubscribe). ## Tham chiếu API | Endpoint | Mô tả | |---|---| | `POST /contacts`, `GET /contacts` | Tạo hoặc cập nhật contact; liệt kê contact (`limit`, `after`, `segment_id`, `q`). | | `GET`, `PATCH`, `DELETE /contacts/{id hoặc email}` | Một contact. | | `GET /contacts/{id}/segments`, `POST` hoặc `DELETE /contacts/{id}/segments/{segment_id}` | Thành viên segment. | | `GET`, `PATCH /contacts/{id}/topics` | Lựa chọn topic. | | `POST /segments`, `GET /segments`, `GET`, `PATCH`, `DELETE /segments/{id}`, `GET /segments/{id}/contacts` | Segment. `GET /segments/{id}` có thêm `contact_count`. | | `POST /topics`, `GET /topics`, `GET`, `PATCH`, `DELETE /topics/{id}` | Topic. | | `POST /contact-properties`, `GET /contact-properties`, `PATCH`, `DELETE /contact-properties/{id}` | Thuộc tính. | | `/audiences`, `/audiences/{id}/contacts` | API audience cũ, giữ lại cho các client Resend và Hạ Tầng đang dùng. Một audience là một segment. | SDK Node.js có các lệnh tương ứng trong `hatang.contacts`, `hatang.segments`, `hatang.topics`, `hatang.contactProperties` và `hatang.audiences`. ## Bước tiếp theo - [Chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md) - [Automations](https://hatang.dev/vi/docs/guides/email/automations.md) - [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md) Source: https://hatang.dev/vi/docs/guides/email/audiences --- # Automations Automation là chuỗi bước chạy cho một contact khi có chuyện xảy ra trong ứng dụng của bạn: gửi thư chào mừng khi khách đăng ký, cảm ơn sau khi thanh toán, nhắc lại nếu ba ngày sau khách vẫn chưa để lại đánh giá. Ứng dụng của bạn gửi sự kiện, Hạ Tầng chạy các bước. API giống Resend Automations. ## Trước khi bắt đầu - Các [template](https://hatang.dev/vi/docs/guides/email/templates.md) đã publish cho những email mà automation gửi, với địa chỉ `From` trên tên miền đã xác minh. - Nếu dùng API, key có `email:write`. ## 1. Khai báo sự kiện Automation bắt đầu từ một **sự kiện**. Khai báo sự kiện ở **Email → Automations → Sự kiện** hoặc bằng API. Schema không bắt buộc; nếu có, Hạ Tầng kiểm tra và chuyển kiểu các trường của payload (`string`, `number`, `boolean`, `date`): **cURL** ```bash curl -X POST https://api.hatang.dev/events \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "order.paid", "schema": { "amount": "number", "order_id": "string" } }' ``` **Node.js** ```ts await hatang.events.create({ name: "order.paid", schema: { amount: "number", order_id: "string" } }); ``` **Python** ```python requests.post( "https://api.hatang.dev/events", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"name": "order.paid", "schema": {"amount": "number", "order_id": "string"}}, ) ``` **PHP** ```php $client->post('/events', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['name' => 'order.paid', 'schema' => ['amount' => 'number', 'order_id' => 'string']], ]); ``` - Tên sự kiện gồm chữ, số và `.`, `_`, `:`, `-`, tối đa 100 ký tự. - Tên bắt đầu bằng `hatang:` hoặc `resend:` được dành cho sự kiện có sẵn. Hiện có một sự kiện: `hatang:contact.created`, phát ra khi một contact được tạo qua API, dashboard hoặc nhập CSV. ## 2. Gửi sự kiện từ ứng dụng Gửi sự kiện cho một contact theo `contact_id`, hoặc theo `email`. Với `email`, Hạ Tầng tạo contact nếu chưa có. **cURL** ```bash curl -X POST https://api.hatang.dev/events/send \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "event": "order.paid", "email": "lan@example.vn", "payload": { "amount": 2000000, "order_id": "DH1024" } }' ``` **Node.js** ```ts const { data } = await hatang.events.send({ event: "order.paid", email: "lan@example.vn", payload: { amount: 2000000, order_id: "DH1024" }, }); console.log(data.runs_started, data.runs_resumed); ``` **Python** ```python hatang.events.send("order.paid", email="lan@example.vn", payload={"amount": 2000000, "order_id": "DH1024"}) ``` **PHP** ```php $client->post('/events/send', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['event' => 'order.paid', 'email' => 'lan@example.vn', 'payload' => ['amount' => 2000000, 'order_id' => 'DH1024']], ]); ``` Kết quả trả về `202`: ```json { "object": "event", "event": "order.paid", "contact_id": "con_0199e6c17a2b7f009c1e3f6a2b1d9e10", "runs_started": 1, "runs_resumed": 0 } ``` `runs_started` là số automation đang bật mà sự kiện này khởi động cho contact; `runs_resumed` là số lượt chạy đang chờ sự kiện này và được chạy tiếp. | Lỗi | Nguyên nhân | |---|---| | `422` "Unknown event" | Sự kiện chưa được khai báo. Tạo bằng `POST /events` trước. | | `422` "must be a number" (hoặc boolean, date) | Một trường của payload không khớp schema. Trường không có trong schema được giữ nguyên. | | `422` "Pass contact_id or email" | Request không chỉ định contact nào. | ## 3. Dựng luồng Mở **Email → Automations**, tạo automation rồi: 1. Chọn sự kiện khởi động (**Bắt đầu từ một sự kiện**). 2. Bấm **+** dưới một bước để thêm bước tiếp theo. 3. Cấu hình bước đang chọn ở khung bên phải. 4. **Lưu**, rồi **Bật**. | Bước | Cấu hình | Nhánh ra | |---|---|---| | `trigger` | `event_name` | | | `send_email` | `template.id` (template **đã publish**), `from` và `subject` tuỳ chọn để ghi đè template, `template.variables` | | | `delay` | `duration`, ví dụ `"30 minutes"`, `"2 days"`, `"1 week"`. Tối đa 30 ngày. | | | `wait_for_event` | `event_name`, `timeout` (mặc định `"30 days"`), `filter_rule` tuỳ chọn trên sự kiện tới | `event_received`, `timeout` | | `condition` | `rule` | `condition_met`, `condition_not_met` | | `update_contact` | `properties` (thuộc tính đã khai báo), `unsubscribed` | | | `add_to_segment` | `segment_id` | | Khi bước `send_email` chạy: - Biến của template lấy lần lượt từ `template.variables` của bước, rồi payload của sự kiện, rồi thông tin contact (`contact.first_name`, `contact.`…). - `{{{UNSUBSCRIBE_URL}}}` trỏ tới trang tuỳ chọn của contact. - Contact đã huỷ đăng ký mọi email thì được bỏ qua (bước được đánh dấu `skipped`). - Mỗi email có tag `automation_id` và hiện ở **Email → Email** như mọi email khác. Hạ Tầng không cho bật automation thiếu trigger, dùng template chưa publish, hoặc có bước `send_email` không có địa chỉ `From` ở cả bước lẫn template. ### Điều kiện Một `rule` so sánh một trường với một giá trị: ```json { "field": "event.amount", "operator": "gte", "value": 1000000 } ``` - **Trường**: - `event.*`: payload của sự kiện khởi động lượt chạy. - `contact.*`: `email`, `first_name`, `last_name`, `unsubscribed` và các thuộc tính tuỳ chỉnh. - `wait_events..*`: payload mà bước `wait_for_event` đã nhận. - **Toán tử**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`, `ends_with`, `exists`, `is_empty`. - **So sánh với trường khác**: `"value": { "var": "contact.limit" }`. - **Ghép nhiều điều kiện**: `{ "type": "and", "rules": [ … ] }` hoặc `{ "type": "or", "rules": [ … ] }`. `filter_rule` của `wait_for_event` có cùng cấu trúc, trong đó `event.*` là sự kiện vừa tới. Lượt chạy đang chờ chỉ tiếp tục khi sự kiện qua được bộ lọc. ### Tạo bằng API ```bash curl -X POST https://api.hatang.dev/automations \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Sau khi thanh toán", "status": "enabled", "steps": [ { "key": "start", "type": "trigger", "config": { "event_name": "order.paid" } }, { "key": "thanks", "type": "send_email", "config": { "template": { "id": "order-confirmed" } } }, { "key": "big", "type": "condition", "config": { "rule": { "field": "event.amount", "operator": "gte", "value": 1000000 } } }, { "key": "vip", "type": "add_to_segment", "config": { "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10" } }, { "key": "review", "type": "wait_for_event", "config": { "event_name": "review.left", "timeout": "3 days" } }, { "key": "remind", "type": "send_email", "config": { "template": { "id": "review-reminder" } } } ], "connections": [ { "from": "start", "to": "thanks" }, { "from": "thanks", "to": "big" }, { "from": "big", "to": "vip", "type": "condition_met" }, { "from": "big", "to": "review", "type": "condition_not_met" }, { "from": "review", "to": "remind", "type": "timeout" } ] }' # { "object": "automation", "id": "auto_…" } ``` - Mỗi automation có đúng một bước `trigger`, tối đa 100 bước và 200 kết nối. - Khoá của bước không trùng nhau, gồm chữ, số, `-` và `_`, tối đa 64 ký tự. - Không bước nào được dẫn ngược về trigger. ## Bật, sửa, dừng - Automation **đang bật** thì không sửa được các bước. Tắt đi để sửa, hoặc **Nhân bản** thành automation mới. - Lượt chạy giữ phiên bản automation lúc nó bắt đầu, kể cả khi bạn sửa automation sau đó. - `POST /automations/{id}/stop` tắt automation và huỷ mọi lượt chạy chưa xong. - `POST /automations/{id}/duplicate` tạo một bản sao ở trạng thái tắt. ## Lượt chạy Mỗi lần một contact đi vào automation, Hạ Tầng tạo một **lượt chạy**. Tab **Lượt chạy** liệt kê theo trạng thái: | Trạng thái | Ý nghĩa | |---|---| | `running` | Đang chạy các bước. | | `waiting` | Đang chờ hết `delay` hoặc chờ một sự kiện. | | `completed` | Đã đi hết luồng. | | `failed` | Một bước bị lỗi; lượt chạy hiện thông báo lỗi. | | `cancelled` | Bị dừng, ví dụ bởi `POST /automations/{id}/stop`. | Mở một lượt chạy để xem từng bước: chạy lúc nào, kết quả (ví dụ id của email đã gửi) và lỗi nếu có. ```bash curl "https://api.hatang.dev/automations/auto_…/runs?status=failed,waiting" -H "Authorization: Bearer $HATANG_API_KEY" curl "https://api.hatang.dev/automations/auto_…/runs/arun_…" -H "Authorization: Bearer $HATANG_API_KEY" ``` Trang dựng luồng có ô **Gửi sự kiện thử**: nhập địa chỉ email và payload để chạy thử một automation đang bật cho contact đó. ## SDK ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); await hatang.events.create({ name: "order.paid", schema: { amount: "number" } }); await hatang.events.send({ event: "order.paid", email: "lan@example.vn", payload: { amount: 2000000 } }); const { data } = await hatang.automations.runs.list("auto_…", { status: "failed" }); await hatang.automations.stop("auto_…"); ``` ## Tham chiếu API | Endpoint | Mô tả | |---|---| | `POST /events`, `GET /events`, `GET`, `PATCH`, `DELETE /events/{id hoặc tên}` | Khai báo sự kiện. `PATCH` đổi schema. | | `POST /events/send` | Gửi sự kiện cho một contact. | | `POST /automations`, `GET /automations`, `GET`, `PATCH`, `DELETE /automations/{id}` | Automation. | | `POST /automations/{id}/stop`, `POST /automations/{id}/duplicate` | Dừng, nhân bản. | | `GET /automations/{id}/runs`, `GET /automations/{id}/runs/{run_id}` | Lượt chạy (`status`, `limit`, `after`) và một lượt chạy kèm các bước. | ## Bước tiếp theo - [Templates](https://hatang.dev/vi/docs/guides/email/templates.md) - [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md) - [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/vi/docs/guides/email/automations --- # Chiến dịch email Chiến dịch email (broadcast) là email marketing, như bản tin, chương trình giảm giá hay thông báo sản phẩm mới, gửi tới mọi contact của một segment. Hạ Tầng tách chiến dịch thành mỗi contact một email, điền tên và thuộc tính của từng người, bỏ qua người đã huỷ đăng ký, rồi thêm link huỷ đăng ký và header `List-Unsubscribe` một chạm. ## Trước khi bắt đầu - Một tên miền đã xác minh cho địa chỉ `From`. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). - Một segment có contact, và các topic nếu muốn người nhận tự chọn loại thư. Xem [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md). - Nếu dùng API, key có `email:write`. ## Chiến dịch chọn người nhận thế nào Một contact nhận chiến dịch khi thoả cả bốn điều kiện: 1. Contact thuộc segment của chiến dịch. 2. Contact chưa huỷ đăng ký mọi email. 3. Nếu chiến dịch có topic: contact đang nhận topic đó. Với topic `opt_in`, nghĩa là contact chưa rời topic; với topic `opt_out`, contact phải đã tự đăng ký. 4. Địa chỉ không nằm trong danh sách chặn (trả lại cứng, báo spam, chặn thủ công). Địa chỉ bị chặn được bỏ qua và báo là bị trả lại. Xem [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md). ## Soạn Trong dashboard, mở **Email → Chiến dịch email → Chiến dịch email mới**: - **Segment**: ai nhận. **Topic**: không bắt buộc; **Không gắn topic (mọi người đang nhận)** gửi cho mọi contact đang nhận trong segment. - **Tên nội bộ**, **Người gửi** và **Tiêu đề**. - **Bắt đầu từ template** chép HTML, văn bản, người gửi và tiêu đề của một template đã publish vào chiến dịch. - Nội dung HTML và bản **Văn bản thuần (không bắt buộc)**, kèm **Xem trước**. Lưu bằng **Tạo nháp**. Chiến dịch sửa được khi còn là bản nháp hoặc đang hẹn giờ. ### Thẻ trộn Thẻ trộn được điền cho từng contact: | Thẻ | Giá trị | |---|---| | `{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}` | Tên và địa chỉ của contact. | | `{{{contact.first_name}}}`, `{{{contact.plan}}}`… | Trường của contact và thuộc tính tuỳ chỉnh. | | `{{{UNSUBSCRIBE_URL}}}` | Trang tuỳ chọn của contact. `{{{RESEND_UNSUBSCRIBE_URL}}}` cũng dùng được. | Thêm giá trị mặc định sau dấu `|`: `Chào {{{FIRST_NAME|bạn}}}`. Thuộc tính chưa có giá trị thì lấy giá trị dự phòng. Thẻ không có giá trị nào sẽ thành chuỗi rỗng; không làm dừng việc gửi. Giá trị được escape trong phần HTML. ```html

Chào {{{FIRST_NAME|bạn}}},

Gói {{{contact.plan|free}}} của bạn giờ có thêm 10 GB lưu trữ.

Huỷ đăng ký hoặc chọn loại email muốn nhận

``` ### Tạo bằng API **cURL** ```bash curl -X POST https://api.hatang.dev/broadcasts \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Bản tin tháng 10", "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10", "topic_id": "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", "from": "Shop ", "subject": "Có gì mới tháng 10, {{{FIRST_NAME|bạn}}}", "html": "

Chào {{{FIRST_NAME|bạn}}}, …

Huỷ đăng ký

" }' # { "object": "broadcast", "id": "bc_…" } ``` **Node.js** ```ts const { data, error } = await hatang.broadcasts.create({ name: "Bản tin tháng 10", segment_id: "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10", topic_id: "topic_0199e6c17a2c7c418f0e61f1b2a3c4d5", from: "Shop ", subject: "Có gì mới tháng 10, {{{FIRST_NAME|bạn}}}", html: '

Chào {{{FIRST_NAME|bạn}}}, …

Huỷ đăng ký

', }); ``` **Python** ```python res = requests.post( "https://api.hatang.dev/broadcasts", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={ "name": "Bản tin tháng 10", "segment_id": "aud_0199e6c17a2b7f009c1e3f6a2b1d9e10", "from": "Shop ", "subject": "Có gì mới tháng 10", "html": '

Chào {{{FIRST_NAME|bạn}}}, …

Huỷ đăng ký

', }, ) broadcast_id = res.json()["id"] ``` **PHP** ```php $res = $client->post('/broadcasts', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'name' => 'Bản tin tháng 10', 'segment_id' => 'aud_0199e6c17a2b7f009c1e3f6a2b1d9e10', 'from' => 'Shop ', 'subject' => 'Có gì mới tháng 10', 'html' => '

Chào {{{FIRST_NAME|bạn}}}, …

Huỷ đăng ký

', ], ]); $broadcastId = json_decode($res->getBody(), true)['id']; ``` | Trường | Mô tả | |---|---| | `segment_id` | Bắt buộc. Segment nhận chiến dịch. `audience_id` là tên cũ, vẫn dùng được. | | `topic_id` | Không bắt buộc. Chỉ gửi cho contact đang nhận topic này. | | `template_id` | Không bắt buộc. Chép `html`, `text`, `from`, `subject` và `reply_to` từ template đã publish này. Trường bạn tự gửi được ưu tiên. | | `from` | Bắt buộc, trừ khi template đã có. | | `subject` | Bắt buộc, trừ khi template đã có. Tối đa 998 ký tự. | | `reply_to` | Một địa chỉ hoặc một danh sách. | | `html`, `text` | Nội dung. Phải có ít nhất một trong hai trước khi gửi. | | `name` | Tên nội bộ, tối đa 200 ký tự. Mặc định là tiêu đề. | Template được chép lúc tạo chiến dịch; publish phiên bản template mới sau đó không làm thay đổi chiến dịch. ## Gửi hoặc hẹn giờ Trong trình soạn, ô **Gửi** nhận `now`, một thời điểm ISO hoặc một cụm như `in 2 hours`. Lưu thay đổi trước khi bấm **Gửi chiến dịch**. Bằng API: **cURL** ```bash # Gửi ngay curl -X POST https://api.hatang.dev/broadcasts/$BROADCAST_ID/send -H "Authorization: Bearer $HATANG_API_KEY" # Hẹn giờ curl -X POST https://api.hatang.dev/broadcasts/$BROADCAST_ID/send \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-10-20T09:00:00+07:00" }' # { "object": "broadcast", "id": "bc_…", "status": "scheduled" } ``` **Node.js** ```ts await hatang.broadcasts.send(broadcastId); // gửi ngay await hatang.broadcasts.send(broadcastId, { scheduledAt: "in 2 hours" }); // hẹn giờ ``` **Python** ```python headers = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"} requests.post(f"https://api.hatang.dev/broadcasts/{broadcast_id}/send", headers=headers, json={"scheduled_at": "in 2 hours"}) ``` **PHP** ```php $client->post("/broadcasts/$broadcastId/send", [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['scheduled_at' => 'in 2 hours'], ]); ``` `scheduled_at` nhận các giá trị giống email đơn lẻ: thời điểm ISO 8601 trong vòng 30 ngày tới, hoặc `in N minutes|hours|days`. Gửi lại một chiến dịch đang hẹn giờ sẽ thay lịch cũ. `POST /broadcasts/{id}/cancel` dừng chiến dịch đang hẹn giờ hoặc còn trong hàng đợi. Chiến dịch đang gửi thì không huỷ hay xoá được. | Trạng thái | Ý nghĩa | |---|---| | `draft` | Chưa gửi. Sửa được. | | `scheduled` | Đang chờ tới giờ. Vẫn sửa hoặc huỷ được. | | `queued` | Sắp bắt đầu. | | `sending` | Đang tạo email cho từng contact. | | `sent` | Đã xử lý xong mọi người nhận. | | `canceled` | Đã huỷ trước khi bắt đầu. | `GET /broadcasts/{id}` cho biết `total` (số contact được chọn) và `sent_count` (số email đã tạo). Mỗi email của chiến dịch là một email bình thường có tag `broadcast_id`, nên bạn theo dõi được ở **Email → Email** (lọc **Chiến dịch email**), qua `GET /emails?source=broadcast` và các webhook quen thuộc. Nếu việc gửi bị gián đoạn, Hạ Tầng tiếp tục từ chỗ dừng và không bao giờ gửi cùng một chiến dịch hai lần cho một contact. > **Caution: Hạn mức** > > Mỗi email của chiến dịch đều tính vào hạn mức ngày. Chiến dịch gửi cho nhiều contact hơn hạn mức còn lại sẽ dừng gửi khi hết hạn mức; các contact còn lại được đánh dấu lỗi. Xem **Cài đặt email** trước khi gửi số lượng lớn. ## Huỷ đăng ký {#unsubscribe} Mỗi email của chiến dịch có hai lối thoát: - Link `{{{UNSUBSCRIBE_URL}}}` trong nội dung mở trang tuỳ chọn, nơi contact chọn topic hoặc huỷ đăng ký mọi email. Chỉ mở trang thì không thay đổi gì. - Header `List-Unsubscribe` cùng `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058), để Gmail, Apple Mail và các ứng dụng khác hiện nút huỷ đăng ký của riêng chúng. Một chạm sẽ gỡ contact khỏi topic của chiến dịch, hoặc khỏi mọi email nếu chiến dịch không gắn topic. Hạ Tầng tự thêm header. Hãy đặt cả link trong nội dung: một số ứng dụng email không hiện nút, và pháp luật nhiều nước yêu cầu cách huỷ đăng ký phải nhìn thấy được. Khi contact huỷ đăng ký mọi email, họ không còn nhận chiến dịch email và email của automation, đồng thời địa chỉ được đưa vào danh sách chặn với lý do `unsubscribe`, chặn luôn các email khác gửi tới địa chỉ đó. Xem [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md). Đổi giao diện trang tuỳ chọn ở **Cài đặt email → Trang huỷ đăng ký**: tiêu đề, mô tả, logo và màu. ## Tham chiếu API | Endpoint | Mô tả | |---|---| | `POST /broadcasts` | Tạo bản nháp. Trả về `201` kèm id. | | `GET /broadcasts`, `GET /broadcasts/{id}` | Liệt kê, hoặc xem một chiến dịch kèm `status`, `total`, `sent_count`, `scheduled_at` và `sent_at`. | | `PATCH /broadcasts/{id}` | Sửa chiến dịch nháp hoặc đang hẹn giờ. | | `POST /broadcasts/{id}/send` | Gửi ngay hoặc hẹn giờ (`scheduled_at`). | | `POST /broadcasts/{id}/cancel` | Huỷ chiến dịch đang hẹn giờ hoặc trong hàng đợi. | | `DELETE /broadcasts/{id}` | Xoá, trừ khi đang gửi. | ## Bước tiếp theo - [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md) - [Templates](https://hatang.dev/vi/docs/guides/email/templates.md) - [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md) - [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/vi/docs/guides/email/broadcasts --- # Khả năng vào hộp thư Khả năng vào hộp thư (deliverability) là việc email của bạn tới hộp thư chính thay vì thư mục spam. Các nhà cung cấp hộp thư như Gmail, Yahoo, Outlook xét ba điều: thư có thật do bạn gửi không (xác thực), người nhận có muốn nhận không (tương tác, thư trả lại, báo spam) và bạn đã gửi thư thế nào theo thời gian (uy tín). Trang này giải thích cấu hình của Hạ Tầng lo phần xác thực ra sao và những gì bạn kiểm soát phần còn lại. ## Xác thực và căn chỉnh DMARC đạt khi SPF hoặc DKIM đạt **và** tên miền được kiểm tra khớp (căn chỉnh, align) với tên miền trong địa chỉ `From`. Hạ Tầng cấu hình cả hai để căn chỉnh: | Kiểm tra | Hạ Tầng làm gì | Bạn thêm gì | Căn chỉnh với `From: ban@shop.vn` | |---|---|---|---| | DKIM | Ký mọi thư bằng khoá 2048 bit riêng của tên miền (`d=shop.vn`, selector `rs…`). | Bản ghi TXT DKIM. | Có, tên miền ký chính là tên miền của bạn. | | SPF | Dùng `bounce+…@send.shop.vn` làm envelope sender (`Return-Path`). | Bản ghi TXT SPF và MX trên `send.shop.vn`. | Có, theo căn chỉnh relaxed, vì `send.shop.vn` là tên miền phụ của `shop.vn`. | | DMARC | Đề xuất `v=DMARC1; p=none;` và kiểm tra đã có bản ghi DMARC. | Bản ghi TXT `_dmarc`. | Dựa trên hai kết quả trên. | Vì bản ghi SPF và MX nằm trên tên miền phụ `send.`, chúng không đụng tới bản ghi SPF của tên miền gốc, và bạn không cần thêm Hạ Tầng vào đó. Bản ghi chính xác xem ở [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). ### Siết DMARC từng bước `p=none` chỉ theo dõi. Khi báo cáo DMARC cho thấy mọi thư thật của bạn đều đạt, hãy chuyển sang chính sách chặt hơn để người khác không giả mạo được tên miền: ```text _dmarc.shop.vn TXT "v=DMARC1; p=none; rua=mailto:dmarc@shop.vn" _dmarc.shop.vn TXT "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@shop.vn" _dmarc.shop.vn TXT "v=DMARC1; p=reject; rua=mailto:dmarc@shop.vn" ``` Mỗi lúc chỉ để một trong các bản ghi này. `rua` gửi cho bạn báo cáo tổng hợp hằng ngày; đọc bằng một công cụ phân tích báo cáo DMARC trước mỗi bước. Trước khi chuyển sang `quarantine` hay `reject`, hãy kiểm tra mọi dịch vụ khác đang gửi thư bằng tên miền của bạn (hộp thư văn phòng, CRM, phần mềm hoá đơn) và đảm bảo từng dịch vụ đều đạt. ### Các nhà cung cấp hộp thư lớn yêu cầu gì Gmail và Yahoo yêu cầu người gửi số lượng lớn xác thực bằng SPF và DKIM, có DMARC, có nút huỷ đăng ký một chạm trong email marketing và giữ tỉ lệ báo spam thấp. Với Hạ Tầng: - SPF, DKIM và DMARC đến từ cấu hình tên miền ở trên. - Email của chiến dịch và automation tự có header `List-Unsubscribe` và `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Xem [Chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md#unsubscribe). - Báo spam được thống kê trong [Số liệu](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md), và mỗi lần báo spam đều đưa địa chỉ vào danh sách chặn. Đọc [hướng dẫn cho người gửi email](https://support.google.com/a/answer/81126) của Google để biết quy định mới nhất. ## Tách các luồng thư Gửi các loại thư khác nhau từ các tên miền khác nhau để loại này không ảnh hưởng loại kia: | Luồng | Tên miền ví dụ | |---|---| | Thư tài khoản và giao dịch (hoá đơn, đặt lại mật khẩu) | `shop.vn` hoặc `notify.shop.vn` | | Marketing (chiến dịch email, bản tin) | `updates.shop.vn` | Mỗi tên miền bạn thêm có khoá DKIM và return path riêng, và Số liệu lọc được theo từng tên miền. Nếu mỗi hệ thống gửi một luồng riêng, hãy giới hạn API key vào một tên miền. ## Làm nóng tên miền mới Một tên miền hay tên miền phụ chưa từng gửi thư thì chưa có uy tín. Các nhà cung cấp hộp thư theo dõi rất kỹ trong những tuần đầu. 1. Bắt đầu với những người nhận tương tác nhiều nhất: người mới đăng ký hoặc đã mở các email gần đây. 2. Bắt đầu với số lượng nhỏ và tăng dần trong vài tuần, ví dụ gấp đôi sau mỗi vài ngày nếu tỉ lệ bị trả lại và báo spam vẫn thấp. 3. Giữ nhịp gửi đều. Im lặng lâu rồi gửi dồn một đợt lớn trông giống spam. 4. Xem tỉ lệ bị trả lại và báo spam sau mỗi bước, và chậm lại nếu chúng tăng. Hạn mức ngày của doanh nghiệp là lưới an toàn trước những đợt tăng đột ngột. Bạn xem được ở **Cài đặt email**. ## Giữ danh sách sạch - **Xin sự đồng ý.** Chỉ gửi email marketing cho người đã đồng ý nhận. Một email xác nhận lúc đăng ký (double opt-in) giúp loại địa chỉ gõ sai và địa chỉ giả. Không bao giờ mua hay thuê danh sách. - **Để thư trả lại và báo spam tự chặn.** Hạ Tầng làm việc này tự động: xem [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md). Đừng gỡ các mục đó khi không có lý do chính đáng. - **Dùng topic.** Người được chọn nhận ít email hơn sẽ ít huỷ đăng ký và ít báo spam hơn. Xem [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md). - **Loại contact không còn tương tác.** Contact nhiều tháng không mở hay nhấp làm giảm mức tương tác của bạn. Ngừng gửi cho họ, hoặc hỏi một lần xem họ có muốn tiếp tục nhận không. - **Để việc huỷ đăng ký thật dễ.** Đặt link `{{{UNSUBSCRIBE_URL}}}` ở chỗ dễ thấy. Người nhận không tìm thấy link sẽ bấm "Báo cáo spam". ## Viết thư cho ra dáng bạn - Dùng tên người gửi dễ nhận ra và địa chỉ ổn định, ví dụ `Shop `. - Gửi kèm phần văn bản thuần với HTML. Template tự tạo phần này; với `POST /emails`, hãy truyền `text`. - Link tới tên miền của bạn. Khi bật theo dõi nhấp, dùng [tên miền phụ theo dõi](https://hatang.dev/vi/docs/guides/email/tracking.md) để link theo dõi cũng mang tên miền của bạn. - Tránh dịch vụ rút gọn link công cộng và tệp đính kèm trong email marketing. - Đặt `Reply-To` là địa chỉ có người đọc. ## Theo dõi | Tín hiệu | Ở đâu | Cần chú ý | |---|---|---| | Tỉ lệ bị trả lại | **Email → Số liệu** | Dashboard đánh dấu khi trên **4%**. Tăng đột ngột thường do danh sách cũ hoặc mua về. | | Tỉ lệ báo spam | **Email → Số liệu** | Dashboard đánh dấu khi trên **0,08%**. Kiểm tra người nhận vào danh sách bằng cách nào và việc huỷ đăng ký có dễ không. | | `email.bounced` với `Policy` hoặc `ContentRejected` | Webhook, **Email → Email** | Máy chủ nhận đang chặn nội dung hoặc uy tín của bạn. | | Báo cáo tổng hợp DMARC | Địa chỉ `rua` trong bản ghi DMARC | Thư không đạt SPF hoặc DKIM, hoặc dịch vụ lạ đang gửi thư dưới tên bạn. | | Uy tín tên miền ở Gmail | [Google Postmaster Tools](https://postmaster.google.com/) | Tỉ lệ spam và uy tín tên miền theo cách Gmail nhìn nhận. | ## Danh sách kiểm tra - [ ] Tên miền **Đã xác minh**, có DKIM, MX và SPF của return path. - [ ] Đã có bản ghi DMARC. - [ ] Email marketing và email giao dịch gửi từ các tên miền hoặc tên miền phụ khác nhau. - [ ] Mọi email marketing đều có link huỷ đăng ký nhìn thấy được. - [ ] Email HTML có kèm phần văn bản thuần. - [ ] Đã xem tỉ lệ bị trả lại và báo spam sau mỗi đợt gửi lớn. ## Bước tiếp theo - [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) - [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md) - [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/vi/docs/guides/email/deliverability --- # Tên miền Bạn chỉ gửi được email từ tên miền đã thêm vào Hạ Tầng và đã xác minh. Mỗi tên miền có khoá DKIM và tên miền phụ return path riêng, để hộp thư người nhận kiểm tra được thư thật sự do bạn gửi. Trang này hướng dẫn thêm tên miền, các bản ghi DNS, xác minh, cấu hình một chạm bằng Domain Connect và cách xử lý khi một bản ghi không xác minh được. ## Trước khi bắt đầu - Quyền sửa DNS của tên miền (Cloudflare, nhà đăng ký tên miền hoặc nhà cung cấp DNS). - Nếu dùng API: key có phạm vi `email:write`. Các endpoint tên miền từ chối key không có phạm vi này. ## Chọn tên miền để gửi {#pick-the-domain-to-send-from} Phần tên miền trong địa chỉ `From` phải **trùng khớp** với một tên miền đã xác minh. Muốn gửi từ `hello@updates.example.com` thì phải thêm `updates.example.com`; thêm `example.com` là chưa đủ, và ngược lại. > **Tip: Dùng tên miền phụ cho email marketing** > > Tên miền phụ như `updates.example.com` hay `mail.example.com` giữ uy tín gửi thư tách biệt với tên miền gốc. Nếu một chiến dịch không suôn sẻ, thư nhân viên gửi từ `example.com` không bị ảnh hưởng. Nhiều đội dùng một tên miền phụ cho email giao dịch và một tên miền phụ khác cho chiến dịch email. ## Thêm tên miền **cURL** ```bash curl -X POST https://api.hatang.dev/domains \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "shop.vn" }' ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data: domain, error } = await hatang.domains.create({ name: "shop.vn" }); if (error) throw new Error(error.message); console.table(domain.records); ``` **Python** ```python import os import requests res = requests.post( "https://api.hatang.dev/domains", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"name": "shop.vn"}, ) res.raise_for_status() for record in res.json()["records"]: print(record["type"], record["name"], record["value"]) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/domains', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['name' => 'shop.vn'], ]); $domain = json_decode($res->getBody(), true); ``` Trong dashboard, mở **Email → Tên miền**, nhập tên miền vào ô **Tên miền** rồi bấm **Thêm tên miền**. Mục **Tuỳ chọn nâng cao** có các thiết lập giống các trường API dưới đây. | Trường | Mặc định | Mô tả | |---|---|---| | `name` | bắt buộc | Tên miền hoặc tên miền phụ, ví dụ `shop.vn` hay `updates.shop.vn`. Được lưu ở dạng chữ thường. | | `custom_return_path` | `send` | Nhãn tên miền phụ cho địa chỉ nhận thư trả lại và SPF: `send` thành `send.shop.vn`. Chữ, số và dấu gạch ngang, tối đa 63 ký tự. Trong dashboard: **Return-Path tuỳ chỉnh**. | | `tracking_subdomain` | không có | Nhãn tuỳ chọn cho link theo dõi mang tên miền của bạn, ví dụ `links` thành `links.shop.vn`. Phải khác nhãn return path. Trong dashboard: **Tên miền phụ theo dõi**. Xem [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md). | | `open_tracking` | `false` | Chèn pixel theo dõi lượt mở vào email HTML gửi từ tên miền này. | | `click_tracking` | `false` | Viết lại link trong email HTML gửi từ tên miền này để ghi nhận lượt nhấp. | | `region` | vùng của nền tảng | Nhận để tương thích Resend và được lưu lại. Việc gửi luôn dùng vùng Amazon SES của nền tảng. | Response (`201`) là tên miền kèm các bản ghi cần thêm: ```json { "object": "domain", "id": "dom_0199e6c17a2b7f009c1e3f6a2b1d9e10", "name": "shop.vn", "status": "not_started", "created_at": "2026-10-14T09:12:03.000Z", "region": "us-west-2", "dkim_selector": "rs7hq2kx9p", "open_tracking": false, "click_tracking": false, "tracking_subdomain": null, "tracking_domain_verified": false, "custom_return_path": "send", "ses_status": "pending", "tls": "opportunistic", "capabilities": { "sending": "enabled", "receiving": "disabled" }, "records": [ { "record": "MX", "name": "send.shop.vn", "type": "MX", "ttl": "Auto", "status": "not_started", "value": "feedback-smtp.us-west-2.amazonses.com", "priority": 10, "required": true }, { "record": "SPF", "name": "send.shop.vn", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=spf1 include:amazonses.com ~all", "required": true }, { "record": "DKIM", "name": "rs7hq2kx9p._domainkey.shop.vn", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…", "required": true }, { "record": "DMARC", "name": "_dmarc.shop.vn", "type": "TXT", "ttl": "Auto", "status": "not_started", "value": "v=DMARC1; p=none;", "required": false } ] } ``` Thêm một tên miền đã có sẽ nhận lỗi `409`. ## Các bản ghi DNS Luôn chép tên và giá trị từ dashboard hoặc từ mảng `records`. Các giá trị dưới đây chỉ là ví dụ. | Bản ghi | Loại | Tên | Giá trị | Bắt buộc | |---|---|---|---|---| | DKIM | TXT | `._domainkey.shop.vn` | `v=DKIM1; k=rsa; p=` | Có | | MX của return path | MX, ưu tiên 10 | `send.shop.vn` | `feedback-smtp..amazonses.com` | Có | | SPF của return path | TXT | `send.shop.vn` | `v=spf1 include:amazonses.com ~all` | Có | | DMARC | TXT | `_dmarc.shop.vn` | `v=DMARC1; p=none;` | Không, nhưng nên có | | Theo dõi | CNAME | `links.shop.vn` | Tên máy chủ theo dõi hiển thị trong dashboard | Chỉ khi có tên miền phụ theo dõi | | Nhận thư | MX, ưu tiên 10 | `shop.vn` | Máy chủ nhận thư hiển thị trong dashboard | Chỉ khi bật nhận email | ### DKIM Hạ Tầng tạo một khoá RSA 2048 bit cho mỗi tên miền. Nửa công khai nằm trong bản ghi TXT dưới một selector bắt đầu bằng `rs` và tám ký tự ngẫu nhiên (trường `dkim_selector`). Mọi thư đều được ký bằng nửa bí mật cho tên miền của bạn, nhờ vậy DMARC đạt qua DKIM. Khoá 2048 bit dài hơn 255 ký tự. Phần lớn nhà cung cấp DNS tự tách thành nhiều chuỗi; bạn chỉ cần dán nguyên giá trị vào một bản ghi. Hạ Tầng bỏ qua khoảng trắng khi so khoá. ### Return path: MX và SPF trên `send.` Địa chỉ nhận thư trả lại (envelope sender, hay `Return-Path`) của mọi thư nằm trên tên miền phụ return path, ví dụ `bounce+…@send.shop.vn`. Hai bản ghi trên tên miền phụ này giúp nó hoạt động: - Bản ghi **MX** đưa thư trả lại và báo spam về Amazon SES, rồi SES báo lại cho Hạ Tầng. - Bản ghi TXT **SPF** cho phép Amazon SES gửi thay tên miền phụ đó. Vì return path là tên miền phụ của tên miền bạn, SPF căn chỉnh (align) với tên miền trong `From` theo chế độ căn chỉnh relaxed của DMARC. Các bản ghi nằm trên tên miền phụ chứ không phải tên miền gốc, nên không bao giờ đụng tới SPF hay MX mà tên miền gốc đang có. Nếu tên miền phụ đã có bản ghi SPF, Hạ Tầng chấp nhận mọi bản ghi `v=spf1` có chứa `include:amazonses.com`. ### DMARC DMARC cho hộp thư người nhận biết phải làm gì với thư không đạt căn chỉnh SPF và DKIM. Hạ Tầng đề xuất `v=DMARC1; p=none;`, tức là chỉ theo dõi. Mọi bản ghi bắt đầu bằng `v=DMARC1` đều được tính là đã có, nên nếu đã có chính sách riêng thì cứ giữ. DMARC không chặn việc xác minh. Gmail và Yahoo yêu cầu người gửi số lượng lớn phải có DMARC, nên hãy thêm. Xem [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md). ### Theo dõi và nhận thư Hai bản ghi này chỉ xuất hiện khi bạn dùng tính năng tương ứng: CNAME theo dõi khi tên miền có tên miền phụ theo dõi, MX nhận thư khi bạn bật nhận email. Cả hai đều không chặn việc gửi. Xem [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md) và [Nhận email](https://hatang.dev/vi/docs/guides/email/receiving.md). > **Caution: Tên bản ghi ở nhà cung cấp DNS** > > Nhiều nhà cung cấp DNS tự nối tên zone vào tên bạn nhập. Nhập `send.shop.vn` vào ô **Name** của zone `shop.vn` có thể tạo ra `send.shop.vn.shop.vn`. Với những nhà cung cấp như vậy, chỉ nhập phần đứng trước tên miền (`send`, `rs7hq2kx9p._domainkey`, `_dmarc`). ## Xác minh tên miền Khi đã thêm bản ghi, bắt đầu xác minh: - Dashboard: mở tên miền và bấm **Xác minh bản ghi DNS**. Trong lúc tên miền đang chờ, trang tự kiểm tra lại và tự làm mới. - API: `POST /domains/{id}/verify`, hoặc `hatang.domains.verify(id)` trong SDK Node.js. ```bash curl -X POST https://api.hatang.dev/domains/dom_0199e6c17a2b7f009c1e3f6a2b1d9e10/verify \ -H "Authorization: Bearer $HATANG_API_KEY" ``` ```json { "object": "domain", "id": "dom_0199e6c17a2b7f009c1e3f6a2b1d9e10", "status": "pending" } ``` Hạ Tầng kiểm tra mọi bản ghi ngay lập tức, sau đó tiếp tục kiểm tra ở chế độ nền cho tới khi tên miền được xác minh. Thay đổi DNS có thể mất tới một giờ để lan truyền. Tên miền **Đã xác minh** khi mọi bản ghi bắt buộc (DKIM, MX và SPF của return path) đều khớp **và** Amazon SES đã xác nhận chữ ký DKIM. Bản ghi không bắt buộc không bao giờ làm chậm việc xác minh. ### Trạng thái tên miền | Trạng thái | Trong dashboard | Ý nghĩa | |---|---|---| | `not_started` | Chưa bắt đầu | Tên miền đã được thêm nhưng chưa yêu cầu xác minh. | | `pending` | Đang chờ | Hạ Tầng đang kiểm tra. Còn bản ghi bắt buộc chưa khớp, hoặc Amazon SES chưa xác nhận DKIM. | | `verified` | Đã xác minh | Gửi được từ tên miền này. | | `temporary_failure` | Lỗi tạm thời | Tra cứu DNS lỗi vì lý do khác "không tìm thấy", ví dụ hết thời gian chờ. Hạ Tầng sẽ thử lại. | | `failed` | Thất bại | Xác minh thất bại, ví dụ Amazon SES từ chối cấu hình DKIM. Sửa bản ghi rồi xác minh lại. | Mỗi bản ghi cũng có `status` riêng (`not_started`, `pending`, `verified` hoặc `failed`) và, khi lỗi, trường `last_error` cho biết Hạ Tầng thấy gì thay vào đó, như `found: nothing` hoặc giá trị đang có. Hạ Tầng gửi webhook `domain.created`, `domain.updated` và `domain.deleted`, kèm tên miền giống như `GET /domains/{id}` trả về. Mỗi lần trạng thái đổi sẽ bắn `domain.updated`. ## Cấu hình một chạm với Domain Connect Nếu nhà cung cấp DNS hỗ trợ [Domain Connect](https://www.domainconnect.org/), trang tên miền có khung **Bản ghi DNS** với nút **Tự động cấu hình với** tên nhà cung cấp. Bấm vào, đăng nhập ở nhà cung cấp DNS và đồng ý thay đổi. Nhà cung cấp thêm bản ghi, đưa bạn quay lại Hạ Tầng và việc xác minh bắt đầu ngay. Nút **Cài đặt thủ công** đưa bạn tới bảng bản ghi để tự thêm. Domain Connect thêm: - bản ghi MX của return path và quy tắc SPF (`include:amazonses.com`, gộp vào bản ghi SPF sẵn có trên tên miền phụ đó), - bản ghi DKIM, - bản ghi DMARC (`v=DMARC1; p=none;`), - CNAME theo dõi, khi tên miền có tên miền phụ theo dõi. > **Note** > > Xem kỹ những thay đổi nhà cung cấp DNS hiển thị trước khi đồng ý, nhất là khi tên miền đã có bản ghi DMARC bạn muốn giữ. Khi nhà cung cấp DNS không hỗ trợ Domain Connect nhưng tên miền nằm trên Cloudflare, trang tên miền có link mở thẳng trang bản ghi DNS của zone. Qua API: ```bash # Tên miền này có cấu hình một chạm được không? curl https://api.hatang.dev/domains/$DOMAIN_ID/domain-connect -H "Authorization: Bearer $HATANG_API_KEY" # { "object": "domain_connect", "available": true, "reason": null, "dns_host": "Cloudflare", "zone": "shop.vn" } # Lấy URL đã ký để chuyển người dùng tới curl -X POST https://api.hatang.dev/domains/$DOMAIN_ID/domain-connect \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" -d '{}' # { "object": "domain_connect", "id": "dom_…", "url": "https://…" } ``` Khi `available` là `false`, `reason` là `no_domain_connect` (nhà cung cấp DNS không hỗ trợ), `template_not_supported` (có hỗ trợ Domain Connect nhưng chưa hỗ trợ template của Hạ Tầng) hoặc `not_configured`. `POST` nhận `redirect_uri` tuỳ chọn; nếu không có, người dùng quay về trang tên miền trong dashboard. `GET /domains/{id}/dns-provider` cho biết nhà cung cấp DNS đang phục vụ tên miền: ```json { "object": "dns_provider", "zone": "shop.vn", "provider": "cloudflare", "nameservers": ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"], "cloudflare_url": "https://dash.cloudflare.com/…" } ``` ## Return path tuỳ chỉnh Nhãn return path là `send`, trừ khi bạn chọn nhãn khác lúc thêm tên miền. Chỉ đổi khi `send.shop.vn` đã được dùng cho việc khác, ví dụ bản ghi thư trả lại của một dịch vụ email khác. Nhãn không đổi được về sau: hãy xoá tên miền rồi thêm lại với nhãn mới. ## Sửa hoặc xoá tên miền `PATCH /domains/{id}` đổi các thiết lập được phép đổi sau khi tạo: ```bash curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "open_tracking": true, "click_tracking": true, "tracking_subdomain": "links" }' ``` | Trường | Tác dụng | |---|---| | `open_tracking`, `click_tracking` | Bật hoặc tắt theo dõi cho các email gửi từ lúc đó. | | `tracking_subdomain` | Đặt, đổi hoặc gỡ (`null`) tên miền phụ theo dõi. Một bản ghi CNAME mới xuất hiện để bạn thêm. | | `capabilities.receiving` | `"enabled"` hoặc `"disabled"`. Xem [Nhận email](https://hatang.dev/vi/docs/guides/email/receiving.md). | `DELETE /domains/{id}` xoá tên miền và định danh Amazon SES của nó. Email còn trong hàng đợi của tên miền đó sẽ bị trả lại với lý do "Sending domain no longer exists". ## Xử lý sự cố | Hiện tượng | Nguyên nhân và cách sửa | |---|---| | Bản ghi báo `found: nothing` | Bản ghi chưa có hoặc đặt sai tên. Kiểm tra tên zone có bị nối hai lần không (xem lưu ý ở trên), rồi chờ vài phút cho DNS cập nhật. | | DKIM lỗi dù đã có bản ghi | Giá trị bị cắt hoặc bị sửa. Dán lại toàn bộ giá trị `v=DKIM1; k=rsa; p=…`. Một số nhà cung cấp cần đặt giá trị dài trong dấu ngoặc kép hoặc tách thành nhiều chuỗi. | | SPF trên `send.` lỗi | Tên miền phụ có bản ghi SPF không chứa `include:amazonses.com`, hoặc có hai bản ghi SPF. Chỉ giữ một bản ghi `v=spf1` có chứa `include:amazonses.com`. | | Mọi bản ghi đều khớp nhưng trạng thái vẫn `pending` | Amazon SES chưa xác nhận DKIM. Thường chỉ chậm hơn DNS vài phút; bấm **Kiểm tra lại DNS** sau đó. | | MX của return path xung đột với bản ghi khác | `send.shop.vn` đã có CNAME. Tên đã có CNAME thì không có bản ghi nào khác được. Gỡ CNAME, hoặc thêm lại tên miền với **Return-Path tuỳ chỉnh** khác. | | Lỗi `403` "The shop.vn domain is not verified" khi gửi | Tên miền của `From` chưa được thêm, chưa xác minh, hoặc là một tên miền phụ khác. Xem [Chọn tên miền để gửi](#pick-the-domain-to-send-from). | | Lỗi `401 restricted_api_key` khi gửi | API key bị giới hạn vào tên miền khác. Dùng key của tên miền này hoặc key không giới hạn. | ## Bước tiếp theo - [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md) - [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md) - [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md) - [Nhận email](https://hatang.dev/vi/docs/guides/email/receiving.md) Source: https://hatang.dev/vi/docs/guides/email/domains --- # Nhóm email Nhóm email là một địa chỉ trên tên miền đang nhận thư của bạn, ví dụ `team@congty.vn`, mà thư gửi tới được chuyển cho một danh sách thành viên. Cách hoạt động giống nhóm của Google Workspace: ai cũng gửi được tới địa chỉ nhóm, và mọi thành viên đều nhận được thư. ## Thư chuyển tiếp trông thế nào Mỗi thành viên nhận một bản riêng, nên không thấy địa chỉ của nhau: - **Người gửi:** `"Lan via Đội bán hàng" `. Thư chỉ được gửi từ tên miền đã xác minh của bạn, nên bản chuyển đi từ địa chỉ nhóm, kèm tên người gửi gốc phía trước. - **Trả lời tới:** người gửi gốc. Bấm Trả lời là trả lời thẳng cho họ. - Tiêu đề, nội dung và tệp đính kèm giữ nguyên. Bản chuyển có header `X-Hatang-Forwarded-By` và `List-Id`. Thư gốc vẫn nằm ở **Email → Nhận về** và vẫn gửi webhook `email.received`. Mỗi bản chuyển là một lần gửi bình thường với nguồn `forward`: hiện trong **Email**, có sự kiện giao thư và **tính vào hạn mức gửi trong ngày**. Không chuyển tiếp: thư bị đánh dấu rác, thư tự động (trả lời tự động, vắng mặt, thư báo lỗi, thư từ mailing list), thư đã đi qua một nhóm, và quá 30 thư mỗi giờ từ cùng một người gửi tới một nhóm. ## Trước khi bắt đầu - Một tên miền đã xác minh và đang bật **Nhận thư**. Xem [Nhận email](https://hatang.dev/vi/docs/guides/email/receiving.md). - API key có quyền `email:write` để quản lý nhóm (`email:read` để xem danh sách). ## Tạo nhóm Trên dashboard, mở **Email → Nhóm email**, chọn địa chỉ và tên miền, rồi nhập thành viên, mỗi dòng một địa chỉ. Hoặc dùng API: **cURL** ```bash curl -X POST https://api.hatang.dev/email-groups \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "domain_id": "dom_...", "local_part": "team", "name": "Đội bán hàng", "members": ["an@example.com", "binh@example.com"] }' ``` **Node.js** ```ts const group = await hatang.emailGroups.create({ domain_id: "dom_...", local_part: "team", name: "Đội bán hàng", members: ["an@example.com", "binh@example.com"], }); ``` **Python** ```python requests.post( "https://api.hatang.dev/email-groups", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"domain_id": "dom_...", "local_part": "team", "name": "Đội bán hàng", "members": ["an@example.com", "binh@example.com"]}, ) ``` Mỗi nhóm có tối đa 100 thành viên. Thành viên không được là chính nhóm đó hay một nhóm khác, nên các nhóm không bao giờ chuyển thư vòng qua lại cho nhau. ## Sửa hoặc xoá nhóm `PATCH /email-groups/{id}` đổi tên, mô tả hoặc thành viên (gửi cả danh sách thành viên). Địa chỉ nhóm không đổi được; muốn đổi thì xoá rồi tạo nhóm mới. `DELETE /email-groups/{id}` xoá nhóm: thư tới địa chỉ này vẫn được nhận và lưu ở Nhận về, chỉ không chuyển cho ai nữa. Xem đầy đủ các trường ở [API reference](https://hatang.dev/vi/docs/reference/api.md). Source: https://hatang.dev/vi/docs/guides/email/groups --- # Số liệu và logs Số liệu cho biết email của bạn hoạt động thế nào theo thời gian: gửi bao nhiêu, tới hộp thư bao nhiêu, bao nhiêu người mở, nhấp, bị trả lại hay báo spam. Logs ghi lại mọi lệnh gọi API bằng key của bạn, kèm nội dung gửi lên và nhận về, để bạn gỡ lỗi tích hợp mà không cần thêm log vào code của mình. ## Số liệu trong dashboard **Email → Số liệu** hiển thị lượng thư theo ngày (đã gửi, tới hộp thư, mở, nhấp) và tỉ lệ tới hộp thư, mở, nhấp, bị trả lại, bị báo spam. Chọn 7, 14 hoặc 30 ngày gần nhất và một tên miền hoặc tất cả. Ngày tính theo giờ Việt Nam. - Biểu đồ tỉ lệ bị trả lại có vạch **4%**, biểu đồ tỉ lệ báo spam có vạch **0,08%**. Vượt các vạch này, Gmail và Yahoo bắt đầu giới hạn hoặc đưa thư của bạn vào spam. - Tỉ lệ mở và nhấp tính trên thư đã tới hộp thư, mỗi người đếm một lần. - Lượt mở chỉ là ước lượng: Apple Mail Privacy Protection và các proxy ảnh khác có thể làm con số cao hơn thực tế. Xem [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md). ## API số liệu `GET /emails/metrics` trả về cùng các con số, theo cấu trúc endpoint metrics của Resend. Key có `email:read` (hoặc `email:send` / `email:write`) là đủ. **cURL** ```bash curl "https://api.hatang.dev/emails/metrics?start_date=2026-10-01&end_date=2026-10-14&timezone=Asia/Ho_Chi_Minh&dimensions=period,domain&metrics=sent,delivered,bounce_rate" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data } = await hatang.emails.metrics({ start_date: "2026-10-01", end_date: "2026-10-14", timezone: "Asia/Ho_Chi_Minh", dimensions: ["period", "domain"], metrics: ["sent", "delivered", "bounce_rate"], }); ``` **Python** ```python res = requests.get( "https://api.hatang.dev/emails/metrics", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, params={"start_date": "2026-10-01", "end_date": "2026-10-14", "timezone": "Asia/Ho_Chi_Minh", "metrics": "sent,delivered,bounce_rate"}, ) ``` **PHP** ```php $res = $client->get('/emails/metrics', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'query' => ['start_date' => '2026-10-01', 'end_date' => '2026-10-14', 'timezone' => 'Asia/Ho_Chi_Minh', 'metrics' => 'sent,delivered,bounce_rate'], ]); ``` | Tham số | Mô tả | |---|---| | `start_date`, `end_date` | Một ngày (`2026-10-01`, hiểu là nửa đêm UTC) hoặc một thời điểm ISO. `end_date` chỉ có ngày thì bao gồm cả ngày đó (tới 23:59:59 UTC). Khoảng tối đa 31 ngày. Không truyền thì lấy 7 ngày gần nhất. | | `timezone` | Múi giờ IANA dùng để chia kỳ, ví dụ `Asia/Ho_Chi_Minh`. Mặc định `UTC`. | | `granularity` | `hourly`, `daily` (mặc định), `weekly` hoặc `monthly`. | | `metrics` | Danh sách chỉ số cần lấy, cách nhau bởi dấu phẩy. Mặc định: tất cả. | | `dimensions` | `period` (mặc định), hoặc `period,domain` để tách từng kỳ theo tên miền. | | `domain_id` | Chỉ các tên miền này, cách nhau bởi dấu phẩy. | ```json { "object": "metrics", "start_date": "2026-10-01T00:00:00.000Z", "end_date": "2026-10-14T23:59:59.999Z", "timezone": "Asia/Ho_Chi_Minh", "granularity": "daily", "metrics": ["sent", "delivered", "bounce_rate"], "dimensions": ["period"], "totals": { "sent": 1840, "delivered": 1812, "bounce_rate": 0.0098 }, "data": [ { "period": "2026-10-01T00:00:00", "sent": 120, "delivered": 119, "bounce_rate": 0.0083 }, { "period": "2026-10-02T00:00:00", "sent": 134, "delivered": 131, "bounce_rate": 0.0149 } ] } ``` `totals` tính cho cả khoảng; `data` có một dòng cho mỗi kỳ (và mỗi tên miền, kèm `domain_id` và `domain_name`). Tỉ lệ là số thập phân làm tròn bốn chữ số, nên `0.0098` là 0,98%. ### Số đếm | Chỉ số | Đếm gì | |---|---| | `sent` | Số người nhận Hạ Tầng đã gửi tới, không tính người nhận bị chặn và bị huỷ. | | `delivered` | Sự kiện tới hộp thư. | | `delivery_delayed` | Sự kiện gửi chậm. | | `failed` | Sự kiện gửi thất bại. | | `suppressed` | Người nhận bị bỏ qua vì nằm trong danh sách chặn. | | `bounced` | Sự kiện bị trả lại, kể cả của người nhận bị chặn. | | `opened`, `unique_opened` | Lượt mở, và số người nhận đã mở ít nhất một lần. | | `clicked`, `unique_clicked` | Lượt nhấp, và số người nhận đã nhấp ít nhất một lần. | | `complained` | Lượt báo spam. | | `unsubscribed` | Số contact huỷ đăng ký mọi email. Không gắn với tên miền nào, nên bị bỏ khi bạn tách hoặc lọc theo tên miền. | | `received` | Thư nhận được trên các tên miền của bạn. | ### Tỉ lệ | Tỉ lệ | Công thức | |---|---| | `delivery_rate` | `delivered / sent` | | `bounce_rate` | `bounced / sent` | | `open_rate` | `unique_opened / delivered` | | `click_rate` | `unique_clicked / delivered` | | `complaint_rate` | `complained / delivered` | | `unsubscribe_rate` | `unsubscribed / delivered` | Lượt gửi được tính vào kỳ email được tạo; sự kiện được tính vào kỳ sự kiện xảy ra. ## Logs **Email → Logs** ghi lại mọi lệnh gọi API bằng key của doanh nghiệp: endpoint, phương thức, mã trạng thái, thời gian xử lý, API key, user agent (cho biết SDK và phiên bản) cùng nội dung request và response. Lọc theo trạng thái, phương thức, API key hoặc đường dẫn. Lệnh gọi lỗi có nút **Chép để hỏi AI**: gom request, response và đường dẫn tài liệu thành một đoạn để dán cho trợ lý AI. - Mật khẩu, secret, token và API key trong nội dung được thay bằng `[redacted]`. Nội dung tệp đính kèm được thay bằng kích thước. - Mỗi nội dung giữ tối đa 16 KB. Logs được giữ 30 ngày. - Thao tác trong chính dashboard không nằm trong log; chỉ lệnh gọi bằng API key mới có. ```bash curl "https://api.hatang.dev/logs?status=error&limit=20" -H "Authorization: Bearer $HATANG_API_KEY" curl "https://api.hatang.dev/logs/log_0199e6c17a2b7f009c1e3f6a2b1d9e10" -H "Authorization: Bearer $HATANG_API_KEY" ``` | Tham số của `GET /logs` | Mô tả | |---|---| | `status` | `2xx`, `4xx`, `5xx` hoặc `error` (mọi mã 4xx và 5xx). | | `method` | `GET`, `POST`, `PATCH`… | | `endpoint` | Một phần của đường dẫn, ví dụ `/emails`. | | `api_key_id` | Chỉ lệnh gọi bằng key này. | | `user_agent` | Một phần của user agent, ví dụ `hatang-sdk-node`. | | `since`, `until` | Khoảng thời gian. | | `limit`, `after`, `before` | Phân trang, như các danh sách khác. | Một mục log trông như sau; `GET /logs/{id}` có thêm `request_body`, `response_body` và `resource_ids` (các id mà response đã tạo hoặc trả về): ```json { "object": "log", "id": "log_…", "created_at": "2026-10-14T09:12:03.000Z", "endpoint": "/emails", "method": "POST", "response_status": 422, "duration_ms": 38, "user_agent": "hatang-sdk-node/1.4.0 (node/22.11.0)", "sdk": "hatang-sdk-node 1.4.0", "api_key_id": "key_…" } ``` ## Mức sử dụng `GET /usage` cho biết doanh nghiệp đã dùng bao nhiêu so với giới hạn: số email hôm nay so với hạn mức ngày (kèm thời điểm đặt lại), số email đã gửi và đã nhận trong tháng, cùng số tên miền, contact, template và số lượt chạy automation trong tháng. ```json { "object": "usage", "emails": { "daily": { "used": 312, "limit": 10000, "resets_at": "2026-10-15T00:00:00.000Z" }, "monthly": { "sent": 8840, "received": 112, "limit": null, "resets_at": "2026-11-01T00:00:00.000Z" } }, "domains": { "used": 2, "limit": null }, "contacts": { "used": 5120, "limit": null }, "templates": { "used": 14, "limit": null }, "automation_runs": { "monthly": 640, "limit": null } } ``` Xem logs và mức sử dụng cần key có phạm vi ghi ở bất kỳ sản phẩm nào (ví dụ `email:write`), hoặc toàn quyền. ## Bước tiếp theo - [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md) - [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md) - [Lỗi](https://hatang.dev/vi/docs/reference/errors.md) Source: https://hatang.dev/vi/docs/guides/email/metrics-and-logs --- # Chuyển từ Resend Hạ Tầng Email theo API của Resend: cùng endpoint, cùng cấu trúc request và response, cùng dạng lỗi và cùng cách ký webhook. Phần lớn code chuyển sang được chỉ bằng cách đổi base URL và API key. Trang này liệt kê những gì tương thích, những gì khác, và các bước chuyển mà không mất thư. ## Những gì tương thích | Hạng mục | Tương thích | |---|---| | Gửi email | `POST /emails` với `from`, `to`, `cc`, `bcc`, `reply_to`, `subject`, `html`, `text`, `headers`, `tags`, `attachments` (dùng `content`), `scheduled_at`, `template`; `POST /emails/batch`; `GET /emails/{id}`; `PATCH /emails/{id}` để dời giờ; `POST /emails/{id}/cancel`. | | Idempotency | Header `Idempotency-Key` trên `POST /emails`. | | Lỗi | Body `{ "statusCode", "name", "message" }` và các tên như `validation_error`, `restricted_api_key`, `rate_limit_exceeded`, `invalid_idempotent_request`. | | Tên miền | `POST /domains` với `name` và `custom_return_path`, `GET`, `PATCH` (`open_tracking`, `click_tracking`, `tls`, `capabilities.receiving`), `DELETE`, `POST /domains/{id}/verify`. | | API key | `POST /api-keys` với `permission` (`full_access` hoặc `sending_access`) và `domain_id`. | | Webhook | Quản lý endpoint, tên sự kiện (`email.sent`, `email.delivered`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.received`, `contact.*`, `domain.*`…) và cấu trúc payload. Request được ký theo kiểu Svix, với header `svix-id`, `svix-timestamp` và `svix-signature` (cùng giá trị ở `webhook-id`, `webhook-timestamp`, `webhook-signature`), nên code xác minh hiện có vẫn chạy với secret `whsec_…` mới. | | Contact | `/contacts`, `/segments`, `/topics`, `/contact-properties` và API `/audiences` cũ. | | Chiến dịch email | `POST /broadcasts` với `segment_id` (hoặc `audience_id`), `topic_id`, `/send` với `scheduled_at`, `/cancel`. | | Templates | `/templates`, `/publish`, `/duplicate`, cú pháp `{{{VAR}}}` có giá trị mặc định tại chỗ, và `{{{RESEND_UNSUBSCRIBE_URL}}}`. | | Automations | `/events`, `/events/send`, `/automations` và các lượt chạy. | | Nhận email | `/emails/receiving` và tệp đính kèm. | | Số liệu | `GET /emails/metrics`. | ## Những gì khác | Chủ đề | Resend | Hạ Tầng | |---|---|---| | Base URL | `https://api.resend.com` | `https://api.hatang.dev` | | API key | `re_…` | `ht_…`. Key Resend không dùng được. Key có phạm vi (`email:send`, `email:write`…); `permission` vẫn được nhận và chuyển thành phạm vi. | | Tệp đính kèm theo URL | `path` | Chưa hỗ trợ: lỗi `422 invalid_attachment`. Gửi `content` dạng base64. | | Gửi theo lô | | `/emails/batch` không hỗ trợ `Idempotency-Key`. Lô không phải "tất cả hoặc không gì cả": các phần tử trước phần tử lỗi đã được gửi. | | `react` | SDK render | Giống: SDK Node.js render bằng `@react-email/render`; API chỉ nhận `html` và `text`. | | `region` của tên miền | Chọn vùng gửi | Được nhận và lưu lại; việc gửi luôn dùng vùng Amazon SES của nền tảng. | | Bản ghi DKIM | `resend._domainkey` | `rs._domainkey`, khoá mới cho mỗi tên miền. | | Giới hạn tốc độ | Theo gói của Resend | 10 request mỗi giây cho mỗi doanh nghiệp. | | SMTP | `smtp.resend.com`, nhiều cổng, user `resend` | `smtp.hatang.dev:5870`, chỉ STARTTLS, user `hatang`. Xem [SMTP relay](https://hatang.dev/vi/docs/guides/email/smtp.md). | | Danh sách chặn | | Chỉ trong dashboard (**Email → Danh sách nhận → Danh sách chặn**), không có API. | | Thêm | | `topic_id` trên `POST /emails`, `GET /emails/{id}/events` có kết quả SMTP của từng người nhận, tìm kiếm và bộ lọc trên `GET /emails`, `GET /logs` và `GET /usage`. | ## Chuyển từng bước ### 1. Thêm tên miền vào Hạ Tầng {#1-thêm-tên-miền-vào-hatang} Thêm từng tên miền gửi ở **Email → Tên miền** và thêm các bản ghi mới bên cạnh bản ghi của Resend. Bản ghi DKIM dùng selector khác nên không xung đột. > **Caution: Chọn return path khác khi cả hai cùng chạy** > > Resend và Hạ Tầng đều dùng `send.` làm tên miền phụ return path mặc định, mỗi bên có bản ghi MX nhận phản hồi Amazon SES riêng. Để không có hai bộ bản ghi thư trả lại trên cùng một tên, hãy chọn **Return-Path tuỳ chỉnh** khác trong Hạ Tầng (ví dụ `hsend`) khi thêm tên miền, hoặc gỡ bản ghi của Resend trước nếu bạn ngừng Resend cùng lúc. Xác minh tên miền và chờ trạng thái **Đã xác minh**. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). ### 2. Tạo API key Tạo một key cho mỗi hệ thống gửi email, chỉ với những phạm vi nó cần (`email:send` để gửi). Giới hạn key vào một tên miền nếu hệ thống đó chỉ nên gửi từ tên miền ấy. ### 3. Trỏ code sang Hạ Tầng {#3-trỏ-code-sang-hatang} Cách đơn giản nhất là chuyển sang `@hatang/sdk`, có cùng tên phương thức và cùng kết quả `{ data, error }`: ```ts // Trước import { Resend } from "resend"; const resend = new Resend(process.env.RESEND_API_KEY); await resend.emails.send({ from, to, subject, html }); // Sau import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); await hatang.emails.send({ from, to, subject, html }); ``` Các lệnh tương ứng có trong `emails`, `batch`, `domains`, `apiKeys`, `webhooks`, `audiences`, `contacts`, `segments`, `topics`, `contactProperties`, `broadcasts`, `templates`, `events` và `automations`, và `emails.send(payload, { idempotencyKey })` nhận cùng tuỳ chọn. Xem [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md). Nếu gọi HTTP API trực tiếp, đổi base URL thành `https://api.hatang.dev` và key thành key `ht_…` của bạn. Không cần đổi gì khác. ### 4. Tạo lại webhook Tạo các endpoint webhook trong Hạ Tầng (mục **Webhooks**, hoặc `POST /webhooks`) với cùng các sự kiện, rồi đặt signing secret mới vào code xác minh. Xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ### 5. Chuyển contact Xuất contact từ Resend và nhập CSV ở **Email → Danh sách nhận** (**Nhập CSV**), hoặc tạo bằng `POST /contacts`. Nhập những người đã huỷ đăng ký với `unsubscribed` là `true` để họ vẫn ở trạng thái huỷ đăng ký. Tạo lại topic và segment trước để gán contact vào. ### 6. Đổi thông tin SMTP Ứng dụng đang gửi qua SMTP của Resend cần host, cổng, tên đăng nhập và mật khẩu mới. Xem [SMTP relay](https://hatang.dev/vi/docs/guides/email/smtp.md). ### 7. Theo dõi những ngày đầu Theo dõi **Email → Số liệu** và **Email → Logs** để thấy thư bị trả lại, báo spam và lệnh gọi lỗi. Khoá DKIM mới và return path mới chưa có lịch sử; hãy tăng dần các đợt gửi lớn như trong [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md). ## Chuyển từ SendGrid hoặc Postmark Hạ Tầng không có lớp tương thích cho các API này, nhưng request gửi thư của chúng ánh xạ từng trường sang `POST /emails`. ### SendGrid (v3 Mail Send) | SendGrid | Hạ Tầng `POST /emails` | |---|---| | `from.email`, `from.name` | `from: "Tên "` | | `personalizations[].to`, `.cc`, `.bcc` | `to`, `cc`, `bcc`. Một personalization là một email; nhiều personalization thì dùng `POST /emails/batch`. | | `personalizations[].dynamic_template_data` | `template.variables` | | `template_id` | `template.id` (id hoặc alias của template trên Hạ Tầng) | | `subject` | `subject` | | `content[]` với `text/plain` và `text/html` | `text` và `html` | | `reply_to` | `reply_to` | | `headers` | `headers` | | `categories`, `custom_args` | `tags` (cặp `name` và `value`) | | `attachments[].content`, `.filename`, `.type`, `.content_id` | `attachments[].content`, `.filename`, `.content_type`, `.content_id` | | `send_at` (Unix timestamp) | `scheduled_at` (ISO 8601) | ### Postmark (`/email`) | Postmark | Hạ Tầng `POST /emails` | |---|---| | `From` | `from` | | `To`, `Cc`, `Bcc` (cách nhau bởi dấu phẩy) | `to`, `cc`, `bcc` (mảng) | | `Subject` | `subject` | | `HtmlBody`, `TextBody` | `html`, `text` | | `ReplyTo` | `reply_to` | | `Headers` (`[{ "Name", "Value" }]`) | `headers` (`{ "Name": "Value" }`) | | `Tag`, `Metadata` | `tags` | | `Attachments[].Name`, `.Content`, `.ContentType`, `.ContentID` | `attachments[].filename`, `.content`, `.content_type`, `.content_id` | | `TemplateAlias`, `TemplateModel` (`/email/withTemplate`) | `template.id`, `template.variables` | | `TrackOpens`, `TrackLinks` | Theo tên miền: `open_tracking`, `click_tracking`. Xem [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md). | Sự kiện webhook của hai dịch vụ này tương ứng với `email.delivered`, `email.bounced`, `email.complained`, `email.opened` và `email.clicked` của Hạ Tầng. Webhook của Hạ Tầng được ký bằng header kiểu Svix; xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## Bước tiếp theo - [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) - [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md) - [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md) Source: https://hatang.dev/vi/docs/guides/email/migrate-from-resend --- # Hạ Tầng Email Hạ Tầng Email gửi email giao dịch và email marketing từ chính tên miền của bạn. API giống hệt Resend, nên code đang dùng Resend chỉ cần đổi base URL và API key là chạy; thư được gửi đi qua Amazon SES. Trang này giải thích các thành phần ghép với nhau thế nào và hướng dẫn bạn gửi email đầu tiên. ## Bạn có gì | Tính năng | Làm được gì | Hướng dẫn | |---|---|---| | Tên miền gửi | DKIM, SPF và return path riêng cho từng tên miền, cấu hình DNS một chạm ở các nhà cung cấp DNS hỗ trợ | [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) | | REST API | `POST /emails` và `POST /emails/batch`, hẹn giờ, idempotency, tag, tệp đính kèm | [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md) | | SMTP relay | `smtp.hatang.dev:5870` dùng STARTTLS, cho framework và ứng dụng chỉ biết gửi qua SMTP | [SMTP relay](https://hatang.dev/vi/docs/guides/email/smtp.md) | | Templates | Mẫu email có biến, gửi bằng id hoặc alias | [Templates](https://hatang.dev/vi/docs/guides/email/templates.md) | | Theo dõi | Theo dõi lượt mở và lượt nhấp, có thể dùng tên miền phụ của bạn | [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md) | | Nhận email | Thư gửi tới tên miền của bạn thành webhook `email.received` | [Nhận email](https://hatang.dev/vi/docs/guides/email/receiving.md) | | Danh sách nhận | Contact, segment, topic và thuộc tính tuỳ chỉnh | [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md) | | Chiến dịch email | Email marketing gửi tới một segment, gửi ngay hoặc hẹn giờ, kèm trang tuỳ chọn nhận thư | [Chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md) | | Automations | Chuỗi email cho từng contact, khởi động bằng sự kiện từ ứng dụng của bạn | [Automations](https://hatang.dev/vi/docs/guides/email/automations.md) | | Danh sách chặn | Thư trả lại cứng, báo spam và huỷ đăng ký tự động chặn các lần gửi sau | [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md) | | Số liệu và logs | Tỉ lệ tới hộp thư, mở, nhấp, bị trả lại, bị báo spam, cùng log mọi lệnh gọi API | [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) | ## Một email đi qua những đâu ```mermaid flowchart LR A[Ứng dụng của bạn] -->|REST API hoặc SMTP| B[Hạ Tầng] B -->|kiểm tra: tên miền đã xác minh, danh sách chặn, hạn mức ngày| C[Hàng đợi gửi] C -->|ký DKIM bằng tên miền của bạn| D[Amazon SES] D --> E[Hộp thư người nhận] D -->|đã tới, bị trả lại, bị báo spam| B B -->|webhook| A ``` 1. Ứng dụng gọi `POST /emails` hoặc gửi thư qua SMTP relay. Cả hai đi qua cùng một lớp kiểm tra. 2. Hạ Tầng kiểm tra tên miền của `From` đã xác minh và thuộc doanh nghiệp của bạn, bỏ qua người nhận nằm trong danh sách chặn và trừ một email vào hạn mức ngày. Hạ Tầng trả về `id` của email ngay lập tức. 3. Worker ký thư bằng khoá DKIM của tên miền, đặt địa chỉ nhận thư trả lại trên tên miền phụ return path (mặc định `send.`) rồi chuyển thư cho Amazon SES. 4. Amazon SES báo lại cho Hạ Tầng thư đã tới, bị trả lại hay bị báo spam. Hạ Tầng cập nhật trạng thái email, đưa địa chỉ trả lại cứng và báo spam vào danh sách chặn, rồi gửi webhook cho bạn. ## Trước khi bắt đầu - Một doanh nghiệp trên Hạ Tầng đã bật module **Email**. Xem [Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules). - Một tên miền bạn thêm được bản ghi DNS. - Một API key. Xem [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md). ## Gửi email đầu tiên ### 1. Thêm và xác minh tên miền Trong dashboard, mở **Email → Tên miền**, thêm tên miền (ví dụ `example.com` hoặc tên miền phụ như `updates.example.com`), thêm các bản ghi DNS được hiển thị rồi bấm **Xác minh bản ghi DNS**. Tên miền phải ở trạng thái **Đã xác minh** thì mới gửi được. Chi tiết ở trang [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). ### 2. Tạo API key Tạo key có phạm vi `email:send`. Chỉ giữ key trên server, không bao giờ đưa vào trình duyệt hay ứng dụng di động. ### 3. Gửi **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Shop ", "to": ["khach@example.vn"], "subject": "Xác nhận đơn hàng", "html": "

Cảm ơn bạn đã đặt hàng!

" }' ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send({ from: "Shop ", to: ["khach@example.vn"], subject: "Xác nhận đơn hàng", html: "

Cảm ơn bạn đã đặt hàng!

", }); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.id); ``` **Python** ```python from hatang import Hatang # pip install hatang hatang = Hatang() # đọc HATANG_API_KEY email = hatang.emails.send({ "from": "Shop ", "to": ["khach@example.vn"], "subject": "Xác nhận đơn hàng", "html": "

Cảm ơn bạn đã đặt hàng!

", }) print(email["id"]) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/emails', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'from' => 'Shop ', 'to' => ['khach@example.vn'], 'subject' => 'Xác nhận đơn hàng', 'html' => '

Cảm ơn bạn đã đặt hàng!

', ], ]); echo json_decode($res->getBody(), true)['id']; ``` Response chứa id của email: ```json { "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10" } ``` > **Note** > > SDK Node.js trả về `{ data, error }` và không bao giờ ném lỗi. Luôn truyền `baseUrl: "https://api.hatang.dev"` (hoặc đặt biến `HATANG_BASE_URL`), vì giá trị mặc định có sẵn trong SDK trỏ tới server phát triển ở máy local. ### 4. Theo dõi email - Trong dashboard, **Email → Email** liệt kê mọi email kèm trạng thái, người nhận và sự kiện. - `GET /emails/{id}` trả về email và `status`; `GET /emails/{id}/events` trả về từng sự kiện và trạng thái của từng người nhận. - Muốn nhận cập nhật tức thì, đăng ký webhook cho `email.delivered`, `email.bounced` và các sự kiện khác. Xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## Các mục trong dashboard | Mục trên thanh bên | Bạn làm gì ở đó | |---|---| | **Email** | Mọi email gửi qua API, SMTP relay hoặc chiến dịch email. Tab **Nhận về** liệt kê thư nhận được trên tên miền của bạn. | | **Chiến dịch email** | Soạn, hẹn giờ và gửi email marketing tới một segment. | | **Templates** | Sửa, gửi thử và publish template. | | **Danh sách nhận** | Contacts, segments, topics, thuộc tính và danh sách chặn. | | **Automations** | Chuỗi email theo sự kiện và các lượt chạy. | | **Số liệu** | Lượng thư và các tỉ lệ theo ngày. | | **Tên miền** | Tên miền gửi và nhận, bản ghi DNS, theo dõi. | | **Logs** | Mọi lệnh gọi API bằng key của bạn. | | **API Keys** | Tạo và thu hồi API key. | | **Webhooks** | Các endpoint nhận sự kiện email. | | **Cài đặt email** | Thông tin kết nối SMTP, hạn mức ngày, nút tạm dừng khẩn cấp và trang huỷ đăng ký. | ## Phạm vi của API key Chỉ cấp cho mỗi key những phạm vi mà hệ thống dùng key đó cần: | Endpoint | Phạm vi bắt buộc | |---|---| | `POST /emails`, `POST /emails/batch`, SMTP relay | `email:send` | | `PATCH /emails/{id}`, `POST /emails/{id}/cancel` | `email:send` | | `GET /emails`, `GET /emails/{id}`, `GET /emails/{id}/events` | `email:read` (hoặc `email:send` / `email:write`, vốn đã bao gồm quyền đọc) | | Tên miền, templates, contacts, segments, topics, chiến dịch email, sự kiện, automations | `email:write` | | `GET /emails/metrics` | `email:read` (hoặc `email:send` / `email:write`, vốn đã bao gồm quyền đọc) | | `GET /emails/receiving/*` | `email:read` hoặc `email:write`, cấp trực tiếp. Chỉ có `email:send` thì không đọc được thư đến. | | `GET /logs`, `GET /usage` | Bất kỳ phạm vi ghi nào | Key cũng có thể bị giới hạn vào một tên miền gửi. Khi gửi từ tên miền khác, key đó nhận lỗi `401 restricted_api_key`. ## Giới hạn chính | Giới hạn | Giá trị | |---|---| | Tốc độ gọi REST API | 10 request mỗi giây cho mỗi doanh nghiệp, dùng chung cho các endpoint email | | Người nhận mỗi email | 50, tính chung `to`, `cc` và `bcc` | | Email mỗi lệnh batch | 100 | | Kích thước email | 40 MB, gồm cả nội dung và tệp đính kèm | | Hẹn giờ | Tối đa 30 ngày tới | | Hạn mức ngày | Đặt riêng cho từng doanh nghiệp, đặt lại lúc nửa đêm UTC. Xem ở **Cài đặt email** hoặc `GET /usage`. | Danh sách đầy đủ ở [Giới hạn](https://hatang.dev/vi/docs/reference/limits.md), mọi mã lỗi ở [Lỗi](https://hatang.dev/vi/docs/reference/errors.md). ## Bước tiếp theo - [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md): cấu hình DKIM, SPF và DMARC. - [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md): mọi trường của `POST /emails`. - [SMTP relay](https://hatang.dev/vi/docs/guides/email/smtp.md): kết nối WordPress, Laravel, Django và các nền tảng khác. - [Chuyển từ Resend](https://hatang.dev/vi/docs/guides/email/migrate-from-resend.md): những gì thay đổi khi bạn chuyển sang. Source: https://hatang.dev/vi/docs/guides/email/overview --- # Nhận email Bật nhận email cho một tên miền đã xác minh, mọi thư gửi tới bất kỳ địa chỉ `@` tên miền đó sẽ được Hạ Tầng lưu lại và báo cho bạn qua webhook `email.received`. Sau đó bạn đọc nội dung, header và tệp đính kèm qua API. Cách làm giống Resend Receiving, nên code viết cho Resend chỉ cần đổi base URL và key. > **Tip: Hỗ trợ khách qua email?** > > Nếu muốn cả đội đọc và trả lời thư của khách trong một hộp thư chung, dùng [kênh Email hỗ trợ](https://hatang.dev/vi/help/channels/email-channel) thay vì tự xử lý webhook. ## Trước khi bắt đầu - Một tên miền đã xác minh. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). - API key được cấp trực tiếp `email:read` hoặc `email:write`. Key chỉ có `email:send` không đọc được thư đến, vì đó là thư của người khác. - Một endpoint nhận webhook. Xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## 1. Bật nhận email Trong dashboard, mở **Email → Tên miền**, chọn tên miền và bật **Nhận email**. Hoặc dùng API: **cURL** ```bash curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "capabilities": { "receiving": "enabled" } }' ``` **Node.js** ```ts await hatang.domains.update({ id: domainId, capabilities: { receiving: "enabled" } }); ``` **Python** ```python requests.patch( f"https://api.hatang.dev/domains/{domain_id}", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"capabilities": {"receiving": "enabled"}}, ) ``` **PHP** ```php $client->patch("/domains/$domainId", [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['capabilities' => ['receiving' => 'enabled']], ]); ``` ## 2. Thêm bản ghi MX Bảng bản ghi của tên miền có thêm dòng **Nhận thư**: một bản ghi MX ưu tiên 10 đặt trên chính tên miền. Thêm bản ghi đó ở nhà cung cấp DNS. Hạ Tầng kiểm tra bản ghi này cùng các bản ghi khác; nó không ảnh hưởng tới việc gửi. > **Caution: Tên miền đang nhận thư ở nơi khác** > > Bản ghi MX quyết định toàn bộ thư của tên miền đi đâu. Hạ Tầng không cho bật nhận email với tên miền có MX đang trỏ tới dịch vụ thư khác (Google Workspace, Microsoft 365…), để bạn không mất hộp thư hiện có, và với tên miền mà một hộp thư hỗ trợ trong Chat đang nhận. Hãy thêm một tên miền phụ như `inbound.shop.vn` ở **Email → Tên miền**, xác minh, rồi nhận thư ở đó. ## 3. Xử lý webhook `email.received` Tạo webhook (mục **Webhooks** trong dashboard, hoặc `POST /webhooks`) đăng ký sự kiện `email.received`. Webhook chỉ mang thông tin tóm tắt, không có nội dung thư: ```json { "type": "email.received", "created_at": "2026-10-14T09:12:03.000Z", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "created_at": "2026-10-14T09:12:03.000Z", "from": "Trần Lan ", "to": ["hotro@shop.vn"], "cc": [], "bcc": [], "message_id": "", "subject": "Đơn #1024 chưa nhận được", "attachments": [ { "id": "att_…", "filename": "hoa-don.pdf", "content_type": "application/pdf", "content_disposition": "attachment", "content_id": null, "size": 48213 } ] } } ``` - `bcc` là các địa chỉ trên tên miền của bạn đã nhận thư mà không có trong `To` hay `Cc`. - Thư bị dịch vụ nhận thư đánh dấu là spam vẫn được lưu (có `spam: true` khi đọc) nhưng không bắn webhook, giống thư mục spam. Thư có virus bị bỏ. - Webhook được ký như mọi webhook khác của Hạ Tầng. Hãy xác minh chữ ký trước khi tin; xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## 4. Đọc thư | Endpoint | Trả về | |---|---| | `GET /emails/receiving` | Danh sách thư đến, mới nhất trước (`limit`, `after`, `before`). | | `GET /emails/receiving/{id}` | Một thư: `text`, `html`, `headers`, `reply_to`, `in_reply_to`, `spam` và các tệp đính kèm. | | `GET /emails/receiving/{id}/attachments` | Các tệp đính kèm, mỗi tệp có `download_url`. | | `GET /emails/receiving/{id}/attachments/{attachment_id}` | Một tệp kèm `download_url`. | **cURL** ```bash curl https://api.hatang.dev/emails/receiving/$EMAIL_ID -H "Authorization: Bearer $HATANG_API_KEY" curl https://api.hatang.dev/emails/receiving/$EMAIL_ID/attachments -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); // Trong handler của webhook email.received: const { data: email } = await hatang.emails.receiving.get(event.data.email_id); console.log(email.subject, email.text); const { data: files } = await hatang.emails.receiving.attachments.list(email.id); for (const file of files.data) { const res = await fetch(file.download_url!); // lưu res.body vào nơi lưu trữ của bạn } ``` **Python** ```python headers = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"} email = requests.get(f"https://api.hatang.dev/emails/receiving/{email_id}", headers=headers).json() files = requests.get(f"https://api.hatang.dev/emails/receiving/{email_id}/attachments", headers=headers).json() for f in files["data"]: content = requests.get(f["download_url"]).content ``` **PHP** ```php $headers = ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')]; $email = json_decode($client->get("/emails/receiving/$emailId", ['headers' => $headers])->getBody(), true); $files = json_decode($client->get("/emails/receiving/$emailId/attachments", ['headers' => $headers])->getBody(), true); ``` `download_url` tải được mà không cần API key và hết hạn một giờ sau khi bạn lấy (`expires_at`), nên có thể chuyển thẳng cho trình duyệt hay một worker khác. Gọi lại endpoint tệp đính kèm để lấy URL mới. Trong dashboard, thư đến nằm ở **Email → Email → Nhận về**. ## Lưu trữ Thư đến và tệp đính kèm được giữ 30 ngày rồi tự xoá. Muốn giữ lâu hơn, hãy chép sang nơi lưu trữ của bạn khi nhận webhook. ## Bước tiếp theo - [Nhóm email](https://hatang.dev/vi/docs/guides/email/groups.md): chuyển thư tới một địa chỉ như team@congty.vn cho nhiều người - [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) - [Kênh Email hỗ trợ](https://hatang.dev/vi/help/channels/email-channel) - [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) Source: https://hatang.dev/vi/docs/guides/email/receiving --- # Gửi email `POST /emails` gửi một email tới tối đa 50 người nhận. `POST /emails/batch` gửi tối đa 100 email khác nhau trong một lần gọi. Trang này đi qua từng trường, response, hẹn giờ, idempotency, các giới hạn, và cách theo dõi một email qua các trạng thái và sự kiện. ## Trước khi bắt đầu - Một tên miền đã xác minh. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). - API key có phạm vi `email:send`. Xem [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md). - Request gửi tới `https://api.hatang.dev` với header `Authorization: Bearer ht_...` và `Content-Type: application/json`. ## Gửi một email **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1024-confirmation" \ -d '{ "from": "Shop ", "to": ["lan@example.vn"], "cc": ["ketoan@shop.vn"], "reply_to": "hotro@shop.vn", "subject": "Đơn #1024 đã được xác nhận", "html": "

Chào Lan, đơn #1024 của bạn đã được xác nhận.

", "text": "Chào Lan, đơn #1024 của bạn đã được xác nhận.", "tags": [{ "name": "category", "value": "order_confirmation" }], "headers": { "X-Order-Id": "1024" } }' ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send( { from: "Shop ", to: ["lan@example.vn"], cc: ["ketoan@shop.vn"], reply_to: "hotro@shop.vn", subject: "Đơn #1024 đã được xác nhận", html: "

Chào Lan, đơn #1024 của bạn đã được xác nhận.

", text: "Chào Lan, đơn #1024 của bạn đã được xác nhận.", tags: [{ name: "category", value: "order_confirmation" }], headers: { "X-Order-Id": "1024" }, }, { idempotencyKey: "order-1024-confirmation" }, ); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.id); ``` **Python** ```python from hatang import Hatang hatang = Hatang() # đọc HATANG_API_KEY email = hatang.emails.send( { "from": "Shop ", "to": ["lan@example.vn"], "cc": ["ketoan@shop.vn"], "reply_to": "hotro@shop.vn", "subject": "Đơn #1024 đã được xác nhận", "html": "

Chào Lan, đơn #1024 của bạn đã được xác nhận.

", "text": "Chào Lan, đơn #1024 của bạn đã được xác nhận.", "tags": [{"name": "category", "value": "order_confirmation"}], "headers": {"X-Order-Id": "1024"}, }, idempotency_key="order-1024-confirmation", ) print(email["id"]) # gọi lỗi thì ném hatang.HatangError ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/emails', [ 'headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Idempotency-Key' => 'order-1024-confirmation', ], 'json' => [ 'from' => 'Shop ', 'to' => ['lan@example.vn'], 'cc' => ['ketoan@shop.vn'], 'reply_to' => 'hotro@shop.vn', 'subject' => 'Đơn #1024 đã được xác nhận', 'html' => '

Chào Lan, đơn #1024 của bạn đã được xác nhận.

', 'text' => 'Chào Lan, đơn #1024 của bạn đã được xác nhận.', 'tags' => [['name' => 'category', 'value' => 'order_confirmation']], 'headers' => ['X-Order-Id' => '1024'], ], ]); echo json_decode($res->getBody(), true)['id']; ``` **Go** ```go body := strings.NewReader(`{ "from": "Shop ", "to": ["lan@example.vn"], "subject": "Đơn #1024 đã được xác nhận", "html": "

Đơn #1024 của bạn đã được xác nhận.

" }`) req, _ := http.NewRequest("POST", "https://api.hatang.dev/emails", body) req.Header.Set("Authorization", "Bearer "+os.Getenv("HATANG_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "order-1024-confirmation") res, err := http.DefaultClient.Do(req) ``` Response là `200` kèm id của email mới (một UUID): ```json { "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10" } ``` `200` nghĩa là Hạ Tầng đã nhận email và đưa vào hàng đợi. Việc giao thư diễn ra sau đó; theo dõi bằng [trạng thái và sự kiện](#statuses-and-events). ## Các trường của request | Trường | Kiểu | Mô tả | |---|---|---| | `from` | string | Người gửi, `hello@shop.vn` hoặc `Shop `. Tên miền phải đã xác minh. Chỉ được bỏ trống khi `template` đã có sẵn. | | `to` | string hoặc mảng | Người nhận. | | `cc` | string hoặc mảng | Người nhận đồng gửi. | | `bcc` | string hoặc mảng | Người nhận ẩn. Không bao giờ xuất hiện trong header thư. | | `reply_to` | string hoặc mảng | Địa chỉ Reply-To, tối đa 50. | | `subject` | string | Tối đa 998 ký tự. Bắt buộc, trừ khi `template` đã có sẵn. | | `html` | string | Nội dung HTML, tối đa 10.000.000 ký tự. | | `text` | string | Nội dung văn bản thuần, tối đa 10.000.000 ký tự. | | `template` | object | `{ "id": "tpl_… hoặc alias", "variables": { … } }` gửi một template đã publish thay cho `html`/`text`. Xem [Templates](https://hatang.dev/vi/docs/guides/email/templates.md). | | `headers` | object | Header thêm, ví dụ `{ "X-Order-Id": "1024" }`. | | `tags` | mảng | Tối đa 50 cặp `{ "name", "value" }`, được gửi kèm trong webhook. | | `attachments` | mảng | Tối đa 50 tệp. Xem [Tệp đính kèm](#attachments). | | `scheduled_at` | string | Gửi sau. Xem [Hẹn giờ gửi](#schedule-an-email). | | `topic_id` | string | Bỏ qua người nhận đã rời topic này. Xem [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md). | Phải có `html`, `text` hoặc `template`. Request có `template` thì không được có thêm `html` hay `text`. > **Tip: Gửi cả HTML lẫn văn bản thuần** > > Hạ Tầng chỉ tự tạo phần văn bản thuần từ HTML khi gửi bằng template. Khi gửi trực tiếp, hãy truyền cả `html` và `text`: một số ứng dụng email và bộ lọc spam có xem phần văn bản. ### Người nhận - Địa chỉ viết dạng thường (`lan@example.vn`) hoặc kèm tên hiển thị (`Lan `). - `to`, `cc` và `bcc` cộng lại tối đa **50** địa chỉ. Muốn gửi cho nhiều người hơn, gửi mỗi người một email (bằng `/emails/batch`) hoặc dùng [chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md). - Mỗi người nhận được giao và theo dõi riêng. Người nhận nằm trong [danh sách chặn](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md) bị bỏ qua và được báo là bị trả lại. ### Header Dùng `headers` cho các header riêng như `X-Order-Id` hay `List-Unsubscribe`. Hạ Tầng bỏ các header do chính nó đặt: `From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `Content-Type`, `Content-Transfer-Encoding`, `MIME-Version`, `Return-Path`, `Received`, `DKIM-Signature` và `Authentication-Results`. Mọi thư còn có header `X-Entity-Ref-ID` chứa id của email. ### Tag Tag dùng để đánh dấu email cho hệ thống của bạn. `name` và `value` mỗi trường dài 1 tới 256 ký tự, chỉ gồm chữ cái ASCII, số, `_` và `-`. Trong webhook, tag có dạng object, ví dụ `"tags": { "category": "order_confirmation" }`. ### Tệp đính kèm {#attachments} Gửi nội dung mỗi tệp dạng base64 trong `content`: ```json { "attachments": [ { "filename": "hoa-don-1024.pdf", "content": "JVBERi0xLjcKJc…", "content_type": "application/pdf" }, { "filename": "logo.png", "content": "iVBORw0KGgo…", "content_type": "image/png", "content_id": "logo" } ] } ``` | Trường | Mô tả | |---|---| | `filename` | Tên tệp người nhận thấy, tối đa 255 ký tự. | | `content` | Tệp dạng chuỗi base64. SDK Node.js nhận cả `Buffer` hoặc `Uint8Array` và tự mã hoá. | | `content_type` | Kiểu MIME. Không bắt buộc. | | `content_id` | Đặt để hiển thị tệp ngay trong nội dung: trong HTML viết ``. | > **Caution: Chưa hỗ trợ `path`** > > Tệp đính kèm theo URL (`path`) bị từ chối với lỗi `422 invalid_attachment`. Hãy tự tải tệp về rồi gửi bằng `content`. Toàn bộ email, gồm nội dung và tệp đính kèm sau khi giải mã, phải dưới **40 MB**. ### React Email SDK Node.js nhận một phần tử `react` và render ra `html` và `text` trước khi gửi request (bản thân API chỉ nhận `html` và `text`). Cài thêm `@react-email/render` và `react` cạnh SDK: ```tsx import { Hatang } from "@hatang/sdk"; import { WelcomeEmail } from "./emails/welcome"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); await hatang.emails.send({ from: "Shop ", to: "lan@example.vn", subject: "Chào mừng bạn đến với Shop", react: , }); ``` ## Hẹn giờ gửi {#schedule-an-email} Đặt `scheduled_at` là một thời điểm ISO 8601 có múi giờ, hoặc một cụm như `in 1 hour`, `in 30 minutes`, `in 2 days`. Thời điểm ISO tối đa 30 ngày tới. ```json { "from": "Shop ", "to": ["lan@example.vn"], "subject": "Ngày mai hết hạn dùng thử", "html": "

…

", "scheduled_at": "2026-10-20T09:00:00+07:00" } ``` Email có trạng thái `scheduled`. Trước khi gửi, bạn có thể dời giờ hoặc huỷ: **cURL** ```bash # Dời giờ curl -X PATCH https://api.hatang.dev/emails/$EMAIL_ID \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "in 3 hours" }' # Huỷ curl -X POST https://api.hatang.dev/emails/$EMAIL_ID/cancel -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts await hatang.emails.update({ id: emailId, scheduledAt: "in 3 hours" }); await hatang.emails.cancel(emailId); ``` **Python** ```python hatang.emails.update(email_id, scheduled_at="in 3 hours") hatang.emails.cancel(email_id) ``` **PHP** ```php $headers = ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')]; $client->patch("/emails/$emailId", ['headers' => $headers, 'json' => ['scheduled_at' => 'in 3 hours']]); $client->post("/emails/$emailId/cancel", ['headers' => $headers]); ``` Cả hai trả về `{ "object": "email", "id": "…" }`. Dời giờ hoặc huỷ một email không còn ở trạng thái `scheduled` sẽ nhận lỗi `422`. Email đã huỷ có trạng thái `canceled`. ## Gửi theo lô `POST /emails/batch` nhận một mảng từ 1 tới 100 email. Mỗi phần tử có các trường giống `POST /emails`, trừ `attachments` và `scheduled_at` không được dùng. **cURL** ```bash curl -X POST https://api.hatang.dev/emails/batch \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '[ { "from": "Shop ", "to": ["lan@example.vn"], "subject": "Chào Lan", "html": "

Chào Lan

" }, { "from": "Shop ", "to": ["minh@example.vn"], "subject": "Chào Minh", "html": "

Chào Minh

" } ]' ``` **Node.js** ```ts const { data, error } = await hatang.batch.send([ { from: "Shop ", to: ["lan@example.vn"], subject: "Chào Lan", html: "

Chào Lan

" }, { from: "Shop ", to: ["minh@example.vn"], subject: "Chào Minh", html: "

Chào Minh

" }, ]); ``` **Python** ```python result = hatang.batch.send([ {"from": "Shop ", "to": ["lan@example.vn"], "subject": "Chào Lan", "html": "

Chào Lan

"}, {"from": "Shop ", "to": ["minh@example.vn"], "subject": "Chào Minh", "html": "

Chào Minh

"}, ]) ``` **PHP** ```php $res = $client->post('/emails/batch', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ ['from' => 'Shop ', 'to' => ['lan@example.vn'], 'subject' => 'Chào Lan', 'html' => '

Chào Lan

'], ['from' => 'Shop ', 'to' => ['minh@example.vn'], 'subject' => 'Chào Minh', 'html' => '

Chào Minh

'], ], ]); ``` ```json { "data": [{ "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10" }, { "id": "0199e6c1-7a2c-7c41-8f0e-61f1b2a3c4d5" }] } ``` Các id theo đúng thứ tự các phần tử. > **Caution: Gửi theo lô không phải "tất cả hoặc không gì cả"** > > Hạ Tầng kiểm tra cấu trúc của mọi phần tử trước khi gửi. Những kiểm tra cần tới tài khoản của bạn (tên miền của `from`, template, hạn mức ngày) chạy lần lượt từng phần tử trong lúc gửi, nên nếu phần tử thứ 5 lỗi thì phần tử 1 tới 4 đã được gửi và lệnh gọi trả về lỗi. Trước một lô lớn, hãy chắc chắn mọi tên miền `from` đã xác minh và hạn mức còn đủ. Endpoint batch không hỗ trợ `Idempotency-Key`. ## Idempotency {#idempotency} Lỗi mạng là chuyện thường. Để gọi lại `POST /emails` an toàn, gửi kèm header `Idempotency-Key` (trong SDK Node.js là tuỳ chọn `idempotencyKey`). Dùng một key định danh thư trong hệ thống của bạn, ví dụ `order-1024-confirmation`. | Tình huống | Kết quả | |---|---| | Request đầu tiên với key | Email được tạo và gửi. | | Cùng key, cùng nội dung | `200` kèm id của email đầu tiên. Không gửi lại và không trừ hạn mức. | | Cùng key, nội dung khác | `400 invalid_idempotent_request` | | Cùng key khi request đầu còn đang chạy | `409 concurrent_idempotent_requests`. Đợi một chút rồi thử lại. | Key thuộc về doanh nghiệp của bạn: hai doanh nghiệp khác nhau dùng cùng một key không xung đột. ## Giới hạn | Giới hạn | Giá trị | |---|---| | Người nhận mỗi email | 50, tính chung `to`, `cc` và `bcc` | | Địa chỉ `reply_to` | 50 | | Tiêu đề | 998 ký tự | | `html`, `text` | Mỗi trường 10.000.000 ký tự | | Tệp đính kèm | 50 tệp mỗi email | | Kích thước email | 40 MB, gồm nội dung và tệp đính kèm | | Tag | 50 tag mỗi email, mỗi tên hoặc giá trị tối đa 256 ký tự | | Gửi theo lô | 100 email mỗi lần gọi | | Hẹn giờ | Tối đa 30 ngày tới | | Tốc độ gọi | 10 request mỗi giây cho mỗi doanh nghiệp. Response có header `ratelimit-limit`, `ratelimit-remaining` và `ratelimit-reset`; lỗi `429` có thêm `retry-after`. | | Hạn mức ngày | Mỗi email (mỗi phần tử của lô, mỗi email của chiến dịch) tính là một. Đặt lại lúc nửa đêm UTC. `GET /usage` cho biết còn bao nhiêu. | ## Lỗi Lỗi ở mọi nơi có cùng một dạng: ```json { "statusCode": 403, "name": "validation_error", "message": "The shop.vn domain is not verified. Please, add and verify your domain first." } ``` | Mã | `name` | Khi nào | |---|---|---| | 422 | `validation_error` | Thiếu hoặc sai trường, ví dụ không có `html`/`text`/`template`, quá 50 người nhận, hoặc `scheduled_at` quá 30 ngày. | | 403 | `validation_error` | Tên miền của `from` chưa được thêm vào doanh nghiệp hoặc chưa xác minh. | | 401 | `restricted_api_key` | API key bị giới hạn vào tên miền gửi khác. | | 422 | `invalid_attachment` | Tệp đính kèm dùng `path`. | | 403 | `application_error` | Doanh nghiệp đang tạm dừng gửi (**Cài đặt email**). | | 429 | `rate_limit_exceeded` | Quá 10 request mỗi giây. | | 429 | `daily_quota_exceeded` | Đã dùng hết hạn mức ngày. | | 400 | `invalid_idempotent_request` | Idempotency key đã được dùng với nội dung khác. | | 409 | `concurrent_idempotent_requests` | Một request khác cùng idempotency key đang chạy. | Danh sách đầy đủ ở [Lỗi](https://hatang.dev/vi/docs/reference/errors.md). ## Xem và liệt kê email `GET /emails/{id}` trả về một email: ```json { "object": "email", "id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "to": ["lan@example.vn"], "from": "Shop ", "created_at": "2026-10-14T09:12:03.000Z", "subject": "Đơn #1024 đã được xác nhận", "html": "

Chào Lan, …

", "text": "Chào Lan, …", "bcc": null, "cc": ["ketoan@shop.vn"], "reply_to": ["hotro@shop.vn"], "last_event": "delivered", "status": "delivered", "scheduled_at": null, "tags": [{ "name": "category", "value": "order_confirmation" }], "headers": { "X-Order-Id": "1024" } } ``` `GET /emails` liệt kê email, mới nhất trước: | Tham số query | Mô tả | |---|---| | `limit` | 1 tới 100, mặc định 20. | | `after`, `before` | Con trỏ phân trang: truyền id của email cuối (hoặc đầu) của trang đang có. | | `status` | Một hoặc nhiều trạng thái cách nhau bởi dấu phẩy, ví dụ `bounced,complained`. | | `source` | `api`, `smtp`, `broadcast`, `chat` hoặc `system`. | | `q` | Tìm theo tiêu đề, người gửi, địa chỉ `to`, hoặc đúng id email. | `GET /emails/{id}/events` trả về mọi sự kiện của email và trạng thái của từng người nhận, kèm phản hồi SMTP gần nhất: ```json { "object": "list", "data": [ { "id": "evt_…", "type": "email.sent", "created_at": "2026-10-14T09:12:03.000Z", "data": { "source": "api" } }, { "id": "evt_…", "type": "email.delivered", "created_at": "2026-10-14T09:12:05.000Z", "data": { "address": "lan@example.vn", "code": 250 } } ], "recipients": [ { "address": "lan@example.vn", "kind": "to", "status": "delivered", "attempts": 1, "last_smtp_code": 250, "last_smtp_response": "250 Ok", "next_attempt_at": null } ] } ``` ## Trạng thái và sự kiện {#statuses-and-events} `status` của email tổng hợp từ các người nhận: | Trạng thái | Ý nghĩa | |---|---| | `scheduled` | Đang chờ tới `scheduled_at`. | | `queued` | Đã nhận, đang chờ chuyển cho máy chủ thư. | | `sent` | Đã chuyển đi; còn ít nhất một người nhận chưa có kết quả cuối. | | `delivered` | Mọi người nhận được gửi tới đều đã nhận được thư. Khi một số người nhận bị trả lại còn số khác đã nhận, trạng thái cũng là `delivered`; xem chi tiết ở từng người nhận. | | `delivery_delayed` | Đang thử giao lại cho ít nhất một người nhận. | | `bounced` | Không giao được cho người nhận nào, kể cả khi mọi người nhận đều nằm trong danh sách chặn. | | `complained` | Một người nhận đánh dấu email là spam. | | `canceled` | Email hẹn giờ đã bị huỷ. | | `failed` | Hạ Tầng không gửi được email. | Mỗi người nhận có trạng thái riêng trong `GET /emails/{id}/events`: `pending`, `sent`, `delivered`, `delayed`, `bounced`, `suppressed` hoặc `canceled`. Các sự kiện webhook sau theo dõi một email. Đăng ký ở mục **Webhooks** trong dashboard hoặc bằng `POST /webhooks`; xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). | Sự kiện | Khi nào | |---|---| | `email.sent` | Hạ Tầng đã nhận email. | | `email.delivered` | Máy chủ thư của người nhận đã nhận thư. | | `email.delivery_delayed` | Đang thử giao lại. Gửi ở lần chậm đầu tiên, sau đó tối đa mỗi giờ một lần cho mỗi người nhận. | | `email.bounced` | Một người nhận bị trả lại, hoặc bị bỏ qua vì địa chỉ nằm trong danh sách chặn (`bounce.subType` là `Suppressed`). | | `email.complained` | Một người nhận báo email là spam. | | `email.opened` | Một người nhận mở email. Cần bật theo dõi mở; tối đa mỗi giờ một lần cho mỗi người nhận. | | `email.clicked` | Một người nhận nhấp vào link. Cần bật theo dõi nhấp. | | `email.failed` | Không gửi được email. | Webhook bị trả lại có kèm lý do: ```json { "type": "email.bounced", "created_at": "2026-10-14T09:12:07.000Z", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "from": "Shop ", "to": ["lan@example.vn"], "subject": "Đơn #1024 đã được xác nhận", "created_at": "2026-10-14T09:12:03.000Z", "tags": { "category": "order_confirmation" }, "bounce": { "type": "Permanent", "subType": "NoEmail", "message": "550 5.1.1 The email account that you tried to reach does not exist." } } } ``` ## Bước tiếp theo - [Templates](https://hatang.dev/vi/docs/guides/email/templates.md): gửi bằng id template kèm biến. - [SMTP relay](https://hatang.dev/vi/docs/guides/email/smtp.md): gửi từ ứng dụng chỉ biết SMTP. - [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md): điều gì xảy ra sau khi thư bị trả lại. - [Tham chiếu REST API](https://hatang.dev/vi/docs/reference/api.md) Source: https://hatang.dev/vi/docs/guides/email/sending --- # SMTP relay SMTP relay cho phép mọi ứng dụng biết gửi thư qua SMTP dùng Hạ Tầng: WordPress, Laravel, Django, Rails, Spring Boot, một server Postfix, thậm chí máy in. Thư đi qua đúng các bước kiểm tra như `POST /emails`, nên cũng hiện ở **Email → Email** với trạng thái, sự kiện và webhook. ## Trước khi bắt đầu - Một tên miền đã xác minh cho địa chỉ `From`. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). - API key có phạm vi `email:send`. Key này chính là mật khẩu SMTP. ## Thông tin kết nối | Thiết lập | Giá trị | |---|---| | Host | `smtp.hatang.dev` | | Cổng | `5870` | | Mã hoá | STARTTLS (kết nối bắt đầu ở dạng thường rồi nâng cấp bằng lệnh `STARTTLS`) | | Tên đăng nhập | `hatang` | | Mật khẩu | API key của bạn, `ht_...` | | Xác thực | `PLAIN` hoặc `LOGIN` | Các giá trị này cũng có ở **Cài đặt email → Thông tin kết nối** trong dashboard. > **Caution: Cổng 5870 dùng STARTTLS, không phải TLS ngầm định** > > Đừng bật "SSL", "implicit TLS" hay `secure: true` cho cổng 5870: client sẽ bắt tay TLS trước khi server chào và bị treo cho tới khi hết thời gian chờ. Hãy chọn STARTTLS, có ứng dụng gọi là "TLS". Server từ chối xác thực cho tới khi kết nối đã được nâng cấp bằng `STARTTLS`, nên API key không bao giờ đi qua mạng ở dạng rõ. ## Hạ Tầng đọc thư thế nào {#hatang-đọc-thư-thế-nào} | Phần của thư | Hạ Tầng xử lý ra sao | |---|---| | Header `From` | Người gửi. Tên miền phải đã xác minh và thuộc doanh nghiệp của API key. Không có header `From` thì dùng envelope sender (`MAIL FROM`). | | Header `To` và `Cc` | Người nhận `to` và `cc`. | | Người nhận trong envelope (`RCPT TO`) không có trong `To` hay `Cc` | Gửi dưới dạng `bcc`. | | `Reply-To`, `Subject`, phần HTML và văn bản, tệp đính kèm, ảnh nhúng | Giữ nguyên. | | `Message-ID` | Giữ nguyên, nên các chuỗi thư trong hệ thống của bạn vẫn khớp. | | Header bắt đầu bằng `X-` | Chuyển tiếp như header riêng, giống trường `headers` của API. Các header riêng khác bị bỏ. | | `X-Tags` | Được đọc thành tag và không chuyển tiếp. Giá trị là một mảng JSON: `X-Tags: [{"name":"category","value":"welcome"}]`. Giá trị không phải JSON hợp lệ bị bỏ qua. | > **Tip: Luôn điền `To`** > > Nếu thư chỉ có người nhận Bcc, Hạ Tầng chuyển người đầu tiên sang `To`, nơi mọi người nhận khác đều thấy. Hãy đặt ít nhất một địa chỉ trong header `To`. ## Giới hạn | Giới hạn | Giá trị | |---|---| | Người nhận mỗi thư | 50 người nhận trong envelope (`RCPT TO`) | | Kích thước thư | Thư trên 40 MB bị từ chối. | | Đăng nhập sai | Sau 10 lần đăng nhập sai từ một địa chỉ IP, mọi lần đăng nhập từ địa chỉ đó bị từ chối cho tới khi đủ 15 phút kể từ lần sai cuối cùng. | | Hạn mức ngày | Dùng chung với API: mỗi thư tính là một email. | ## Mã phản hồi SMTP | Phản hồi | Mã mở rộng | Ý nghĩa | Cần làm gì | |---|---|---|---| | `250` | `2.0.0` | Đã nhận. Phần chữ kết thúc bằng `queued as `. | Không cần làm gì. | | `530` | `5.7.0` | Cần đăng nhập. | Đăng nhập trước lệnh `MAIL FROM`. | | `535` | `5.7.8` | API key sai hoặc đã bị thu hồi. | Kiểm tra mật khẩu. | | `421` | `4.7.0` | Quá nhiều lần đăng nhập sai từ địa chỉ IP của bạn. | Sửa mật khẩu và chờ rồi mới thử lại. | | `452` | `5.5.3` | Quá 50 người nhận. | Tách thư ra. | | `550` | `5.7.1` | Tên miền của `From` chưa xác minh hoặc không thuộc doanh nghiệp, API key bị giới hạn vào tên miền khác, hoặc doanh nghiệp đang tạm dừng gửi. Phần chữ nói rõ lý do. | Xác minh tên miền hoặc sửa địa chỉ `From`. | | `553` | `5.6.0` | Thư bị từ chối vì không hợp lệ, ví dụ header `From` sai. | Sửa thư. Gửi lại y nguyên sẽ lại lỗi. | | `451` | `4.3.0` | Lỗi tạm thời, kể cả khi đã hết hạn mức ngày. | Thử lại sau. Các mail server như Postfix tự thử lại. | ## Kiểm tra kết nối Kiểm tra STARTTLS hoạt động: ```bash openssl s_client -starttls smtp -connect smtp.hatang.dev:5870 -crlf -quiet ``` Gửi thư thử bằng [swaks](https://github.com/jetmore/swaks): ```bash swaks --server smtp.hatang.dev:5870 --tls \ --auth PLAIN --auth-user hatang --auth-password "$HATANG_API_KEY" \ --from hello@shop.vn --to ban@example.vn \ --header "Subject: Thử SMTP" --body "Chạy rồi." ``` Hoặc bằng thư viện chuẩn của Python: ```python import os import smtplib from email.message import EmailMessage msg = EmailMessage() msg["From"] = "Shop " msg["To"] = "ban@example.vn" msg["Subject"] = "Thử SMTP" msg["X-Tags"] = '[{"name":"category","value":"test"}]' msg.set_content("Chạy rồi.") with smtplib.SMTP("smtp.hatang.dev", 5870) as smtp: smtp.starttls() smtp.login("hatang", os.environ["HATANG_API_KEY"]) smtp.send_message(msg) ``` ## Cấu hình mẫu ### Nodemailer ```js import nodemailer from "nodemailer"; const transporter = nodemailer.createTransport({ host: "smtp.hatang.dev", port: 5870, secure: false, // bắt buộc là false: cổng 5870 dùng STARTTLS requireTLS: true, // không gửi nếu không nâng cấp được STARTTLS auth: { user: "hatang", pass: process.env.HATANG_API_KEY }, }); const info = await transporter.sendMail({ from: "Shop ", to: "lan@example.vn", subject: "Chào mừng bạn đến với Shop", html: "

Chào mừng!

", text: "Chào mừng!", headers: { "X-Tags": JSON.stringify([{ name: "category", value: "welcome" }]) }, }); console.log(info.response); // 250 2.0.0 Ok: queued as ``` `secure: true` nghĩa là TLS ngầm định và không chạy trên cổng 5870. ### Laravel Trong `.env`: ```bash MAIL_MAILER=smtp MAIL_HOST=smtp.hatang.dev MAIL_PORT=5870 MAIL_USERNAME=hatang MAIL_PASSWORD=ht_123... MAIL_FROM_ADDRESS=hello@shop.vn MAIL_FROM_NAME="Shop" ``` Mailer của Laravel tự nâng cấp kết nối bằng STARTTLS khi server hỗ trợ, và `smtp.hatang.dev` luôn hỗ trợ. Để trống `MAIL_SCHEME` hoặc đặt là `smtp`; đừng dùng `smtps`. Với Laravel 10 trở về trước, đặt `MAIL_ENCRYPTION=tls`. ### Django Trong `settings.py`: ```python import os EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "smtp.hatang.dev" EMAIL_PORT = 5870 EMAIL_HOST_USER = "hatang" EMAIL_HOST_PASSWORD = os.environ["HATANG_API_KEY"] EMAIL_USE_TLS = True # STARTTLS EMAIL_USE_SSL = False # TLS ngầm định: phải tắt DEFAULT_FROM_EMAIL = "Shop " ``` ### Ruby on Rails Trong `config/environments/production.rb`: ```ruby config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: "smtp.hatang.dev", port: 5870, user_name: "hatang", password: ENV.fetch("HATANG_API_KEY"), authentication: :plain, enable_starttls_auto: true } ``` ### Spring Boot Trong `application.properties`: ```text spring.mail.host=smtp.hatang.dev spring.mail.port=5870 spring.mail.username=hatang spring.mail.password=${HATANG_API_KEY} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.smtp.starttls.required=true ``` ### WordPress (WP Mail SMTP) Cài plugin [WP Mail SMTP](https://wordpress.org/plugins/wp-mail-smtp/), mở **WP Mail SMTP → Settings** và chọn mailer **Other SMTP**: | Thiết lập | Giá trị | |---|---| | From Email | Một địa chỉ trên tên miền đã xác minh, ví dụ `hello@shop.vn`. Bật **Force From Email**. | | SMTP Host | `smtp.hatang.dev` | | Encryption | **TLS** (trong WP Mail SMTP, đây là STARTTLS) | | SMTP Port | `5870` | | Auto TLS | Bật | | Authentication | Bật | | SMTP Username | `hatang` | | SMTP Password | API key của bạn | Để không lưu key trong database, khai báo trong `wp-config.php`: ```php define( 'WPMS_ON', true ); define( 'WPMS_MAILER', 'smtp' ); define( 'WPMS_SMTP_HOST', 'smtp.hatang.dev' ); define( 'WPMS_SMTP_PORT', 5870 ); define( 'WPMS_SSL', 'tls' ); define( 'WPMS_SMTP_AUTH', true ); define( 'WPMS_SMTP_USER', 'hatang' ); define( 'WPMS_SMTP_PASS', 'ht_123...' ); define( 'WPMS_MAIL_FROM', 'hello@shop.vn' ); define( 'WPMS_MAIL_FROM_FORCE', true ); ``` Sau đó gửi thử ở **WP Mail SMTP → Tools → Email Test**. ### PHPMailer ```php isSMTP(); $mail->Host = 'smtp.hatang.dev'; $mail->Port = 5870; $mail->SMTPAuth = true; $mail->Username = 'hatang'; $mail->Password = getenv('HATANG_API_KEY'); $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->CharSet = 'UTF-8'; $mail->setFrom('hello@shop.vn', 'Shop'); $mail->addAddress('lan@example.vn'); $mail->Subject = 'Chào mừng bạn đến với Shop'; $mail->isHTML(true); $mail->Body = '

Chào mừng!

'; $mail->AltBody = 'Chào mừng!'; $mail->addCustomHeader('X-Tags', '[{"name":"category","value":"welcome"}]'); $mail->send(); ``` ### Postfix relayhost Để mọi thư từ một server đi qua Hạ Tầng, đặt Hạ Tầng làm relay host trong `/etc/postfix/main.cf`: ```text relayhost = [smtp.hatang.dev]:5870 smtp_sasl_auth_enable = yes smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd smtp_sasl_security_options = noanonymous smtp_tls_security_level = encrypt ``` Ghi thông tin đăng nhập vào `/etc/postfix/sasl_passwd`: ```text [smtp.hatang.dev]:5870 hatang:ht_123... ``` Sau đó khoá quyền tệp và nạp lại: ```bash sudo chmod 600 /etc/postfix/sasl_passwd sudo postmap /etc/postfix/sasl_passwd sudo systemctl reload postfix ``` Chỉ thư có địa chỉ `From` trên tên miền đã xác minh mới được nhận. Hãy đổi người gửi của thư hệ thống (ví dụ bằng `smtp_generic_maps`) để `root@localhost` thành một địa chỉ trên tên miền của bạn. ## Bước tiếp theo - [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md): REST API, có gửi theo lô, hẹn giờ và idempotency. - [Theo dõi mở và nhấp](https://hatang.dev/vi/docs/guides/email/tracking.md): theo dõi cũng áp dụng cho thư gửi qua SMTP. - [Danh sách chặn và thư trả lại](https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces.md) Source: https://hatang.dev/vi/docs/guides/email/smtp --- # Danh sách chặn và thư trả lại Gửi tiếp tới một địa chỉ không tồn tại, hoặc tới người đã báo thư của bạn là spam, làm uy tín gửi thư giảm rất nhanh. Hạ Tầng giữ một **danh sách chặn** cho mỗi doanh nghiệp và tự động ngừng gửi tới các địa chỉ trong đó. Trang này giải thích điều gì đưa địa chỉ vào danh sách, điều gì xảy ra khi bạn gửi tới địa chỉ bị chặn, và cách xem, gỡ địa chỉ. ## Điều gì thêm địa chỉ vào danh sách | Lý do | Nhãn trong dashboard | Thêm khi | |---|---|---| | `hard_bounce` | trả lại cứng | Thư bị trả lại vĩnh viễn vì hộp thư không tồn tại: thư trả lại có loại phụ `NoEmail` hoặc mã trạng thái SMTP bắt đầu bằng `5.1.` (ví dụ `550 5.1.1 User unknown`). | | `complaint` | báo spam | Người nhận đánh dấu thư là spam trong ứng dụng email. | | `unsubscribe` | huỷ đăng ký | Một contact huỷ đăng ký mọi email: trên trang tuỳ chọn, bằng nút huỷ đăng ký một chạm của chiến dịch không gắn topic, qua API (`"unsubscribed": true`), bằng bước `update_contact` của automation, hoặc khi nhập CSV. | | `manual` | thủ công | Ai đó trong đội thêm địa chỉ ở tab **Danh sách chặn**. | Danh sách thuộc về doanh nghiệp và áp dụng cho mọi email bạn gửi: qua API, SMTP relay, chiến dịch email và automations. Địa chỉ được so sánh ở dạng chữ thường. ### Thư trả lại không đưa vào danh sách chặn Không phải thư trả lại nào cũng có nghĩa là địa chỉ hỏng. Những trường hợp sau được ghi trên email nhưng **không** đưa địa chỉ vào danh sách: - **Trả lại tạm thời (soft bounce)**: hộp thư đầy, máy chủ đang lỗi, thư quá lớn. Webhook `email.bounced` có `bounce.type` là `Transient`. - **Từ chối vĩnh viễn vì nội dung thư chứ không phải vì địa chỉ**: ví dụ bị chặn theo chính sách hoặc nội dung (`5.7.x`). Webhook có `bounce.type` là `Permanent` với loại phụ như `Policy` hoặc `ContentRejected`. Hãy theo dõi các trường hợp này qua webhook và số liệu: từ chối theo chính sách tăng lên thường cho thấy vấn đề về nội dung hoặc uy tín. Xem [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md). ## Điều gì xảy ra khi gửi tới địa chỉ bị chặn Hạ Tầng vẫn nhận request, rồi bỏ qua người nhận đó: - Trạng thái của người nhận là `suppressed`, và không có gì được gửi đi. - Webhook `email.bounced` bắn cho người nhận đó, với `bounce.subType` là `Suppressed`: ```json { "type": "email.bounced", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "to": ["dia-chi-cu@example.vn"], "subject": "Hoá đơn của bạn", "bounce": { "type": "Permanent", "subType": "Suppressed", "message": "Recipient dia-chi-cu@example.vn is on the suppression list (hard_bounce)" } } } ``` - Khi mọi người nhận của email đều bị chặn, email có trạng thái `bounced` ngay lập tức. - Email vẫn tính vào hạn mức ngày. - Trong [số liệu](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md), những người nhận này được tính ở `suppressed`, và sự kiện `email.bounced` của họ cũng được tính ở `bounced`. Hãy làm sạch danh sách để chúng không đẩy tỉ lệ bị trả lại lên. Người nhận bị bỏ qua vì đã rời một `topic_id` cũng được báo theo cách này (lý do là `topic_opt_out`), nhưng không bị đưa vào danh sách chặn. > **Note: Huỷ đăng ký chặn cả email giao dịch** > > Mục `unsubscribe` chặn mọi email tới địa chỉ đó, kể cả hoá đơn hay email đặt lại mật khẩu gửi qua `POST /emails`. Nếu ai đó chỉ muốn nhận ít email marketing hơn, hãy để họ rời một [topic](https://hatang.dev/vi/docs/guides/email/audiences.md) thay vì huỷ đăng ký mọi email. ## Xem danh sách Mở **Email → Danh sách nhận → Danh sách chặn**. Danh sách hiển thị từng địa chỉ, lý do, ghi chú (với thư trả lại cứng là thông báo từ máy chủ nhận) và thời điểm thêm. Hiện chưa có API cho danh sách chặn. Để đồng bộ với hệ thống của bạn, hãy nghe các webhook sau: | Webhook | Dùng để | |---|---| | `email.bounced` | Đánh dấu địa chỉ không hợp lệ khi `bounce.type` là `Permanent` và loại phụ là `NoEmail`. | | `email.complained` | Ngừng mọi email marketing tới người đó. | | `contact.updated` | Biết khi `unsubscribed` thay đổi. | ## Thêm hoặc gỡ địa chỉ Ở tab **Danh sách chặn**, nhập địa chỉ và bấm **Thêm** để chặn thủ công (lý do `manual`). Bấm **Gỡ** cạnh một địa chỉ để xoá mục đó; bạn cần quyền quản lý email. Chỉ gỡ khi chắc chắn: - **Trả lại cứng**: địa chỉ đã được sửa hoặc hộp thư nay đã tồn tại, ví dụ sau khi khách sửa lỗi gõ trong tài khoản. - **Báo spam**: người đó đã yêu cầu nhận lại email của bạn. Gửi cho người đã báo spam là cách nhanh nhất để tên miền bị chặn. - **Huỷ đăng ký**: hãy đăng ký lại cho contact thay vì gỡ, để contact và danh sách chặn luôn khớp nhau. Bấm **Cho nhận lại** trên contact, hoặc gửi `PATCH /contacts/{id hoặc email}` với `{ "unsubscribed": false }`; thao tác này tự gỡ mục `unsubscribe`. Nếu chỉ gỡ mục trong danh sách chặn, email giao dịch sẽ đi lại được, nhưng contact vẫn không nhận chiến dịch email chừng nào còn bị đánh dấu huỷ đăng ký. ## Bước tiếp theo - [Khả năng vào hộp thư](https://hatang.dev/vi/docs/guides/email/deliverability.md) - [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md) - [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) Source: https://hatang.dev/vi/docs/guides/email/suppressions-and-bounces --- # Templates Template là mẫu email cả đội soạn một lần và gửi nhiều lần: xác nhận đơn hàng, đặt lại mật khẩu, thư chào mừng. Bạn soạn HTML, khai báo biến, publish, rồi gửi chỉ với id hoặc alias của template cùng giá trị các biến. API giống Resend Templates. ## Trước khi bắt đầu - Quản lý template qua API cần key có `email:write`. Key chỉ dùng để gửi bằng template thì cấp `email:send`. - Địa chỉ `from` của template, hoặc địa chỉ bạn truyền khi gửi, phải thuộc tên miền đã xác minh. ## Soạn và publish Mở **Email → Templates → Template mới**. Trình soạn có: - **Giá trị mặc định**: người gửi, tiêu đề, Reply-To và thư mục. Lệnh gửi có thể ghi đè từng giá trị. - **HTML** có tô màu cú pháp, tab **Xem trước** điền sẵn giá trị fallback, và tab **Văn bản**. Để trống phần văn bản thì Hạ Tầng tự tạo từ HTML. - **Biến**: khai báo mọi biến dùng trong tiêu đề và nội dung, kiểu `string` hoặc `number`, kèm fallback tuỳ chọn. **Khai báo tất cả** thêm những biến Hạ Tầng tìm thấy trong nội dung. - **Kiểm tra link** báo các link trống, chỉ có `#`, dùng tên miền mẫu (`example.com`, `yourdomain.com`, `localhost`…), không phải `http(s)`, không mở được, hoặc trả về trang lỗi. Link có vấn đề sẽ chặn publish cho tới khi bạn chọn **Vẫn publish**. - **Gửi thử** tới địa chỉ của bạn, **Nhân bản** và **Publish**. Chỉ bản đã publish mới gửi được. Bạn vẫn sửa tiếp bản nháp được: lệnh gửi tiếp tục dùng bản đã publish cho tới lần publish sau. Danh sách hiện **Có thay đổi chưa publish** khi bản nháp khác bản đã publish. ## Biến Viết biến dạng `{{{NAME}}}`, hoặc `{{{NAME|giá trị mặc định}}}` để kèm giá trị mặc định ngay tại chỗ: ```html

Chào {{{NAME|bạn}}}, đơn {{{ORDER}}} trị giá {{{TOTAL}}}đ đã được xác nhận.

``` - Khoá gồm chữ, số và `_`, tối đa 50 ký tự. Mỗi template khai báo tối đa 50 biến, mỗi giá trị tối đa 2.000 ký tự. - Giá trị được escape khi chèn vào phần HTML. - Biến kiểu `number` từ chối giá trị không phải số. - Giá trị lấy từ lệnh gửi; nếu không có thì lấy giá trị mặc định tại chỗ; nếu vẫn không có thì lấy fallback đã khai báo. Biến không có cả ba thì lệnh gửi bị từ chối với `422` và không thư nào được gửi. - Không publish được khi nội dung dùng biến chưa khai báo. - Các tên sau được giữ sẵn và được chiến dịch email, automations điền cho từng người nhận: `{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}`, `{{{UNSUBSCRIBE_URL}}}` (và tên khác của nó `{{{RESEND_UNSUBSCRIBE_URL}}}`). Thuộc tính contact dùng dạng `{{{contact.plan}}}`; xem [Contacts, segments và topics](https://hatang.dev/vi/docs/guides/email/audiences.md). Bạn không cần khai báo các biến này. ## Gửi bằng template **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": ["khach@example.vn"], "template": { "id": "order-confirmation", "variables": { "NAME": "Lan", "ORDER": "DH1024", "TOTAL": 450000 } } }' ``` **Node.js** ```ts const { data, error } = await hatang.emails.send({ to: "khach@example.vn", template: { id: "order-confirmation", variables: { NAME: "Lan", ORDER: "DH1024", TOTAL: 450000 } }, }); ``` **Python** ```python hatang.emails.send({ "to": ["khach@example.vn"], "template": {"id": "order-confirmation", "variables": {"NAME": "Lan", "ORDER": "DH1024", "TOTAL": 450000}}, }) ``` **PHP** ```php $res = $client->post('/emails', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'to' => ['khach@example.vn'], 'template' => ['id' => 'order-confirmation', 'variables' => ['NAME' => 'Lan', 'ORDER' => 'DH1024', 'TOTAL' => 450000]], ], ]); ``` - `template.id` là id template (`tpl_…`) hoặc alias. - `from`, `subject` và `reply_to` trong request ghi đè giá trị mặc định của template. Bắt buộc nếu template không có. - Không gửi `html` hay `text` cùng với `template`. - `POST /emails/batch` nhận `template` ở từng phần tử. - Để tạo chiến dịch email từ template, truyền `template_id` cho `POST /broadcasts`, hoặc chọn **Bắt đầu từ template** trong trình soạn chiến dịch. Xem [Chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md). | Lỗi | Nguyên nhân | |---|---| | `404 not_found` | Không có template nào có id hoặc alias này. | | `422 validation_error` "is not published yet" | Hãy publish template trước. | | `422 validation_error` "Missing value for template variable" | Một biến không có giá trị, không có mặc định tại chỗ và không có fallback. | | `422 validation_error` "The template has no `from`" | Truyền `from` trong request hoặc đặt cho template. | ## Quản lý template bằng API ```bash curl -X POST https://api.hatang.dev/templates \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "name": "Xác nhận đơn hàng", "alias": "order-confirmation", "from": "Shop ", "subject": "Đơn {{{ORDER}}} đã được xác nhận", "html": "

Chào {{{NAME|bạn}}}, đơn {{{ORDER}}} trị giá {{{TOTAL}}}đ đã được xác nhận.

", "variables": [ { "key": "NAME", "type": "string" }, { "key": "ORDER", "type": "string" }, { "key": "TOTAL", "type": "number", "fallback_value": 0 } ] }' # { "object": "template", "id": "tpl_…" } curl -X POST https://api.hatang.dev/templates/order-confirmation/publish -H "Authorization: Bearer $HATANG_API_KEY" # { "object": "template", "id": "tpl_…", "current_version_id": "tplv_…" } ``` | Endpoint | Mô tả | |---|---| | `POST /templates` | Tạo bản nháp: `name`, `html` (bắt buộc), `alias`, `from`, `subject`, `reply_to`, `text`, `variables`, `folder`. | | `GET /templates` | Danh sách template. Lọc theo `q` (tên hoặc alias), `folder`, `status` (`draft` hoặc `published`), cùng `limit` và `after`. | | `GET /templates/{id hoặc alias}` | Một template, kèm `status`, `current_version_id`, `published_at` và `has_unpublished_versions`. | | `PATCH /templates/{id hoặc alias}` | Sửa bản nháp. | | `POST /templates/{id hoặc alias}/publish` | Publish bản nháp. | | `POST /templates/{id hoặc alias}/duplicate` | Nhân bản thành bản nháp mới có tên "(copy)". | | `DELETE /templates/{id hoặc alias}` | Xoá. | Alias gồm chữ thường, số, `-` và `_`, tối đa 64 ký tự, và không trùng trong doanh nghiệp. SDK Node.js có các lệnh tương ứng trong `hatang.templates`: `create`, `get`, `list`, `update`, `publish`, `duplicate` và `remove`. ## Bước tiếp theo - [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md) - [Chiến dịch email](https://hatang.dev/vi/docs/guides/email/broadcasts.md) - [Automations](https://hatang.dev/vi/docs/guides/email/automations.md) Source: https://hatang.dev/vi/docs/guides/email/templates --- # Theo dõi mở và nhấp Theo dõi cho bạn biết ai đã mở email và nhấp vào link nào. Bạn bật theo từng tên miền gửi, và mọi email HTML gửi từ tên miền đó đều được theo dõi, dù gửi qua API, SMTP relay, chiến dịch email hay automation. Lượt mở và lượt nhấp hiện trên trang email trong dashboard, trong [Số liệu](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) và qua webhook `email.opened`, `email.clicked`. ## Trước khi bắt đầu - Một tên miền đã thêm vào Hạ Tầng. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). - Nếu dùng API, key có `email:write`. ## Bật theo dõi Trong dashboard, mở **Email → Tên miền**, chọn tên miền và bật **Theo dõi mở**, **Theo dõi nhấp** ở mục **Theo dõi**. Bạn cũng có thể bật ngay khi thêm tên miền (**Tuỳ chọn nâng cao → Tuỳ chọn theo dõi**). Cả hai mặc định tắt. **cURL** ```bash curl -X PATCH https://api.hatang.dev/domains/$DOMAIN_ID \ -H "Authorization: Bearer $HATANG_API_KEY" -H "Content-Type: application/json" \ -d '{ "open_tracking": true, "click_tracking": true }' ``` **Node.js** ```ts await hatang.domains.update({ id: domainId, open_tracking: true, click_tracking: true }); ``` **Python** ```python requests.patch( f"https://api.hatang.dev/domains/{domain_id}", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={"open_tracking": True, "click_tracking": True}, ) ``` **PHP** ```php $client->patch("/domains/$domainId", [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => ['open_tracking' => true, 'click_tracking' => true], ]); ``` Thiết lập được chép vào từng email lúc Hạ Tầng nhận email, nên thay đổi chỉ áp dụng cho email gửi từ lúc đó. Không bật tắt theo dõi riêng cho từng email được. ## Theo dõi mở hoạt động thế nào Hạ Tầng chèn một ảnh trong suốt 1×1 vào cuối phần HTML. Khi ứng dụng email tải ảnh này, Hạ Tầng ghi nhận một lượt mở cho người nhận đó. - Chỉ phần HTML được theo dõi. Email chỉ có văn bản thuần không ghi nhận được lượt mở. - Mọi lượt mở đều được lưu. Webhook `email.opened` gửi tối đa mỗi giờ một lần cho mỗi người nhận. - Lượt mở chỉ là ước lượng. Một số ứng dụng email và cổng bảo mật tự tải ảnh dù không ai đọc thư (ví dụ Apple Mail Privacy Protection), số khác chặn ảnh, nên con số thật có thể cao hoặc thấp hơn. Hạ Tầng đánh dấu lượt mở đến từ các proxy ảnh đã biết bằng `suspected_proxy` trong dữ liệu sự kiện. ## Theo dõi nhấp hoạt động thế nào Hạ Tầng viết lại mọi link `http` hoặc `https` trong HTML thành một địa chỉ theo dõi. Khi người nhận nhấp, Hạ Tầng ghi nhận sự kiện rồi chuyển hướng (`302`) tới URL gốc. - Link không phải `http(s)` được giữ nguyên: `mailto:`, `tel:`, `sms:`, `#anchor`. - Muốn giữ nguyên một link, thêm thuộc tính `data-no-track`: ``. - URL gốc được mã hoá bên trong link theo dõi, nên không ai sửa được để chuyển hướng sang trang khác. - Mỗi lượt nhấp được lưu và gửi webhook `email.clicked` kèm link: ```json { "type": "email.clicked", "created_at": "2026-10-14T09:20:11.000Z", "data": { "email_id": "0199e6c1-7a2b-7f00-9c1e-3f6a2b1d9e10", "from": "Shop ", "to": ["lan@example.vn"], "subject": "Đơn #1024 đã được xác nhận", "created_at": "2026-10-14T09:12:03.000Z", "tags": {}, "click": { "link": "https://shop.vn/don-hang/1024", "timestamp": "2026-10-14T09:20:11.000Z", "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 …" } } } ``` Trang email trong dashboard (**Tương tác**) hiển thị số lượt mở duy nhất và tổng, cùng số lượt nhấp theo từng link. ## Dùng tên miền phụ theo dõi của bạn Mặc định link theo dõi và pixel mở dùng máy chủ theo dõi của nền tảng. Một tên miền phụ theo dõi như `links.shop.vn` giúp chúng dùng tên miền của bạn, để link trong email khớp thương hiệu và không dùng chung máy chủ với người gửi khác. 1. Đặt nhãn tên miền phụ: khi thêm tên miền (**Tên miền phụ theo dõi**), hoặc sau đó bằng `PATCH /domains/{id}` với `{ "tracking_subdomain": "links" }`. Nhãn phải khác nhãn return path (`send`). 2. Thêm bản ghi CNAME **Theo dõi** hiển thị trên trang tên miền, ví dụ `links.shop.vn CNAME` trỏ tới máy chủ theo dõi được ghi ở đó. 3. Xác minh lại tên miền (**Kiểm tra lại DNS** hoặc `POST /domains/{id}/verify`). Cho tới khi CNAME được xác minh, link vẫn dùng máy chủ của nền tảng (dashboard ghi rõ ở mục **Theo dõi**). Khi đã xác minh, `tracking_domain_verified` là `true` và email mới dùng `https://links.shop.vn/…`. Hạ Tầng tự lấy chứng chỉ HTTPS cho tên miền phụ. Đặt `tracking_subdomain` là `null` để quay lại máy chủ của nền tảng. ## Quyền riêng tư - Mỗi sự kiện mở hoặc nhấp lưu người nhận, thời điểm, địa chỉ IP và user agent của request. Các thông tin này có trong webhook và trong `GET /emails/{id}/events`. - Theo dõi mở thêm một ảnh mà ứng dụng email của người nhận tải từ Hạ Tầng. Hãy nói rõ việc theo dõi trong chính sách quyền riêng tư, và xin đồng ý khi pháp luật yêu cầu. - Nếu không cần, hãy tắt theo dõi cho email giao dịch như đặt lại mật khẩu. - Link trong email đặt lại mật khẩu hay đăng nhập cũng đi qua bước chuyển hướng. Thêm `data-no-track` để giữ URL nhạy cảm khỏi link theo dõi. ## Bước tiếp theo - [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) - [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) - [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) Source: https://hatang.dev/vi/docs/guides/email/tracking --- # Dùng Hạ Tầng với AI coding agent Coding agent viết tích hợp tốt hơn khi đọc tài liệu hiện hành thay vì đoán. Hạ Tầng cung cấp bốn thứ; dùng càng nhiều càng tốt, tuỳ công cụ của bạn hỗ trợ. ## 1. MCP server [`@hatang/mcp`](https://hatang.dev/vi/docs/guides/sdks/mcp.md) cho agent tìm trong tài liệu này và đọc bất kỳ trang nào dạng Markdown; khi có API key, agent còn gọi được API: gửi email thử, kiểm tra tên miền, tạo thanh toán VietQR và giả lập chuyển khoản ở môi trường development, đọc hộp thư. ```bash claude mcp add hatang --env HATANG_API_KEY=ht_... --env HATANG_ENVIRONMENT=development -- npx -y @hatang/mcp ``` Cursor, VS Code, Windsurf và Claude Desktop: xem [MCP server](https://hatang.dev/vi/docs/guides/sdks/mcp.md#install). ## 2. Quy tắc cho dự án Một đoạn văn ngắn cho agent biết dùng SDK nào, key để ở đâu, lỗi trả về ra sao, và cách xác minh thanh toán, webhook. Có ba dạng: | Agent | File | Cài đặt | |---|---|---| | Codex, Copilot, Jules, Zed, Aider và các agent đọc `AGENTS.md`; Claude Code (`CLAUDE.md`) | [`AGENTS.md`](https://hatang.dev/docs/agents/AGENTS.md) | `curl -s https://hatang.dev/docs/agents/AGENTS.md >> AGENTS.md` | | Cursor | [`hatang.mdc`](https://hatang.dev/docs/agents/hatang.mdc) | `mkdir -p .cursor/rules && curl -so .cursor/rules/hatang.mdc https://hatang.dev/docs/agents/hatang.mdc` | | Claude Code (skill) | [`SKILL.md`](https://hatang.dev/docs/agents/SKILL.md) | `mkdir -p .claude/skills/hatang && curl -so .claude/skills/hatang/SKILL.md https://hatang.dev/docs/agents/SKILL.md` | | GitHub Copilot trong VS Code | [`AGENTS.md`](https://hatang.dev/docs/agents/AGENTS.md) | `curl -s https://hatang.dev/docs/agents/AGENTS.md >> .github/copilot-instructions.md` | Commit file này để cả nhóm, và mọi lần agent chạy trong CI, dùng chung một bộ quy tắc. Nội dung quy tắc bằng tiếng Anh, ngôn ngữ mà mọi agent đọc tốt nhất. Trong Claude Code, [plugin](https://github.com/sota-labs/hatang-claude-plugin) cài cả skill lẫn MCP server trong một bước: `/plugin marketplace add sota-labs/hatang-claude-plugin`, rồi `/plugin install hatang@hatang`. Bắt đầu app mới? [Starter Next.js](https://github.com/sota-labs/hatang-nextjs-starter) đã nối sẵn email, thanh toán VietQR, IPN, webhook và widget chat, kèm quy tắc trong `AGENTS.md`. ## 3. Tài liệu dạng Markdown Agent tải được URL thì không cần cài gì: - [`/vi/docs/llms.txt`](https://hatang.dev/docs/llms.txt): mọi trang hướng dẫn và tham chiếu kèm một dòng tóm tắt ([tiếng Anh](https://hatang.dev/docs/llms.txt)). - [`/vi/docs/llms-full.txt`](https://hatang.dev/docs/llms-full.txt): toàn bộ tài liệu trong một file, cho công cụ nạp tài liệu từ trước (ví dụ **@Docs** của Cursor). - Bất kỳ trang nào thêm `.md`, ví dụ [`/vi/docs/guides/email/sending.md`](https://hatang.dev/docs/guides/email/sending.md). - [`/docs/openapi.json`](https://hatang.dev/docs/openapi.json): REST API dạng OpenAPI 3.1. Trong Cursor, thêm `https://hatang.dev/docs/llms-full.txt` ở **Settings → Indexing & Docs** rồi nhắc tới bằng `@Docs`. ## 4. Câu lệnh hiệu quả Nêu tên Hạ Tầng và tài liệu trong câu lệnh, và nói rõ dùng môi trường nào: - "Thêm email xác nhận đơn hàng bằng Hạ Tầng (`@hatang/sdk`). Đọc https://hatang.dev/vi/docs/guides/email/sending.md trước. Key nằm trong `HATANG_API_KEY`." - "Thêm widget chat Hạ Tầng vào app Next.js này bằng `@hatang/chat-react`, và định danh người dùng đã đăng nhập." - "Nhận thanh toán VietQR bằng Hạ Tầng: tạo thanh toán lúc checkout, xác minh IPN, và kiểm tra bằng `payments.verify` trước khi đánh dấu đơn đã trả. Dùng môi trường development." - "Chuyển phần gửi email của dự án từ Resend sang Hạ Tầng, theo https://hatang.dev/vi/docs/guides/email/migrate-from-resend.md." ## Giữ key ngoài tầm agent - Cho agent một key tạo riêng cho việc này, chỉ cấp scope cần thiết, và thu hồi sau đó. Xem [Bảo mật](https://hatang.dev/vi/docs/guides/getting-started/security.md). - Không dán key vào câu lệnh hay file quy tắc; đặt nó trong biến môi trường. - Để Thanh toán ở `development` (`HATANG_ENVIRONMENT=development`) trong lúc agent làm việc. Source: https://hatang.dev/vi/docs/guides/getting-started/ai-agents --- # Thư viện API Bạn gọi được REST API của Hạ Tầng từ bất kỳ ngôn ngữ nào bằng một HTTP client thông thường. Trang này liệt kê các thư viện chính thức giúp việc đó dễ hơn, và chỉ cách sinh client có kiểu dữ liệu từ bản OpenAPI cho những ngôn ngữ chưa có SDK chính thức. ## Node.js: `@hatang/sdk` SDK server chính thức bao gồm Email, Hỗ trợ, Sản phẩm, CRM, Thanh toán và hoá đơn điện tử, có kiểu TypeScript cho mọi request và response. ```bash npm install @hatang/sdk ``` ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", // environment: "development", // chỉ cho Thanh toán; gửi dưới dạng Hatang-Environment }); const { data, error } = await hatang.emails.send({ from: "Shop ", to: "customer@example.com", subject: "Chào mừng", html: "

Chào mừng bạn đến với shop!

", }); ``` Cần biết: - **Trả kết quả, không throw.** Mọi hàm trả về `{ data, error }` và không throw khi API báo lỗi. `error` có `statusCode`, `name` và `message` như thường lệ ([Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md)). Lỗi mạng cho `statusCode: 0`. - **Cấu hình.** Truyền key và tuỳ chọn vào constructor, hoặc đặt `HATANG_API_KEY`, `HATANG_BASE_URL` và `HATANG_ENVIRONMENT`. Luôn đặt base URL là `https://api.hatang.dev`. - **Namespace** theo đúng API: `emails`, `batch`, `domains`, `templates`, `contacts`, `contactProperties`, `audiences`, `segments`, `topics`, `broadcasts`, `automations`, `events`, `apiKeys`, `webhooks`, `logs`, `usage`, `chat`, `payments` và `einvoices`. - **Idempotency.** Các hàm tạo mới nhận tuỳ chọn `{ idempotencyKey }` ([Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md)). - **Hàm hỗ trợ webhook.** `verifyWebhook` và `constructWebhookEvent` kiểm tra chữ ký webhook; `verifyIpn` và `constructIpnEvent` kiểm tra lời gọi IPN của Thanh toán ([Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md)). - **React Email.** Truyền một element `react` vào `emails.send`, SDK sẽ render ra HTML và văn bản thuần (cài thêm `@react-email/render` và `react`). [Hướng dẫn SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md) đi qua từng namespace kèm ví dụ. ## SDK chat cho web và di động SDK chat chạy trên trình duyệt và ứng dụng của khách. Chúng kết nối tới chat gateway bằng site id công khai (và khoá ứng dụng trên di động), không bao giờ bằng API key. | Nền tảng | Gói | |---|---| | Website | `https://chat.hatang.dev/widget.js` | | React / Next.js | `@hatang/chat-react` | | WordPress | Plugin Hạ Tầng Chat | | Flutter | `hatang_chat` | | React Native / Expo | `@hatang/chat-react-native` | | iOS (Swift) | `HatangChat` | | Android (Kotlin) | `dev.hatang:chat` | [Tổng quan SDK](https://hatang.dev/vi/docs/guides/sdks/overview.md) dẫn tới hướng dẫn của từng SDK, các phiên bản được hỗ trợ và [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) chúng dùng chung. ## Ngôn ngữ khác Hiện chưa có SDK server chính thức cho PHP, Python hay Go. Hai lựa chọn tốt: - **Gọi thẳng REST API.** Mọi hướng dẫn trong trang tài liệu này đều có mẫu cURL, Python và PHP. API chỉ là JSON qua HTTPS với Bearer token. - **Sinh client** từ bản OpenAPI, như bên dưới. ## Bản OpenAPI REST API được mô tả bằng một tài liệu OpenAPI 3.1, sinh ra từ chính code phục vụ API: | Bản mô tả | URL | |---|---| | REST API | `https://api.hatang.dev/openapi.json` | | Chat gateway | `https://chat.hatang.dev/openapi.json` | Xem trong [tham chiếu REST API](https://hatang.dev/vi/docs/reference/api.md) và [tham chiếu chat gateway](https://hatang.dev/vi/docs/reference/chat-gateway.md), hoặc nhập vào một API client như Postman hay Insomnia để gọi thử bằng tay. ### Sinh client Trỏ trình sinh mã vào URL của bản mô tả. Vài ví dụ: ```bash # Kiểu TypeScript (dùng với openapi-fetch hoặc hàm fetch của bạn) npx openapi-typescript https://api.hatang.dev/openapi.json -o hatang.d.ts # Python pipx run openapi-python-client generate --url https://api.hatang.dev/openapi.json # Nhiều ngôn ngữ, gồm PHP, Java, Kotlin, C# và Ruby npx @openapitools/openapi-generator-cli generate \ -i https://api.hatang.dev/openapi.json -g php -o ./hatang-php ``` > **Note** > > Bản mô tả dùng OpenAPI 3.1. Hãy kiểm tra trình sinh mã của bạn hỗ trợ 3.1; một số chỉ hỗ trợ đầy đủ 3.0. Sinh lại client khi nâng cấp, vì endpoint và trường mới được thêm vào bản mô tả ngay khi ra mắt. Client sinh ra chỉ gửi những gì bạn đưa, nên hãy nhớ các quy tắc chung: header `Authorization: Bearer`, `Idempotency-Key` khi gửi email và tạo thanh toán, [phân trang](https://hatang.dev/vi/docs/guides/getting-started/pagination.md) bằng `after`, và thử lại khi gặp `429` ([Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md)). ## Bước tiếp theo - [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md) - [Tổng quan SDK](https://hatang.dev/vi/docs/guides/sdks/overview.md) - [Tham chiếu REST API](https://hatang.dev/vi/docs/reference/api.md) Source: https://hatang.dev/vi/docs/guides/getting-started/api-libraries --- # Xác thực API Mọi lời gọi tới REST API tại `https://api.hatang.dev` đều xác thực bằng một **API key** gửi dưới dạng Bearer token. Trang này chỉ cách tạo key, chọn scope, giới hạn key, và API trả gì khi key thiếu, sai hoặc không đủ quyền. ## Trước khi bắt đầu Để tạo và thu hồi key, bạn cần là chủ sở hữu, quản trị viên, hoặc thành viên có quyền **Developer** ([Nhân viên và phân quyền](https://hatang.dev/vi/help/account/employees-and-roles)). ## Tạo API key 1. Trong dashboard, mở **Cài đặt → API Keys**. Người dùng Email cũng thấy trang này ở **Email → API Keys**. 2. Bấm **Tạo key** và đặt tên cho biết key chạy ở đâu, như `production-web` hay `erp-sync`. 3. Ở mục **Key này có thể làm gì**, tích các scope cần dùng. Chỉ những module đang bật mới được liệt kê. 4. Nếu cần, giới hạn key vào một **Tên miền gửi** hoặc một số **Origin trình duyệt** (xem [Key bị giới hạn](#restricted-keys)). 5. Bấm **Tạo key** và sao chép key. Key bắt đầu bằng `ht_` và **chỉ hiện một lần**: Hạ Tầng chỉ lưu bản băm của key. Bảng key hiện tiền tố của từng key, quyền của nó và thời điểm **Dùng lần cuối**. ## Gửi key Đặt key vào header `Authorization` của mọi request: ```http Authorization: Bearer ht_123... ``` **cURL** ```bash curl "https://api.hatang.dev/emails?limit=1" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; // SDK tự gửi header Authorization. const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.list({ limit: 1 }); ``` **Python** ```python import os from hatang import Hatang # SDK tự gửi header Authorization. hatang = Hatang(os.environ["HATANG_API_KEY"], base_url="https://api.hatang.dev") emails = hatang.emails.list(limit=1) ``` **PHP** ```php $client = new GuzzleHttp\Client([ 'base_uri' => 'https://api.hatang.dev', 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], ]); $res = $client->get('/emails', ['query' => ['limit' => 1]]); ``` Key cũng dùng làm mật khẩu SMTP cho `smtp.hatang.dev:5870` (STARTTLS), với tên đăng nhập `hatang`. > **Danger: Chỉ dùng ở server** > > API key thay mặt cho cả doanh nghiệp của bạn. Giữ key trong biến môi trường hoặc trình quản lý secret trên server. Đừng bao giờ đặt key vào JavaScript chạy trên trình duyệt, ứng dụng di động hay kho Git. Trình duyệt và ứng dụng dùng widget chat và SDK di động, vốn có site id và khoá ứng dụng công khai riêng. ## Scope {#scopes} Scope có dạng `:`, `:*` hoặc `*`. | Module | Scope | Cho phép | |---|---|---| | Email | `email:send` | Gửi email: `POST /emails`, `POST /emails/batch` và SMTP relay | | | `email:read` | Liệt kê và lấy email, số liệu và thư đến | | | `email:write` | Quản lý tên miền, template, liên hệ, danh sách nhận, segment, topic, chiến dịch email và automation | | Hỗ trợ | `chat:read`, `chat:write` | Hội thoại, tin nhắn, khách truy cập, phiếu hỗ trợ và site widget; `write` thêm quyền trả lời, giao và xử lý xong | | Thanh toán | `payments:read`, `payments:write` | Thanh toán, giao dịch, liên kết ngân hàng và hoá đơn điện tử; `write` thêm quyền tạo thanh toán và phát hành hoá đơn | | Sản phẩm | `products:read`, `products:write` | Sản phẩm, danh mục và đơn hàng, kể cả link thanh toán của đơn | | CRM | `crm:read`, `crm:write` | Khách hàng, trường tuỳ chỉnh, gộp khách, xuất CSV và ZNS | | Toàn quyền | `*` | Mọi quyền trên mọi module | Cách các scope kết hợp: - `write` và `send` đều bao gồm `read`. Key có `email:send` đọc được các email nó đã gửi. - `:*` bao gồm mọi quyền của sản phẩm đó, còn `*` bao gồm mọi sản phẩm. - Sản phẩm và CRM trước đây nằm trong Hỗ trợ: key có `chat:read`, `chat:write` hoặc `chat:*` vẫn gọi được các endpoint Sản phẩm và CRM như cũ. Key mới có thể dùng `products:*` và `crm:*` để thu hẹp quyền. - Một vài endpoint thuộc về cả doanh nghiệp chứ không thuộc module nào. Quản lý endpoint webhook, đọc **Logs** API và đọc **Usage** cần một scope `write` bất kỳ; quản lý API key qua API cần `email:write`. > **Info: Thư đến** > > Đọc thư đến (`/emails/receiving`) cần key được cấp thẳng `email:read` hoặc `email:write`. Quyền đọc đi kèm `email:send` không đủ ở đây. Dashboard chỉ có scope của Email, Hỗ trợ và Thanh toán. Để tạo key có `products:*`, `crm:*` hoặc `*`, gọi `POST /api-keys` bằng một key có `email:write`: ```bash curl -X POST https://api.hatang.dev/api-keys \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "erp-sync", "scopes": ["products:*", "crm:read"] }' ``` ```json { "id": "key_123...", "token": "ht_123..." } ``` Key cần đúng scope **và** module tương ứng phải đang bật. Gọi endpoint của module đang tắt nhận `403 product_disabled` dù key đủ scope ([Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules)). ## Key bị giới hạn {#restricted-keys} Hai giới hạn tuỳ chọn giúp thu hẹp thiệt hại nếu key bị lộ. | Giới hạn | Đặt ở đâu | Tác dụng | |---|---|---| | **Tên miền gửi** | Dashboard, hoặc `domain_id` khi gọi `POST /api-keys` | Key chỉ gửi được từ tên miền đã xác minh đó. Gửi từ tên miền khác nhận `401 restricted_api_key`. Chỉ áp dụng cho key có scope Email. | | **Origin trình duyệt** | Dashboard: **Key này chạy ở đâu?** → **Trong trình duyệt**, rồi mỗi dòng một origin, như `https://shop.vn` | Request phải mang header `Origin` nằm trong danh sách. Mọi request khác, **kể cả request từ server không có header `Origin`**, nhận `401 restricted_api_key`. | Chọn **Trên server của bạn** (mặc định) cho key dùng từ backend; key đó không bị khoá theo origin. Chỉ chọn **Trong trình duyệt** cho key được nạp trong trình duyệt. Thông báo lỗi sẽ nói rõ khi một key chỉ dành cho trình duyệt được gọi mà không có header `Origin`. ## Key test và môi trường Thanh toán Chỉ module Thanh toán có môi trường. Mỗi doanh nghiệp có môi trường **development** (ngân hàng giả lập và sandbox của nhà cung cấp hoá đơn điện tử) và **production** (tiền thật). Dữ liệu thanh toán không bao giờ lẫn giữa hai môi trường. Email, Hỗ trợ, Sản phẩm và CRM không có môi trường. Chọn môi trường cho từng request bằng header `Hatang-Environment`: ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" ``` | Key | Không có header | `Hatang-Environment: development` | `Hatang-Environment: production` | |---|---|---|---| | `ht_...` | production | development | production | | `ht_test_...` | development | development | `403 environment_mismatch` | - Mọi response lặp lại môi trường đã dùng trong header `Hatang-Environment`. - Giá trị header khác hai giá trị trên nhận `422 validation_error`. - Với SDK Node.js, truyền `environment: "development"` vào constructor `Hatang`, hoặc đặt biến `HATANG_ENVIRONMENT`. - Key bắt đầu bằng `ht_test_` là key development từ các phiên bản Hạ Tầng trước. Chúng không bao giờ chạm được dữ liệu production. Key mới bắt đầu bằng `ht_` và dùng header. Xem [Môi trường Thanh toán](https://hatang.dev/vi/docs/guides/payments/environments.md) để biết hai môi trường khác nhau ở đâu. ## Xoay và thu hồi key Xoay key khi người từng biết key nghỉ việc, khi key có thể đã lộ, hoặc theo lịch định kỳ: 1. Tạo key mới với cùng scope và giới hạn. 2. Triển khai key mới ở mọi nơi đang dùng key cũ. 3. Theo dõi cột **Dùng lần cuối** của key cũ cho tới khi nó ngừng thay đổi. 4. Bấm **Thu hồi** ở key cũ, hoặc gọi `DELETE /api-keys/{id}`. Key bị thu hồi ngừng hoạt động trong vòng một phút. Không thể khôi phục key đã thu hồi; hãy tạo key mới. ## Lỗi | Status | `name` | Khi nào | |---|---|---| | 401 | `missing_api_key` | Thiếu header `Authorization: Bearer` | | 403 | `invalid_api_key` | Key sai định dạng, không tồn tại hoặc đã bị thu hồi | | 401 | `restricted_api_key` | Key thiếu scope, được gọi từ origin không cho phép, hoặc gửi từ tên miền mà key không được khoá vào | | 403 | `product_disabled` | Module của endpoint đang tắt với doanh nghiệp | | 403 | `environment_mismatch` | Key `ht_test_` đòi production, hoặc thao tác Thanh toán không có trong môi trường này | | 422 | `validation_error` | `Hatang-Environment` không phải `development` hay `production` | ```json { "statusCode": 401, "name": "restricted_api_key", "message": "This API key does not have the payments:write scope" } ``` Rẽ nhánh theo `name`, không theo `message`: message viết cho người đọc và có thể đổi. Không lỗi nào ở trên tự hết khi thử lại; hãy sửa key, scope hoặc bật module trước. Xem [Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md) để có bức tranh đầy đủ. ## Bước tiếp theo - [Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md) - [Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md) - [Bảo mật](https://hatang.dev/vi/docs/guides/getting-started/security.md) - [Môi trường Thanh toán](https://hatang.dev/vi/docs/guides/payments/environments.md) Source: https://hatang.dev/vi/docs/guides/getting-started/authentication --- # Lỗi Mọi API của Hạ Tầng, từ Email, Thanh toán tới chat gateway, báo lỗi theo cùng một dạng JSON kèm mã HTTP theo quy ước. Trang này giải thích dạng lỗi đó, liệt kê các lỗi bạn dễ gặp nhất cùng cách xử lý, và cách bắt lỗi trong code. [Tham chiếu lỗi](https://hatang.dev/vi/docs/reference/errors.md) liệt kê mọi mã lỗi. ## Dạng lỗi Request thất bại trả status `4xx` hoặc `5xx` và body như sau: ```json { "statusCode": 422, "name": "validation_error", "message": "to: Invalid email address" } ``` | Trường | Kiểu | Ý nghĩa | |---|---|---| | `statusCode` | number | Mã HTTP, lặp lại trong body | | `name` | string | Mã ổn định cho máy đọc. **Rẽ nhánh theo trường này.** | | `message` | string | Một câu tiếng Anh cho người đọc và cho log. Có thể đổi bất cứ lúc nào; đừng phân tích nội dung của nó. | Với lỗi kiểm tra dữ liệu, `message` nêu trường đầu tiên bị sai, theo dạng `trường: vấn đề`. ## Mã trạng thái | Status | Ý nghĩa | Thử lại? | |---|---|---| | `400` | Request xung đột với một request trước đó (idempotency) | Không, sửa request | | `401` | Thiếu API key, hoặc key không được phép làm việc này | Không | | `403` | Key không hợp lệ, module đang tắt, hoặc thao tác không được phép ở trạng thái này | Không | | `404` | Tài nguyên không tồn tại, hoặc thuộc doanh nghiệp hay môi trường khác | Không | | `409` | Xung đột với trạng thái hiện tại (trùng lặp, hội thoại đã đóng, request đồng thời) | Đôi khi, sau khi đọc lại trạng thái | | `413` | Tệp tải lên quá lớn | Không | | `422` | Body hoặc query không hợp lệ | Không, sửa request | | `429` | Chạm giới hạn tần suất hoặc hạn mức trong ngày | Có, sau khi chờ (chỉ với giới hạn tần suất) | | `500`, `503` | Có lỗi phía Hạ Tầng hoặc phía nhà cung cấp | Có, với backoff | ## Các lỗi thường gặp | `name` | Status | Ý nghĩa | Nên làm gì | |---|---|---|---| | `validation_error` | 422 | Thiếu trường, trường sai, hoặc body không phải JSON hợp lệ | Đọc `message` và sửa request. Đừng gửi lại nguyên như cũ. | | `validation_error` | 403 | Chỉ ở Email: tên miền của `from` chưa xác minh | [Xác minh tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) | | `missing_api_key` | 401 | Thiếu header `Authorization: Bearer` | Gửi kèm key | | `invalid_api_key` | 403 | Key sai định dạng, không tồn tại hoặc đã bị thu hồi | Kiểm tra key; tạo key mới nếu key đã bị thu hồi | | `restricted_api_key` | 401 | Key thiếu scope, hoặc giới hạn tên miền, origin của key không cho phép | Dùng key có đúng [scope](https://hatang.dev/vi/docs/guides/getting-started/authentication.md#scopes) | | `product_disabled` | 403 | Module đang tắt với doanh nghiệp | Nhờ chủ sở hữu hoặc quản trị viên bật lại; đừng thử lại | | `environment_mismatch` | 403 | Thao tác hoặc key Thanh toán không khớp với môi trường | Kiểm tra `Hatang-Environment` và key | | `not_found` | 404 | Doanh nghiệp (và môi trường) này không có tài nguyên đó | Kiểm tra id; có thể nó thuộc doanh nghiệp hay môi trường khác | | `invalid_idempotent_request` | 400 | `Idempotency-Key` đã được dùng với body khác | Dùng key mới cho request mới ([Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md)) | | `concurrent_idempotent_requests` | 409 | Hai request cùng `Idempotency-Key` chạy cùng lúc | Chờ một chút rồi thử lại; bạn nhận kết quả của request đầu | | `rate_limit_exceeded` | 429 | Quá nhiều request mỗi giây cho module này | Chờ `retry-after` giây rồi thử lại ([Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md)) | | `daily_quota_exceeded` | 429 | Doanh nghiệp đã dùng hết hạn mức email trong ngày | Chờ đến 0 giờ UTC hoặc nâng hạn mức | | `conversation_closed` | 409 | Hội thoại chat đã đóng | Mở lại, hoặc bắt đầu hội thoại mới | | `file_too_large`, `unsupported_file_type` | 413, 422 | Tệp tải lên quá lớn hoặc thuộc loại không được nhận | Xem [giới hạn](https://hatang.dev/vi/docs/reference/limits.md) | | `invalid_state` | 409 | Đối tượng không làm được việc đó lúc này, như huỷ một thanh toán đã trả | Lấy lại đối tượng và kiểm tra trạng thái | | `application_error` | 500 hoặc 403 | Lỗi ngoài dự kiến; trả `403` khi việc gửi email của doanh nghiệp đang tạm dừng | Thử lại `500` với backoff; với `403`, mở lại việc gửi ở **Email → Cài đặt email** | | `internal_server_error` | 500 | Có lỗi phía Hạ Tầng | Thử lại với backoff; liên hệ hỗ trợ nếu lỗi kéo dài | | `provider_unavailable` | 503 | Ngân hàng hoặc nhà cung cấp hoá đơn điện tử không phản hồi | Thử lại sau | ## Xử lý lỗi trong code Quyết định theo `name` và `statusCode`: thử lại lỗi tạm thời, báo cho người phụ trách lỗi cần người sửa, và ghi log phần còn lại cùng request. **Node.js** SDK Node.js không bao giờ throw khi API báo lỗi. Mọi hàm trả về `{ data, error }`, trong đó `error` có cùng `statusCode`, `name` và `message`. Khi hoàn toàn không kết nối được tới API, `error.statusCode` là `0` và `error.name` là `application_error`. ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send({ from, to, subject, html }, { idempotencyKey: orderId }); if (error) { switch (error.name) { case "validation_error": // Lỗi của code hoặc dữ liệu đầu vào: ghi log và sửa payload. logger.error({ error }, "Hatang từ chối email"); break; case "rate_limit_exceeded": case "internal_server_error": case "application_error": // Tạm thời: thử lại sau với cùng idempotency key. await queue.retryLater({ orderId }); break; case "product_disabled": case "restricted_api_key": case "invalid_api_key": // Cấu hình: báo cho người phụ trách, đừng thử lại. await alertOps(error.message); break; default: logger.error({ error }, "Lỗi Hatang ngoài dự kiến"); } } ``` **Python** ```python from hatang import Hatang, HatangError hatang = Hatang() # đọc HATANG_API_KEY try: hatang.emails.send(payload, idempotency_key=order_id) except HatangError as err: if err.retryable: # bị giới hạn, lỗi 5xx hoặc không tới được (status_code 0) retry_later(order_id) # tạm thời elif err.name in ("product_disabled", "restricted_api_key", "invalid_api_key"): alert_ops(err.message) # cấu hình else: raise ValueError(f"{err.name}: {err.message}") # sửa request ``` **PHP** ```php $res = $client->post('/emails', [ 'headers' => ['Idempotency-Key' => $orderId], 'json' => $payload, 'http_errors' => false, ]); if ($res->getStatusCode() >= 400) { $err = json_decode((string) $res->getBody(), true); switch ($err['name'] ?? '') { case 'rate_limit_exceeded': retryLater($orderId); break; case 'product_disabled': case 'restricted_api_key': case 'invalid_api_key': alertOps($err['message']); break; default: if ($res->getStatusCode() >= 500) { retryLater($orderId); } else { throw new RuntimeException($err['name'] . ': ' . $err['message']); } } } ``` > **Tip: Tìm các lời gọi lỗi** > > **Email → Logs** trong dashboard liệt kê mọi request API của doanh nghiệp kèm status, thời gian xử lý, body của request và response. Xem các lời gọi có status `4xx` hoặc `5xx` để biết chính xác lỗi mỗi lời gọi nhận về. ## Bước tiếp theo - [Tham chiếu lỗi](https://hatang.dev/vi/docs/reference/errors.md): mọi mã lỗi - [Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md) - [Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md) Source: https://hatang.dev/vi/docs/guides/getting-started/errors --- # Idempotency Lỗi mạng hay hết thời gian chờ khiến bạn không biết request đã tới nơi hay chưa. Nếu cứ gửi lại, khách có thể nhận cùng một email hai lần hoặc bị yêu cầu trả tiền hai lần. **Idempotency key** giúp việc gửi lại an toàn: Hạ Tầng chỉ làm việc đó một lần và trả cùng một kết quả cho mọi lần lặp lại. ## Hỗ trợ ở đâu Gửi key trong header `Idempotency-Key`. Key là chuỗi bất kỳ do bạn chọn, tối đa 256 ký tự. | Endpoint | Phạm vi của key | Lặp lại với cùng body | Lặp lại với body khác | |---|---|---|---| | `POST /emails` | Doanh nghiệp | `200` với cùng `id`; không gửi lại thư | `400 invalid_idempotent_request` | | `POST /payments` | Doanh nghiệp và môi trường Thanh toán | `201` với cùng thanh toán | `400 invalid_idempotent_request` | | `POST /chat/conversations/{id}/messages` | Hội thoại | `200` với cùng tin nhắn và `"duplicate": true` | `200` với tin nhắn đầu (body không được so sánh) | `POST /emails/batch` và các endpoint khác không nhận idempotency key. ## Gửi idempotency key Dùng key xác định nghiệp vụ, không phải lần gọi HTTP: mã đơn hàng, `welcome-`, `invoice--reminder-1`. Tạo key một lần, lưu cùng tác vụ, và dùng lại ở mọi lần thử. **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1001-confirmation" \ -d '{ "from": "Shop ", "to": "customer@example.com", "subject": "Đơn #1001 đã được xác nhận", "html": "

Cảm ơn bạn đã đặt hàng!

" }' ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); // Email await hatang.emails.send( { from: "Shop ", to: "customer@example.com", subject: "Đơn #1001 đã được xác nhận", html: "

Cảm ơn!

" }, { idempotencyKey: "order-1001-confirmation" }, ); // Thanh toán await hatang.payments.create({ amount: 150000, description: "Đơn 1001" }, { idempotencyKey: "order-1001" }); // Trả lời chat await hatang.chat.conversations.send("cnv_123...", { content: "Đơn của anh được giao trong hôm nay ạ." }, { idempotencyKey: "order-1001-shipped" }); ``` **Python** ```python from hatang import Hatang hatang = Hatang() # đọc HATANG_API_KEY hatang.emails.send( { "from": "Shop ", "to": "customer@example.com", "subject": "Đơn #1001 đã được xác nhận", "html": "

Cảm ơn bạn đã đặt hàng!

", }, idempotency_key="order-1001-confirmation", ) ``` **PHP** ```php $res = $client->post('/emails', [ 'headers' => ['Idempotency-Key' => 'order-1001-confirmation'], 'json' => [ 'from' => 'Shop ', 'to' => 'customer@example.com', 'subject' => 'Đơn #1001 đã được xác nhận', 'html' => '

Cảm ơn bạn đã đặt hàng!

', ], ]); ``` ## Key được lưu thế nào - **Email và thanh toán.** Key được lưu cùng email hoặc thanh toán mà nó tạo ra, kèm bản băm của body request. Lần lặp lại với cùng key được so với bản băm đó. Key không hết hạn: còn email hay thanh toán thì key vẫn trỏ tới nó. Email được trả lại theo key không bị tính thêm vào hạn mức trong ngày. - **Tin nhắn chat.** Key trở thành `client_id` của tin nhắn. Lần lặp lại trong cùng hội thoại trả về tin nhắn đã có với `"duplicate": true`. Widget và SDK di động cũng làm như vậy với `client_id` gửi kèm mỗi tin nhắn, nhờ đó tin nhắn gửi hai lần khi mạng chập chờn chỉ hiện một lần trong hộp thư. - **Môi trường Thanh toán.** Key của thanh toán tính riêng theo môi trường, nên cùng một mã đơn dùng được một lần ở development và một lần ở production. ## Xung đột | `name` | Status | Nguyên nhân | Nên làm gì | |---|---|---|---| | `invalid_idempotent_request` | 400 | Key đã được dùng với body **khác** | Bạn đã dùng lại key cho một nghiệp vụ khác. Tạo key mới; đừng bao giờ đổi body khi thử lại. | | `concurrent_idempotent_requests` | 409 | Chỉ ở Email: hai request cùng key tới cùng một lúc | Chờ một chút rồi thử lại với cùng key; bạn nhận kết quả của request đầu. | ```json { "statusCode": 400, "name": "invalid_idempotent_request", "message": "Same idempotency key used with a different request payload" } ``` "Body khác" là bất kỳ giá trị nào thay đổi, như một mốc thời gian mới hay một id ngẫu nhiên được sinh lại trong payload. Hãy dựng payload một lần, lưu cùng key, và gửi lại đúng payload đó ở mọi lần thử. ## Lời khuyên khi thử lại 1. **Tạo key trước lần gọi đầu tiên** và lưu cùng tác vụ, để tiến trình có chết giữa chừng thì key vẫn còn. 2. **Chỉ thử lại lỗi tạm thời:** lỗi mạng, `429 rate_limit_exceeded` và `5xx`. Tăng thời gian chờ theo cấp số nhân và tôn trọng `retry-after` ([Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md)). 3. **Không thử lại lỗi `4xx`** ngoài `429` và `409 concurrent_idempotent_requests`; hãy sửa request. 4. **Coi lần trả lại là thành công.** `200` cho email lặp lại hay tin nhắn chat có `"duplicate": true` nghĩa là lần đầu đã thành công. 5. **Làm cho handler webhook cũng idempotent.** Lượt gửi webhook có thể lặp lại; xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md#make-your-handler-idempotent). ## Bước tiếp theo - [Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md) - [Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md) - [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) Source: https://hatang.dev/vi/docs/guides/getting-started/idempotency --- # Giới thiệu Hạ Tầng là nền tảng cho lập trình viên dành cho doanh nghiệp Việt Nam, ghép từ các **module**: email giao dịch, chat hỗ trợ khách hàng, thanh toán chuyển khoản, danh mục sản phẩm và CRM. Mỗi doanh nghiệp chỉ bật những module mình cần. Tất cả dùng chung một tài khoản, một đội ngũ, một bộ API key và một luồng webhook, nên một lần tích hợp là dùng được mọi thứ. ## Các module | Module | Dùng để | Bắt đầu | |---|---|---| | **Email** | API email tương thích Resend và SMTP relay, nhiều tên miền gửi, template, danh sách nhận, chiến dịch email, automation và nhận thư đến | [Tổng quan Email](https://hatang.dev/vi/docs/guides/email/overview.md) | | **Hỗ trợ** (Hỗ trợ khách hàng) | Widget chat cho website, SDK gốc cho iOS, Android, Flutter và React Native, một hộp thư chung cho Zalo (OA và cá nhân), Messenger, Instagram, TikTok, TikTok Shop, WhatsApp, Telegram, LINE, X và email, cùng phiếu hỗ trợ và trợ lý AI | [Tổng quan Hỗ trợ](https://hatang.dev/vi/docs/guides/support/overview.md) | | **Thanh toán** | Nhận chuyển khoản VietQR thẳng vào tài khoản ngân hàng của bạn, tự đối soát và xuất hoá đơn điện tử | [Tổng quan Thanh toán](https://hatang.dev/vi/docs/guides/payments/overview.md) | | **Sản phẩm** | Danh mục sản phẩm và đơn hàng, gửi thẻ sản phẩm và link thanh toán ngay trong cuộc chat | [REST API Hỗ trợ](https://hatang.dev/vi/docs/guides/support/rest-api.md) | | **CRM** | Một hồ sơ khách hàng gộp từ mọi kênh, trường tuỳ chỉnh, lọc theo giai đoạn, nhãn, kênh, và gửi Zalo ZNS | [REST API Hỗ trợ](https://hatang.dev/vi/docs/guides/support/rest-api.md) | Không có lớp "website" hay "kênh bán" đứng trên các module. Mỗi module tự quản lý tài nguyên của nó và có thể có nhiều cái: Email có nhiều tên miền gửi, Hỗ trợ có nhiều website và ứng dụng di động. Owner hoặc admin bật, tắt module trong **Cài đặt doanh nghiệp → Module**. Xem [Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules). ## Một API key, một luồng webhook Mọi module dùng chung một REST API tại `https://api.hatang.dev`, với cùng quy ước: - **Một loại API key.** Key bắt đầu bằng `ht_` và mang các [scope](https://hatang.dev/vi/docs/guides/getting-started/authentication.md) như `email:send` hay `chat:read`. Gửi trong header `Authorization: Bearer ht_123...`. - **Một định dạng lỗi.** Mọi lỗi là một object JSON `{ statusCode, name, message }`. Xem [Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md). - **Một luồng webhook.** Một endpoint webhook đăng ký sự kiện của bất kỳ module nào (`email.delivered`, `chat.message.created`, `payment.succeeded`, …) và mọi lượt gửi đều ký theo cùng một cách. Xem [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). - **Quy tắc chung** cho [giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md), [phân trang](https://hatang.dev/vi/docs/guides/getting-started/pagination.md) và [idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md). Ngoài ra còn hai host phục vụ các client riêng: | Host | Ai dùng | |---|---| | `https://api.hatang.dev` | Server của bạn: REST API của mọi module | | `https://chat.hatang.dev` | Widget chat và SDK chat di động (gateway realtime) | | `smtp.hatang.dev:5870` (STARTTLS) | Ứng dụng gửi email qua SMTP | | `https://app.hatang.dev` | Dashboard, cho bạn và đội ngũ | ## Cách dùng trang tài liệu này Trang tài liệu có hai phần: - **Hướng dẫn** (phần này) chỉ cách làm một việc từ đầu đến cuối: gửi email đầu tiên, gắn widget chat lên website, nhận thanh toán, xác minh webhook. Hãy bắt đầu với **Bắt đầu**, rồi mở hướng dẫn của các module bạn dùng. - **Tham chiếu** liệt kê mọi chi tiết: [tham chiếu REST API](https://hatang.dev/vi/docs/reference/api.md), [tham chiếu chat gateway](https://hatang.dev/vi/docs/reference/chat-gateway.md), [sự kiện webhook](https://hatang.dev/vi/docs/reference/webhooks.md), [mã lỗi](https://hatang.dev/vi/docs/reference/errors.md), [giới hạn](https://hatang.dev/vi/docs/reference/limits.md) và [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md). Mã mẫu trong hướng dẫn có cURL, Node.js (với [`@hatang/sdk`](https://hatang.dev/vi/docs/guides/getting-started/api-libraries.md)), Python và PHP. Chọn ngôn ngữ một lần, mọi trang sẽ hiện theo ngôn ngữ đó. ## Các bước tích hợp 1. Tạo tài khoản tại [app.hatang.dev](https://app.hatang.dev) và chọn các module cần dùng. 2. Tạo API key với scope của các module đó ([Xác thực API](https://hatang.dev/vi/docs/guides/getting-started/authentication.md)). 3. Gọi API tại `https://api.hatang.dev`, hoặc dùng SDK Node.js `@hatang/sdk`. 4. Thêm một [endpoint webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) để nhận sự kiện. 5. Mời đội ngũ và cấp cho mỗi người đúng quyền họ cần ([Nhân viên và phân quyền](https://hatang.dev/vi/help/account/employees-and-roles)). ## Bước tiếp theo - [Bắt đầu nhanh: gửi email đầu tiên](https://hatang.dev/vi/docs/guides/getting-started/quickstart-email.md) - [Bắt đầu nhanh: gắn chat lên website](https://hatang.dev/vi/docs/guides/getting-started/quickstart-chat.md) - [Bắt đầu nhanh với Thanh toán](https://hatang.dev/vi/docs/guides/payments/quickstart.md) - [Xác thực API](https://hatang.dev/vi/docs/guides/getting-started/authentication.md) Source: https://hatang.dev/vi/docs/guides/getting-started/introduction --- # Phân trang Endpoint danh sách trả kết quả từng trang, mới nhất trước, theo kiểu **phân trang theo khoá (keyset)**: bạn xin trang nằm ngay sau phần tử cuối cùng bạn đang có. Khác với số trang, cách này vẫn đúng khi phần tử mới liên tục được thêm vào. Trang này giải thích tham số, các trường trong response và cách duyệt hết một danh sách. ## Tham số | Tham số | Ý nghĩa | |---|---| | `limit` | Số phần tử cần trả. Giá trị mặc định và tối đa tuỳ danh sách (xem bên dưới). Giá trị ngoài khoảng nhận `422 validation_error`. | | `after` | `id` của phần tử cuối ở trang trước. Response bắt đầu ngay sau phần tử đó. Bỏ trống ở trang đầu. | `GET /emails` nhận thêm `before`, là `id` của một phần tử, để lấy các phần tử mới hơn nó. ## Response Mọi danh sách trả về cùng một dạng: ```json { "object": "list", "has_more": true, "next_cursor": "pay_01a0b904297873e8b3524a26fdca0ab5", "data": [ { "object": "payment", "id": "pay_01a0b904...", "status": "paid" }, { "object": "payment", "id": "pay_01a0b903...", "status": "pending" } ] } ``` | Trường | Ý nghĩa | |---|---| | `data` | Các phần tử của trang này | | `has_more` | `true` khi còn ít nhất một trang nữa | | `next_cursor` | Chỉ có ở danh sách của Thanh toán: giá trị truyền vào `after` để lấy trang tiếp, hoặc `null` ở trang cuối. Đó là `id` của phần tử cuối trong `data`. | Với danh sách không có `next_cursor`, truyền `id` của phần tử cuối trong `data` vào `after`. Kết quả như nhau. ## Giới hạn theo từng danh sách | Danh sách | `limit` mặc định | Tối đa | |---|---|---| | Email: email, thư đến, liên hệ, template, logs | 20 | 100 | | Thanh toán: thanh toán, giao dịch, hoá đơn điện tử | 20 | 100 | | Hỗ trợ: hội thoại | 50 | 100 | | Hỗ trợ, Sản phẩm và CRM: phiếu hỗ trợ, sản phẩm, đơn hàng, khách hàng | 50 | 200 | Một vài danh sách nhỏ như tên miền, webhook và chiến dịch email trả mọi phần tử trong một response. Tin nhắn của hội thoại phân trang theo cách khác, bằng số thứ tự: `GET /chat/conversations/{id}/messages` nhận `after_seq` (`seq` của tin nhắn cuối bạn đang có) và `limit` tối đa 200, mặc định 100. Đây là cách đồng bộ một hội thoại theo từng phần mới. ## Duyệt hết một danh sách Lấy một trang, xử lý, rồi tiếp tục khi `has_more` còn là `true`: **cURL** ```bash # Trang đầu curl "https://api.hatang.dev/payments?limit=100" \ -H "Authorization: Bearer $HATANG_API_KEY" # Trang tiếp: truyền next_cursor (hoặc id cuối) vào after curl "https://api.hatang.dev/payments?limit=100&after=pay_01a0b904297873e8b3524a26fdca0ab5" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); async function* allEmails() { let after; while (true) { const { data: page, error } = await hatang.emails.list({ limit: 100, after }); if (error) throw new Error(`${error.name}: ${error.message}`); yield* page.data; if (!page.has_more || page.data.length === 0) return; after = page.data[page.data.length - 1].id; } } for await (const email of allEmails()) { console.log(email.id, email.last_event); } ``` **Python** ```python from hatang import Hatang hatang = Hatang() # đọc HATANG_API_KEY def all_payments(): after = None while True: page = hatang.payments.list(limit=100, after=after) yield from page["data"] if not page["has_more"]: return after = page.get("next_cursor") or page["data"][-1]["id"] for payment in all_payments(): print(payment["id"], payment["status"]) ``` **PHP** ```php function allConversations(GuzzleHttp\Client $client): Generator { $query = ['limit' => 100]; do { $page = json_decode((string) $client->get('/chat/conversations', ['query' => $query])->getBody(), true); yield from $page['data']; $last = end($page['data']); $query['after'] = $last ? $last['id'] : null; } while ($page['has_more'] && $last); } foreach (allConversations($client) as $conversation) { echo $conversation['id'], PHP_EOL; } ``` > **Tip** > > Bộ lọc như `status` giữ nguyên ở mọi trang: gửi kèm trong từng request cùng với `after`. Mỗi trang là một request tính vào [giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md), nên hãy dùng `limit` lớn nhất mà bạn xử lý được. ## Bước tiếp theo - [Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md) - [Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md) - [Tham chiếu REST API](https://hatang.dev/vi/docs/reference/api.md) Source: https://hatang.dev/vi/docs/guides/getting-started/pagination --- # Bắt đầu nhanh: gắn chat lên website Hướng dẫn này đặt nút chat lên website của bạn, cho hộp thư biết người dùng đã đăng nhập là ai, và để bạn trả lời tin nhắn đầu tiên từ dashboard, từ ứng dụng Hạ Tầng trên điện thoại hoặc từ server của bạn. Cuối cùng, bạn thêm cùng khung chat đó vào ứng dụng iOS, Android, Flutter hoặc React Native. ## Trước khi bắt đầu - Một tài khoản Hạ Tầng tại [app.hatang.dev](https://app.hatang.dev) đã bật module **Hỗ trợ** ([Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules)). - Một website bạn sửa được, có quyền vào HTML chung của trang hoặc vào tag manager. - Để đổi cài đặt Hỗ trợ, bạn cần là chủ sở hữu, quản trị viên, hoặc thành viên có quyền **Quản lý** ở Hỗ trợ ([Nhân viên và phân quyền](https://hatang.dev/vi/help/account/employees-and-roles)). ## 1. Sao chép đoạn mã 1. Trong dashboard, mở **Hỗ trợ → Kênh** và chọn website của bạn. Muốn thêm website khác, bấm **Thêm kênh** rồi chọn ở nhóm **Website**. 2. Ở khung **Cài đặt**, bấm **Sao chép mã**. Đoạn mã gồm hai dòng. Dòng đầu đặt site id (bắt đầu bằng `site_`), dòng sau tải widget từ chat gateway: ```html ``` ## 2. Gắn vào website Dán đoạn mã ngay trước `` ở mọi trang, hoặc ở layout dùng chung cho mọi trang. Script tải bất đồng bộ nên không làm chậm trang. Tải lại website: nút chat hiện ở góc màn hình. Trong dashboard, khung **Cài đặt** chuyển sang **Đã cài** và hiện tên miền nơi widget được thấy. Dùng WordPress, Shopify, Haravan, LadiPage, Sapo, Google Tag Manager, Webflow hay React? [Hướng dẫn cài widget](https://hatang.dev/vi/docs/guides/support/web-widget/install.md) có các bước cho từng nền tảng, còn [plugin WordPress](https://hatang.dev/vi/docs/guides/integrations/wordpress.md) không cần viết mã. > **Note: Widget chạy ở đâu** > > Widget chỉ khởi động trên tên miền của website và các tên miền phụ. Để thử trên `http://localhost:3000` hoặc bản xem trước, thêm origin đó vào **Origin được phép thêm** trên trang website. Origin khác bị từ chối với `origin_not_allowed`. ## 3. Nhận diện người dùng đã đăng nhập Nếu không viết thêm mã, mọi khách truy cập đều là khách ẩn danh. Khi người dùng đã đăng nhập trên website, hãy truyền thông tin của họ cho widget để nhân viên biết mình đang nói chuyện với ai: ```html ``` `$hatang.push` dùng được cả trước và sau khi widget tải xong, nên bạn đặt đoạn này ở đâu cũng được, miễn là sau dòng đầu của đoạn mã cài đặt. `hash` chứng minh user id đến từ server của bạn chứ không phải do ai đó gõ trong console của trình duyệt. Không có `hash`, thông tin được hiển thị là **Chưa xác minh** và không bao giờ được gộp vào lịch sử của khách khác. Có `hash` hợp lệ, hộp thư coi user id là **Đã xác minh** và gộp mọi phiên của người đó, trên mọi thiết bị, vào một khách hàng. Tính `hash` ở server bằng HMAC-SHA256 của user id, khoá là **Mã bí mật định danh** của site (trên trang website, bấm **Tạo mới & hiển thị**), dạng hex chữ thường: **Node.js** ```js import { createHmac } from "node:crypto"; export const hatangHash = (userId) => createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(String(userId)).digest("hex"); ``` **Python** ```python import os from hatang import identity_hash # pip install hatang def hatang_hash(user_id) -> str: return identity_hash(str(user_id), os.environ["HATANG_IDENTITY_SECRET"]) ``` **PHP** ```php $hash = hash_hmac('sha256', (string) $userId, getenv('HATANG_IDENTITY_SECRET')); ``` > **Danger: Giữ mã bí mật định danh ở server** > > Ai có mã bí mật định danh đều có thể vào chat dưới danh nghĩa bất kỳ người dùng nào của bạn. Không đặt nó vào JavaScript của trang, ứng dụng di động hay plugin công khai; chỉ gửi `hash` đã tính xuống trình duyệt. [Hướng dẫn nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md) giải thích cách gộp phiên, xoay mã bí mật và cú pháp cho từng SDK. [JavaScript API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) liệt kê các lệnh khác như `open`, `setLocale` và `on`. ## 4. Trả lời từ hộp thư Mở website trong cửa sổ ẩn danh và gửi một tin nhắn từ widget. Tin nhắn hiện ngay trong **Hỗ trợ → Hộp thư**. Gõ câu trả lời, khách thấy ngay trong widget. Nhân viên cũng trả lời được từ ứng dụng Hạ Tầng trên iPhone, Android và Mac, có thông báo đẩy khi có tin nhắn mới. Muốn trả lời từ hệ thống của bạn, dùng REST API với key có scope `chat:write`. Lấy danh sách hội thoại đang mở, rồi gửi tin nhắn dưới tên đội ngũ: **cURL** ```bash curl "https://api.hatang.dev/chat/conversations?status=open&limit=20" \ -H "Authorization: Bearer $HATANG_API_KEY" curl -X POST https://api.hatang.dev/chat/conversations/cnv_123.../messages \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-cnv_123-1" \ -d '{ "content": "Chào anh An, đơn của anh được giao trong hôm nay ạ." }' ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data: open } = await hatang.chat.conversations.list({ status: "open", limit: 20 }); const conversation = open.data[0]; const { data, error } = await hatang.chat.conversations.send( conversation.id, { content: "Chào anh An, đơn của anh được giao trong hôm nay ạ." }, { idempotencyKey: `reply-${conversation.id}-1` }, ); ``` **Python** ```python from hatang import Hatang hatang = Hatang() # đọc HATANG_API_KEY open_ = hatang.chat.conversations.list(status="open", limit=20) conversation_id = open_["data"][0]["id"] message = hatang.chat.conversations.send( conversation_id, {"content": "Chào anh An, đơn của anh được giao trong hôm nay ạ."}, idempotency_key=f"reply-{conversation_id}-1", ) print(message) ``` **PHP** ```php 'https://api.hatang.dev', 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], ]); $open = json_decode((string) $client->get('/chat/conversations', ['query' => ['status' => 'open', 'limit' => 20]])->getBody(), true); $conversationId = $open['data'][0]['id']; $res = $client->post("/chat/conversations/{$conversationId}/messages", [ 'headers' => ['Idempotency-Key' => "reply-{$conversationId}-1"], 'json' => ['content' => 'Chào anh An, đơn của anh được giao trong hôm nay ạ.'], ]); ``` Lệnh gửi tin nhắn trả `201`: ```json { "object": "chat_message", "id": "msg_123...", "seq": 7, "conversation_id": "cnv_123...", "duplicate": false } ``` Để phản hồi tin nhắn mới của khách mà không phải hỏi liên tục, đăng ký [webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) cho sự kiện `chat.message.created`. [Hướng dẫn REST API Hỗ trợ](https://hatang.dev/vi/docs/guides/support/rest-api.md) có thêm cách giao hội thoại, gắn tag, ghi chú nội bộ, khách hàng và đơn hàng. ## 5. Thêm chat vào ứng dụng di động SDK gốc nói chuyện với cùng gateway như widget, nên tin nhắn từ ứng dụng về cùng hộp thư, và người dùng đã xác minh giữ một lịch sử chung giữa web và di động. 1. Ở **Hỗ trợ → Kênh**, bấm **Thêm kênh**, chọn **Ứng dụng di động**, rồi **Tạo khoá ứng dụng**. Khoá bắt đầu bằng `hak_` và chỉ hiện một lần. 2. Cài SDK cho nền tảng của bạn và cấu hình bằng site id và khoá ứng dụng. 3. Gọi `identify` với cùng user id và `hash` như trên web. 4. Nếu cần, tải thông tin Firebase lên để ứng dụng nhận [thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md). | Nền tảng | Hướng dẫn | |---|---| | Flutter | [SDK Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md) | | iOS (Swift) | [SDK iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md) | | Android (Kotlin) | [SDK Android](https://hatang.dev/vi/docs/guides/sdks/android.md) | | React Native / Expo | [SDK React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md) | | React / Next.js (web) | [Component React](https://hatang.dev/vi/docs/guides/sdks/react.md) | Khoá ứng dụng không bí mật như API key (nó nằm trong bản build), nhưng bạn thu hồi và cấp khoá mới được bất cứ lúc nào. Đừng bao giờ đưa API key `ht_` hay mã bí mật định danh vào ứng dụng. ## Lỗi thường gặp | Hiện tượng | Nguyên nhân | Cách xử lý | |---|---|---| | Không thấy nút chat, console báo `origin_not_allowed` | Trang không nằm trên tên miền của website | Thêm origin vào **Origin được phép thêm** | | Không thấy nút chat, báo `product_disabled` | Module Hỗ trợ đang tắt | Nhờ chủ sở hữu hoặc quản trị viên bật lại | | Không thấy nút chat ở một số trang | Quy tắc hiển thị trên trang website đang ẩn widget ở đó | Kiểm tra cài đặt nơi hiện widget | | Người dùng hiện **Chưa xác minh** | Thiếu `hash` hoặc `hash` không khớp | Tính từ đúng chuỗi user id, với mã bí mật định danh hiện tại | | Ứng dụng báo `invalid_app_key` | Khoá sai, hoặc khoá của site khác | Sao chép lại khoá ứng dụng của đúng website | ## Bước tiếp theo - [Tổng quan Hỗ trợ](https://hatang.dev/vi/docs/guides/support/overview.md): kênh, phân chia hội thoại, phiếu hỗ trợ và trợ lý AI - [JavaScript API của widget](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md) - [Tổng quan SDK](https://hatang.dev/vi/docs/guides/sdks/overview.md) Source: https://hatang.dev/vi/docs/guides/getting-started/quickstart-chat --- # Bắt đầu nhanh: gửi email đầu tiên Hướng dẫn này đưa bạn từ tài khoản mới đến một email đã gửi tới hộp thư người nhận trong khoảng mười phút. Bạn tạo API key, xác minh tên miền gửi, gửi thư qua REST API rồi xem lại nó trong dashboard. ## Trước khi bắt đầu - Một tài khoản Hạ Tầng tại [app.hatang.dev](https://app.hatang.dev) đã bật module **Email** ([Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules)). - Quyền sửa DNS của một tên miền bạn sở hữu, ví dụ `shop.vn`. - Để tạo key, bạn cần là chủ sở hữu, quản trị viên, hoặc thành viên có quyền **Developer** ([Nhân viên và phân quyền](https://hatang.dev/vi/help/account/employees-and-roles)). ## 1. Tạo API key 1. Trong dashboard, mở **Cài đặt → API Keys** (trang này cũng có ở **Email → API Keys**). 2. Bấm **Tạo key**, đặt tên như `production`, và tích **Gửi** ở mục Email. Quyền này là scope `email:send`, đủ để gửi thư. 3. Có thể chọn **Tên miền gửi** để khoá key vào một tên miền. 4. Sao chép key. Key bắt đầu bằng `ht_` và chỉ hiện một lần. Lưu key vào biến môi trường trên server: ```bash export HATANG_API_KEY="ht_123..." ``` > **Caution** > > API key chỉ dùng ở server. Đừng bao giờ đặt key vào JavaScript chạy trên trình duyệt hay trong gói ứng dụng di động. Xem [Xác thực API](https://hatang.dev/vi/docs/guides/getting-started/authentication.md) về scope và key bị giới hạn. ## 2. Xác minh tên miền gửi Hạ Tầng chỉ gửi từ những tên miền bạn đã chứng minh là của mình. 1. Mở **Email → Tên miền** và thêm tên miền, ví dụ `shop.vn`. 2. Thêm các bản ghi DNS mà Hạ Tầng hiển thị vào nhà cung cấp DNS của bạn. 3. Chờ tên miền chuyển sang **Đã xác minh**. [Hướng dẫn Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) giải thích từng bản ghi, thời gian xác minh và cách xử lý tên miền mãi ở trạng thái chờ. Nếu gửi từ tên miền chưa xác minh, API trả `403` với `name: "validation_error"` và thông báo như `The shop.vn domain is not verified. Please, add and verify your domain first.` ## 3. Gửi email Gửi request `POST` tới `https://api.hatang.dev/emails`. Địa chỉ `from` phải thuộc tên miền đã xác minh. `to` nhận một địa chỉ hoặc một danh sách. **cURL** ```bash curl -X POST https://api.hatang.dev/emails \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Shop ", "to": ["customer@example.com"], "subject": "Đơn hàng của bạn đã được xác nhận", "html": "

Cảm ơn bạn đã đặt hàng!

" }' ``` **Node.js** ```bash npm install @hatang/sdk ``` ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const { data, error } = await hatang.emails.send({ from: "Shop ", to: ["customer@example.com"], subject: "Đơn hàng của bạn đã được xác nhận", html: "

Cảm ơn bạn đã đặt hàng!

", }); if (error) { console.error(error.name, error.message); } else { console.log("Đã gửi", data.id); } ``` **Python** ```python from hatang import Hatang, HatangError # pip install hatang hatang = Hatang() # đọc HATANG_API_KEY try: email = hatang.emails.send({ "from": "Shop ", "to": ["customer@example.com"], "subject": "Đơn hàng của bạn đã được xác nhận", "html": "

Cảm ơn bạn đã đặt hàng!

", }) print("Đã gửi", email["id"]) except HatangError as err: print(err.name, err.message) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/emails', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'from' => 'Shop ', 'to' => ['customer@example.com'], 'subject' => 'Đơn hàng của bạn đã được xác nhận', 'html' => '

Cảm ơn bạn đã đặt hàng!

', ], 'http_errors' => false, ]); $body = json_decode((string) $res->getBody(), true); if ($res->getStatusCode() === 200) { echo 'Đã gửi ' . $body['id']; } else { echo $body['name'] . ': ' . $body['message']; } ``` Gọi thành công trả `200` kèm id của email mới: ```json { "id": "01a0b904-2978-73e8-b352-4a26fdca0ab5" } ``` Email được xếp hàng và gửi đi ở nền. Request cần `html`, `text` hoặc một `template` đã xuất bản; `to`, `cc` và `bcc` cộng lại tối đa 50 địa chỉ. > **Tip: Thử lại an toàn** > > Thêm header `Idempotency-Key` (ví dụ mã đơn hàng) để request gửi lại không bao giờ gửi trùng email. Xem [Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md). > **Note: Base URL của SDK Node.js** > > Truyền `baseUrl: "https://api.hatang.dev"` (hoặc đặt biến môi trường `HATANG_BASE_URL`) khi tạo client, như ví dụ trên. ## 4. Xem lại trong dashboard - **Email → Email** liệt kê mọi thư cùng trạng thái: đang chờ, đã gửi, đã nhận, bị trả về, v.v. Mở một thư để xem từng người nhận và các sự kiện gửi. - **Email → Logs** liệt kê mọi request API mà key của bạn đã gọi, kèm mã trạng thái, thời gian xử lý, body của request và response. Request lỗi hiện ở đây cùng lỗi của nó. Bạn cũng đọc được trạng thái qua API: ```bash curl https://api.hatang.dev/emails/01a0b904-2978-73e8-b352-4a26fdca0ab5 \ -H "Authorization: Bearer $HATANG_API_KEY" ``` Response dưới đây đã được rút gọn: ```json { "object": "email", "id": "01a0b904-2978-73e8-b352-4a26fdca0ab5", "from": "Shop ", "to": ["customer@example.com"], "subject": "Đơn hàng của bạn đã được xác nhận", "last_event": "delivered", "created_at": "2026-10-23T09:34:12.952Z" } ``` ## Lỗi thường gặp | Status | `name` | Nguyên nhân | Cách xử lý | |---|---|---|---| | 401 | `missing_api_key` | Thiếu header `Authorization` | Gửi `Authorization: Bearer ht_...` | | 403 | `invalid_api_key` | Key sai hoặc đã bị thu hồi | Sao chép lại key hoặc tạo key mới | | 401 | `restricted_api_key` | Key bị khoá vào tên miền gửi khác | Dùng key của tên miền này, hoặc key cho mọi tên miền | | 403 | `validation_error` | Tên miền của `from` chưa xác minh | Hoàn tất [xác minh tên miền](https://hatang.dev/vi/docs/guides/email/domains.md) | | 422 | `validation_error` | Thiếu trường hoặc trường không hợp lệ; `message` nêu tên trường | Sửa body của request | | 403 | `product_disabled` | Module Email đang tắt | Nhờ chủ sở hữu hoặc quản trị viên bật lại | | 429 | `rate_limit_exceeded` | Quá 10 request email mỗi giây | Chờ rồi thử lại ([Giới hạn tần suất](https://hatang.dev/vi/docs/guides/getting-started/rate-limits.md)) | | 429 | `daily_quota_exceeded` | Doanh nghiệp đã dùng hết hạn mức email trong ngày | Chờ đến 0 giờ UTC hoặc nâng hạn mức ở **Email → Cài đặt email** | ## Bước tiếp theo - [Nhận sự kiện gửi thư qua webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) - [Tổng quan Email](https://hatang.dev/vi/docs/guides/email/overview.md): template, chiến dịch email, danh sách nhận và thư đến - [Số liệu và logs](https://hatang.dev/vi/docs/guides/email/metrics-and-logs.md) - [Xác thực API](https://hatang.dev/vi/docs/guides/getting-started/authentication.md) Source: https://hatang.dev/vi/docs/guides/getting-started/quickstart-email --- # Giới hạn tần suất REST API giới hạn số request mà một doanh nghiệp gọi được mỗi giây, tính riêng cho từng module; riêng Email còn có hạn mức gửi hằng ngày. Trang này liệt kê các giới hạn, các header cho biết bạn đang ở đâu, và cách thử lại khi chạm giới hạn. ## Số request mỗi giây Giới hạn được đếm theo **doanh nghiệp** (không theo API key), trong cửa sổ trượt một giây. Mọi key của một doanh nghiệp dùng chung một hạn mức. | Endpoint | Giới hạn | |---|---| | Hỗ trợ, Sản phẩm và CRM: mọi thứ dưới `/chat/` | 50 request mỗi giây | | Thanh toán: `/payments` và `/einvoices` | 20 request mỗi giây | | Email và phần còn lại: `/emails`, `/domains`, `/templates`, `/contacts`, `/audiences`, `/segments`, `/topics`, `/broadcasts`, `/automations`, `/events`, `/webhooks`, `/api-keys`, `/logs`, `/usage` | 10 request mỗi giây | Mỗi module có hạn mức riêng: một đợt gửi email dồn dập không làm chậm các lời gọi chat hay thanh toán. Chat gateway tại `chat.hatang.dev`, nơi widget và SDK di động kết nối, có giới hạn riêng về khách truy cập và tin nhắn. Xem [Giới hạn](https://hatang.dev/vi/docs/reference/limits.md). ## Header giới hạn tần suất Mọi response của endpoint có giới hạn tần suất đều kèm các header sau: | Header | Ý nghĩa | |---|---| | `ratelimit-limit` | Số request được phép trong một cửa sổ của module này | | `ratelimit-remaining` | Số request còn lại trong cửa sổ hiện tại | | `ratelimit-reset` | Số giây tới khi cửa sổ làm mới (luôn là `1`) | | `retry-after` | Chỉ có khi nhận `429`: số giây cần chờ trước khi thử lại | ```http HTTP/1.1 429 Too Many Requests ratelimit-limit: 10 ratelimit-remaining: 0 ratelimit-reset: 1 retry-after: 1 content-type: application/json { "statusCode": 429, "name": "rate_limit_exceeded", "message": "Too many requests. You can only make 10 requests per second." } ``` ## Hạn mức email hằng ngày Ngoài giới hạn mỗi giây, mỗi doanh nghiệp có **hạn mức email hằng ngày**: số email được nhận gửi trong một ngày. Mỗi email được nhận tính một lần, dù đến từ `POST /emails`, gửi theo lô, SMTP relay hay chiến dịch email. Email trả lời gửi từ hộp thư Hỗ trợ không bị tính. Bộ đếm làm mới lúc **0 giờ UTC**. Vượt hạn mức, lệnh gửi nhận `429 daily_quota_exceeded`: ```json { "statusCode": 429, "name": "daily_quota_exceeded", "message": "You have reached your daily email sending quota of 10000. It resets at midnight UTC." } ``` Thử lại không có tác dụng cho tới khi hạn mức làm mới. Xem hạn mức và số đã dùng hôm nay ở **Email → Cài đặt email** (**Hạn mức hằng ngày**), hoặc qua `GET /usage` (response đã rút gọn): ```json { "object": "usage", "emails": { "daily": { "used": 1520, "limit": 10000, "resets_at": "2026-10-24T00:00:00.000Z" } } } ``` Thành viên có quyền **Quản lý** ở Email đặt được hạn mức thấp hơn hoặc cao hơn cho doanh nghiệp trong Cài đặt email, một lưới an toàn hữu ích khi code lỡ chạy vòng lặp gửi thư. Để trống ô này để dùng mặc định của nền tảng. `GET /usage` cần key có một scope `write`. ## Thử lại với backoff Chỉ thử lại `429 rate_limit_exceeded` và lỗi `5xx`, không thử lại lỗi `4xx` do chính request gây ra. Chờ đúng `retry-after` giây nếu có header này, nếu không thì tăng thời gian chờ theo cấp số nhân, và cộng thêm một chút ngẫu nhiên để nhiều worker không thử lại cùng lúc. Kết hợp thử lại với [`Idempotency-Key`](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md) để lệnh gửi lặp lại không bao giờ tạo bản trùng. **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev" }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // statusCode 0 nghĩa là không kết nối được tới API (lỗi mạng). const retryable = (e) => e.name === "rate_limit_exceeded" || e.statusCode >= 500 || e.statusCode === 0; async function withRetry(call, { attempts = 5 } = {}) { for (let i = 0; ; i++) { const result = await call(); if (!result.error || !retryable(result.error) || i === attempts - 1) return result; const backoff = Math.min(1000 * 2 ** i, 30_000); await sleep(backoff + Math.random() * 250); } } const { data, error } = await withRetry(() => hatang.emails.send( { from: "Shop ", to: "customer@example.com", subject: "Xin chào", html: "

Xin chào

" }, { idempotencyKey: "welcome-user-42" }, ), ); ``` **Python** ```python import random, time from hatang import Hatang, HatangError hatang = Hatang() # đọc HATANG_API_KEY def with_retry(call, attempts=5): for i in range(attempts): try: return call() except HatangError as err: # thử lại được: rate_limit_exceeded (429), 5xx, hoặc không tới được API (status_code 0) if not err.retryable or i == attempts - 1: raise time.sleep(min(2 ** i, 30) + random.random() / 4) email = with_retry(lambda: hatang.emails.send( {"from": "Shop ", "to": "customer@example.com", "subject": "Hi", "html": "

Hi

"}, idempotency_key="welcome-user-42", )) ``` SDK Node.js trả về `{ data, error }` và không tự thử lại, nên hãy bọc các lời gọi quan trọng như ví dụ trên. ## Giữ dưới giới hạn - **Gửi email theo lô.** `POST /emails/batch` gửi tối đa 100 email trong một request, và chỉ tính là một request với giới hạn mỗi giây. - **Dùng webhook thay vì hỏi liên tục.** Đăng ký `email.delivered` hay `chat.message.created` thay vì hỏi trạng thái trong vòng lặp ([Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md)). - **Xếp hàng việc lớn.** Chạy import và đồng bộ qua hàng đợi với số luồng cố định thay vì bắn mọi request cùng lúc. - **Đọc header.** Giảm tốc khi `ratelimit-remaining` gần về `0`. ## Bước tiếp theo - [Lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md) - [Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md) - [Tham chiếu giới hạn](https://hatang.dev/vi/docs/reference/limits.md) Source: https://hatang.dev/vi/docs/guides/getting-started/rate-limits --- # Bảo mật Một tích hợp Hạ Tầng giữ nhiều secret: API key, secret ký webhook, mã bí mật định danh của chat và secret key của Thanh toán. Trang này là danh sách kiểm tra để giữ an toàn từng loại, thu hẹp những gì một secret bị lộ có thể làm, và xác minh mọi thứ gửi tới server của bạn. ## Các loại thông tin xác thực | Loại | Dạng | Đặt ở đâu | Bí mật? | |---|---|---|---| | API key | `ht_...` | Chỉ ở server của bạn | Có | | Secret ký webhook | `whsec_...` | Chỉ ở server của bạn | Có | | Mã bí mật định danh của chat | hiện một lần trên trang website | Chỉ ở server của bạn | Có | | Secret key của Thanh toán | `sk_...` | Chỉ ở server của bạn | Có | | Site id của chat | `site_...` | Trang web và ứng dụng của bạn | Không, công khai | | Khoá ứng dụng di động | `hak_...` | Trong bản build ứng dụng | Không, nhưng thu hồi được | Mọi thứ được đánh dấu bí mật không bao giờ được xuất hiện trong JavaScript chạy trên trình duyệt, ứng dụng di động, plugin công khai, kho Git hay phiếu hỗ trợ. Giữ chúng trong biến môi trường hoặc trình quản lý secret. ## API key - **Chỉ cấp cho key những scope nó cần.** Server chỉ gửi email cần `email:send`, không cần `*` ([Xác thực API](https://hatang.dev/vi/docs/guides/getting-started/authentication.md#scopes)). - **Mỗi dịch vụ hoặc môi trường một key**, đặt tên theo nơi nó chạy. Như vậy bạn thu hồi một key mà không đụng tới key khác, và cột **Dùng lần cuối** cho biết key nào còn được dùng. - **Giới hạn key.** Khoá key email vào một **Tên miền gửi**. Chỉ khi key buộc phải chạy trong trình duyệt mới điền **Origin trình duyệt**; request từ origin khác bị từ chối ([Key bị giới hạn](https://hatang.dev/vi/docs/guides/getting-started/authentication.md#restricted-keys)). - **Xoay key** khi người từng biết key nghỉ việc, khi nghi lộ, và theo lịch định kỳ: tạo key mới, triển khai, rồi thu hồi key cũ. Key bị thu hồi ngừng hoạt động trong vòng một phút. - **Kiểm tra việc sử dụng.** **Email → Logs** liệt kê mọi request API kèm key đã gọi, user agent, status và body. Lời gọi hay endpoint lạ là dấu hiệu key bị lộ. Key chỉ hiện một lần khi tạo. Hạ Tầng chỉ lưu bản băm, nên key bị mất không khôi phục được; hãy tạo key mới. ## Webhook - **Xác minh mọi lượt gửi.** Kiểm tra chữ ký trên body gốc và từ chối lượt gửi có timestamp cũ quá 300 giây ([Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md)). Lượt gửi được xác thực bằng chữ ký, không phải bằng nơi nó xuất phát. Header `user-agent: hatang-webhooks/1.0` chỉ mang tính thông tin. - **Dùng endpoint HTTPS.** Hạ Tầng chỉ gửi tới URL `https` công khai và không đi theo chuyển hướng. - **Xoay secret ký** khi nó có thể đã lộ. Trong 24 giờ sau khi xoay, lượt gửi mang chữ ký của cả secret cũ lẫn mới, nên bạn triển khai mà không mất sự kiện. - **Lọc trùng theo `svix-id`** để lượt gửi lại hay thử lại không kích hoạt cùng một hành động hai lần. - **Lời gọi IPN của Thanh toán** tới `ipn_url` được ký riêng, bằng `Hatang-Signature` và secret key của môi trường. Xem [Xác minh IPN](https://hatang.dev/vi/docs/guides/payments/verify-ipn.md). ## Danh tính trong chat - **Xác minh danh tính.** Khi website hoặc ứng dụng cho chat biết người dùng là ai, hãy kèm `hash` (HMAC-SHA256 của user id với mã bí mật định danh của site) tính ở server. Không có nó, ai cũng có thể tự nhận là khách hàng khác và đọc lịch sử của họ ([Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md)). - **Giữ mã bí mật định danh ở server**, và xoay bằng **Tạo mới & hiển thị** nếu nghi lộ. Hash tạo bằng mã cũ hết hiệu lực ngay. - **Giới hạn nơi widget chạy.** Widget chỉ khởi động trên tên miền của website, các tên miền phụ và các **Origin được phép thêm** bạn liệt kê. Gỡ các origin xem trước và localhost không còn dùng. - **Thu hồi khoá ứng dụng di động** không còn dùng, sau khi đã phát hành bản ứng dụng dùng khoá mới. ## Thanh toán - Giữ secret key của từng môi trường ở server và kiểm tra `environment` trong mọi sự kiện Thanh toán và IPN, để thanh toán development không bao giờ kích hoạt đơn hàng thật. - Key `ht_test_` không bao giờ chạm được dữ liệu production. Quyền vào production được chọn theo từng request bằng `Hatang-Environment` ([Môi trường Thanh toán](https://hatang.dev/vi/docs/guides/payments/environments.md)). ## SMTP relay SMTP relay tại `smtp.hatang.dev:5870` dùng STARTTLS, tên đăng nhập `hatang` và mật khẩu là một API key. Chỉ cấp cho key đó scope `email:send`. Sau 10 lần đăng nhập sai từ một địa chỉ IP, relay từ chối mọi lần thử tiếp theo từ địa chỉ đó trong 15 phút. ## Đội ngũ - Cấp cho nhân viên mức quyền thấp nhất đủ để làm việc, và chỉ cấp quyền **Developer** (tạo được API key và webhook) cho người cần ([Nhân viên và phân quyền](https://hatang.dev/vi/help/account/employees-and-roles)). - Gỡ người nghỉ việc ở **Cài đặt → Nhân viên**, và xoay các API key, secret họ từng biết. Key của họ vẫn chạy cho tới khi bạn thu hồi, vì key không gắn với một người. ## Khi một secret bị lộ 1. **API key:** thu hồi ở **Cài đặt → API Keys**, tạo key thay thế, và xem **Email → Logs** để tìm các lời gọi không phải của bạn. 2. **Secret webhook:** xoay secret và triển khai secret mới trong vòng 24 giờ. 3. **Mã bí mật định danh:** bấm **Tạo mới & hiển thị** và cập nhật server. 4. **Secret key Thanh toán:** thay mới ở **Cài đặt thanh toán → Khoá tích hợp** và cập nhật server. ## Bước tiếp theo - [Xác thực API](https://hatang.dev/vi/docs/guides/getting-started/authentication.md) - [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md) Source: https://hatang.dev/vi/docs/guides/getting-started/security --- # Webhook Webhook báo cho server của bạn khi có chuyện xảy ra trong Hạ Tầng: một email đã tới người nhận, khách gửi tin nhắn chat, một khoản chuyển khoản thanh toán cho đơn hàng. Bạn đăng ký một endpoint HTTPS và chọn các sự kiện muốn nhận. Hạ Tầng gửi mỗi sự kiện bằng một request `POST` có chữ ký, tự thử lại khi endpoint lỗi, và lưu nhật ký mọi lượt gửi. Một endpoint nhận được sự kiện của mọi module. Chữ ký theo chuẩn [Svix](https://docs.svix.com/receiving/verifying-payloads/how) và [Standard Webhooks](https://www.standardwebhooks.com/), nên các thư viện có sẵn đều dùng được. ## Trước khi bắt đầu - Bạn cần là chủ sở hữu, quản trị viên, hoặc thành viên có quyền **Developer** ([Nhân viên và phân quyền](https://hatang.dev/vi/help/account/employees-and-roles)). - Endpoint phải truy cập được từ Internet qua HTTPS. Khi phát triển trên máy, xem [Thử trên máy](#test-locally). ## 1. Tạo endpoint 1. Trong dashboard, mở **Cài đặt → Webhooks** (cũng có ở **Email → Webhooks**). 2. Bấm **Thêm endpoint**, nhập URL, ví dụ `https://shop.vn/webhooks/hatang`, và tích các sự kiện cần gửi. 3. Sao chép **secret ký**. Secret bắt đầu bằng `whsec_` và chỉ hiện một lần. Hoặc tạo bằng API, với key có một scope `write` bất kỳ: ```bash curl -X POST https://api.hatang.dev/webhooks \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "endpoint": "https://shop.vn/webhooks/hatang", "events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"], "description": "Backend production" }' ``` ```json { "object": "webhook", "id": "wh_123...", "endpoint": "https://shop.vn/webhooks/hatang", "events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"], "status": "enabled", "description": "Backend production", "created_at": "2026-10-23T09:34:12.952Z", "signing_secret": "whsec_123..." } ``` Lưu secret vào biến môi trường, ví dụ `HATANG_WEBHOOK_SECRET`. URL của endpoint phải dùng `https` và trỏ tới một host công khai. URL chứa tên đăng nhập hay mật khẩu, host `localhost`, `.local`, `.internal` và địa chỉ IP nội bộ bị từ chối với `422 validation_error`. Muốn tạm dừng nhận, đặt `status` của endpoint thành `disabled` (`PATCH /webhooks/{id}`). ## 2. Chọn sự kiện Mọi lượt gửi có cùng một dạng: `type` của sự kiện, thời điểm xảy ra, và đối tượng trong `data`. ```json { "type": "email.delivered", "created_at": "2026-10-23T09:34:15.120Z", "data": { "email_id": "01a0b904-2978-73e8-b352-4a26fdca0ab5", "from": "Shop ", "to": ["customer@example.com"], "subject": "Đơn hàng của bạn đã được xác nhận", "created_at": "2026-10-23T09:34:12.952Z" } } ``` | Module | Sự kiện | |---|---| | Email | `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.received`, `contact.created`, `contact.updated`, `contact.deleted`, `domain.created`, `domain.updated`, `domain.deleted` | | Hỗ trợ | `chat.conversation.created`, `.assigned`, `.resolved`, `.closed`, `.reopened`, `.handoff`, `.tagged`; `chat.message.created`; `chat.visitor.identified`; `chat.ticket.created`, `.updated`, `.resolved`, `.breached`; `chat.rating.created`; `chat.flow.webhook`; `chat.campaign.lead` | | Sản phẩm | `chat.order.created`, `chat.order.updated`, `chat.order.paid` | | CRM | `chat.zns.sent`, `chat.zns.failed` | | Thanh toán | `payment.created`, `.succeeded`, `.partially_paid`, `.overpaid`, `.expired`, `.canceled`; `transaction.received`; `bank_connection.activated`, `.revoked`, `.needs_reauth`; `invoice.*` (sự kiện nháp, phát hành, cơ quan thuế và điều chỉnh) | [Tham chiếu sự kiện webhook](https://hatang.dev/vi/docs/reference/webhooks.md) mô tả từng sự kiện và `data` của nó. > **Note: Môi trường Thanh toán** > > Một endpoint nhận sự kiện Thanh toán của cả môi trường development lẫn production. Mọi đối tượng Thanh toán có trường `environment`: kiểm tra trước khi giao hàng. Thanh toán còn có thể gọi `ipn_url` riêng của từng thanh toán, ký theo cách khác; xem [Xác minh IPN](https://hatang.dev/vi/docs/guides/payments/verify-ipn.md). ## 3. Xác minh chữ ký Mỗi lượt gửi là một request `POST` với body JSON và các header sau: ```http POST /webhooks/hatang HTTP/1.1 content-type: application/json user-agent: hatang-webhooks/1.0 svix-id: msg_01a0b90429d87cf89e37fc2f66040ec5 svix-timestamp: 1761212055 svix-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE= webhook-id: msg_01a0b90429d87cf89e37fc2f66040ec5 webhook-timestamp: 1761212055 webhook-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE= ``` | Header | Ý nghĩa | |---|---| | `svix-id` | Id duy nhất của sự kiện. Giữ nguyên qua các lần thử lại và gửi lại. | | `svix-timestamp` | Thời điểm ký lượt gửi này, tính bằng giây Unix | | `svix-signature` | Một hoặc nhiều chữ ký cách nhau bằng dấu cách, mỗi chữ ký dạng `v1,` | | `webhook-id`, `webhook-timestamp`, `webhook-signature` | Cùng giá trị, dưới tên của Standard Webhooks | Để xác minh một lượt gửi: 1. Lấy **body gốc** đúng như nhận được, trước khi parse JSON. 2. Ghép nội dung cần ký `..`. 3. Bỏ tiền tố `whsec_` của secret và giải mã base64 phần còn lại: đó là khoá HMAC. 4. Tính HMAC-SHA256 của nội dung cần ký và mã hoá base64. 5. Chấp nhận nếu kết quả bằng một chữ ký `v1` bất kỳ trong `svix-signature`, so sánh bằng hàm thời gian hằng. 6. Từ chối nếu `svix-timestamp` lệch quá **300 giây** so với đồng hồ của bạn, để chặn việc phát lại request cũ bị bắt được. **Node.js** `@hatang/sdk` làm đủ sáu bước. `constructWebhookEvent` trả về sự kiện đã parse hoặc throw `HatangSignatureError`; `verifyWebhook` trả `true` hoặc `false`. Cả hai nhận header `svix-*` hoặc `webhook-*`, độ lệch mặc định 300 giây. ```js import express from "express"; import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk"; const app = express(); // express.raw giữ body dạng Buffer. Đừng dùng express.json() cho route này. app.post("/webhooks/hatang", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET); } catch (err) { if (err instanceof HatangSignatureError) return res.status(400).send("Invalid signature"); throw err; } await queue.add("hatang-event", { id: req.get("svix-id"), event }); // xử lý ở nền res.sendStatus(200); }); ``` Trong route handler của Next.js hay bất kỳ runtime nào có Fetch API: ```js import { verifyWebhook } from "@hatang/sdk"; export async function POST(request) { const raw = await request.text(); if (!(await verifyWebhook(raw, request.headers, process.env.HATANG_WEBHOOK_SECRET))) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(raw); // ... return new Response(null, { status: 200 }); } ``` Gói [`svix`](https://www.npmjs.com/package/svix) cũng dùng được: `new Webhook(secret).verify(rawBody, headers)`. **Python** ```python import os from flask import Flask, request, abort from hatang import HatangSignatureError, construct_webhook_event SECRET = os.environ["HATANG_WEBHOOK_SECRET"] # whsec_... app = Flask(__name__) @app.post("/webhooks/hatang") def hatang_webhook(): try: # byte gốc, trước khi parse; kiểm tra chữ ký và khung thời gian 5 phút event = construct_webhook_event(request.get_data(), request.headers, SECRET) except HatangSignatureError: abort(400) enqueue(request.headers["svix-id"], event) # xử lý ở nền return "", 200 ``` **PHP** ```php $tolerance) { return false; } $key = base64_decode(str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret); $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true)); foreach (explode(' ', $signatures) as $part) { [$version, $sig] = array_pad(explode(',', $part, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $sig)) { return true; } } return false; } $raw = file_get_contents('php://input'); if (!hatang_verify_webhook($raw, getallheaders(), getenv('HATANG_WEBHOOK_SECRET'))) { http_response_code(400); exit; } $event = json_decode($raw, true); // Đưa $event vào hàng đợi rồi trả lời ngay. http_response_code(200); ``` **Go** ```go package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "io" "net/http" "os" "strconv" "strings" "time" ) func header(h http.Header, name string) string { if v := h.Get("svix-" + name); v != "" { return v } return h.Get("webhook-" + name) } func VerifyWebhook(body []byte, h http.Header, secret string) bool { id, ts, sigs := header(h, "id"), header(h, "timestamp"), header(h, "signature") if id == "" || ts == "" || sigs == "" { return false } sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if age := time.Since(time.Unix(sec, 0)); age > 300*time.Second || age < -300*time.Second { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, part := range strings.Split(sigs, " ") { version, sig, ok := strings.Cut(part, ",") if ok && version == "v1" && hmac.Equal([]byte(sig), []byte(expected)) { return true } } return false } func Handler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil || !VerifyWebhook(body, r.Header, os.Getenv("HATANG_WEBHOOK_SECRET")) { http.Error(w, "invalid signature", http.StatusBadRequest) return } // json.Unmarshal(body, &event), đưa vào hàng đợi, rồi trả lời. w.WriteHeader(http.StatusOK) } ``` > **Caution: Xác minh trên body gốc** > > Luôn xác minh trên đúng các byte nhận được. Parse JSON rồi serialize lại sẽ đổi khoảng trắng hoặc thứ tự khoá và làm sai chữ ký. Với framework tự parse JSON, hãy tắt tính năng đó cho route webhook. ## 4. Trả lời nhanh Trả về một status `2xx` bất kỳ trong vòng **15 giây**. Mọi trường hợp khác đều tính là lỗi: status khác, quá thời gian, lỗi kết nối, hoặc chuyển hướng (Hạ Tầng không đi theo redirect). 4 KB đầu của body bạn trả về được lưu cùng lượt gửi, tiện khi gỡ lỗi. Làm phần việc chính ở tác vụ nền: xác minh, lưu hoặc đưa sự kiện vào hàng đợi, trả `200`, rồi mới xử lý. ## Thử lại Khi một lượt gửi thất bại, Hạ Tầng thử lại theo lịch sau, tính từ lần thử trước: | Lần thử | Chờ | |---|---| | 1 | Ngay lập tức | | 2 | 5 giây | | 3 | 5 phút | | 4 | 30 phút | | 5 | 2 giờ | | 6 | 5 giờ | | 7 | 10 giờ | | 8 | 10 giờ | Sau lần thử thứ tám thất bại, khoảng 27,5 giờ sau sự kiện, lượt gửi bị đánh dấu là đã hết lượt và không thử lại nữa. Bạn vẫn có thể [gửi lại](#replay-a-delivery). Các lượt gửi đang chờ của endpoint bị tắt hoặc bị xoá sẽ bị bỏ. ## Gửi lại một lượt {#replay-a-delivery} Trang của endpoint trong dashboard liệt kê các **Lượt gửi** gần đây kèm sự kiện, số lần thử, phản hồi của endpoint và thời điểm **Thử lại lúc**. Bấm **Gửi lại** để gửi một lượt lần nữa, ví dụ sau khi sửa lỗi trong handler. Bằng API: ```bash # 50 lượt gửi gần nhất của một endpoint curl https://api.hatang.dev/webhooks/wh_123.../deliveries \ -H "Authorization: Bearer $HATANG_API_KEY" # Gửi lại một lượt curl -X POST https://api.hatang.dev/webhooks/deliveries/whd_123.../replay \ -H "Authorization: Bearer $HATANG_API_KEY" ``` Lượt gửi lại đi theo cùng lịch thử lại và mang **cùng `svix-id`** với bản gốc, nên handler lọc trùng theo id này sẽ không xử lý sự kiện hai lần. ## Xoay secret ký Xoay secret khi nó có thể đã lộ, hoặc theo lịch định kỳ. Bấm **Đổi secret** trên trang của endpoint, hoặc gọi `POST /webhooks/{id}/rotate`, trả về `{ "id": "wh_123...", "secret": "whsec_..." }`. Trong **24 giờ** sau khi xoay, mọi lượt gửi được ký bằng cả secret mới lẫn secret cũ: `svix-signature` chứa hai chữ ký `v1`. Code hiện tại vẫn chạy trong lúc bạn triển khai secret mới, và đoạn mã xác minh ở trên chấp nhận lượt gửi nếu một chữ ký bất kỳ khớp. ## Thử trên máy {#test-locally} Hạ Tầng chỉ gửi tới địa chỉ HTTPS công khai, nên hãy đưa server trên máy ra Internet qua một tunnel như [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) hoặc [ngrok](https://ngrok.com/), rồi đăng ký URL của tunnel thành một endpoint riêng: ```bash cloudflared tunnel --url http://localhost:3000 # hoặc ngrok http 3000 ``` Sau đó tạo một sự kiện thật, như gửi email cho chính bạn, hoặc gửi lại một lượt cũ từ dashboard. Dùng secret ký của chính endpoint mới trên máy, và xoá endpoint khi xong việc. ## Viết handler idempotent {#make-your-handler-idempotent} Coi mọi lượt gửi là "ít nhất một lần": - **Lọc trùng theo `svix-id`.** Cùng một sự kiện có thể tới nhiều lần, sau khi phía bạn quá thời gian, khi thử lại hoặc khi gửi lại. Lưu các id đã xử lý và bỏ qua lần lặp. - **Đừng dựa vào thứ tự.** Lần thử lại có thể mang sự kiện cũ tới sau sự kiện mới. So sánh `created_at`, hoặc lấy trạng thái hiện tại của đối tượng qua API trước khi xử lý. - **Trả `2xx` cho cả sự kiện bạn bỏ qua**, để chúng không bị thử lại suốt một ngày. - **Kiểm tra môi trường** của sự kiện Thanh toán, để thanh toán development không bao giờ kích hoạt một đơn hàng thật. ## Bước tiếp theo - [Tham chiếu sự kiện webhook](https://hatang.dev/vi/docs/reference/webhooks.md) - [Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md) - [Bảo mật](https://hatang.dev/vi/docs/guides/getting-started/security.md) - [Webhook Thanh toán](https://hatang.dev/vi/docs/guides/payments/webhooks.md) Source: https://hatang.dev/vi/docs/guides/getting-started/webhooks --- # Plugin WordPress Hạ Tầng Chat Plugin Hạ Tầng Chat thêm widget chat Hạ Tầng vào mọi trang của website WordPress, để đội của bạn trả lời khách trên website trong cùng hộp thư với Zalo, Messenger, Instagram, TikTok, WhatsApp, Telegram và email. Plugin nạp đúng [widget web](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) qua `wp_enqueue_script` và thêm cài đặt vị trí, ngôn ngữ và người dùng đã đăng nhập. > **Info: Phiên bản** > > Plugin **0.2.1** · WordPress 6.0+ · PHP 7.4+ · GPLv2 trở lên · [wordpress.org](https://wordpress.org/plugins/hatang-chat/) · [Changelog](#changelog) ## Trước khi bắt đầu - Một tài khoản Hạ Tầng có kênh website. Bạn cần **Site ID** (`site_...`) của nó: trong dashboard, mở **Hỗ trợ → Kênh**, chọn website và chép id trong đoạn mã cài đặt. - Một tài khoản WordPress quản lý được plugin và cài đặt (quản trị viên). ## Cài plugin {#install} 1. Trong WordPress, vào **Plugins → Add New**, tìm "Hạ Tầng Chat", cài rồi kích hoạt. Bạn cũng có thể tải tệp zip của plugin lên qua **Plugins → Add New → Upload Plugin**. 2. Vào **Settings → Hạ Tầng Chat**, dán **Site ID** rồi bấm **Save Changes**. 3. Mở một trang bất kỳ của website. Bong bóng chat xuất hiện, và website trong dashboard Hạ Tầng hiện trạng thái đã cài. Widget chỉ tải ở phía công khai của website, không bao giờ trong wp-admin. Khi Site ID còn trống, plugin không tải gì. ## Cài đặt {#settings} Mọi cài đặt nằm ở **Settings → Hạ Tầng Chat** và được lưu trong một option là `hatang_chat`. Bảng dưới dùng nhãn tiếng Việt mà trang cài đặt hiện khi ngôn ngữ quản trị là tiếng Việt. | Cài đặt | Giá trị | Mặc định | |---|---|---| | Site ID | Id của website trong dashboard Hạ Tầng | trống (tắt widget) | | Máy chủ widget | Nơi tải `widget.js` | `https://chat.hatang.dev` | | Vị trí | Góc dưới bên phải, Góc dưới bên trái | Góc dưới bên phải | | Ngôn ngữ | **Theo ngôn ngữ WordPress (từng trang)**, **Theo ngôn ngữ trình duyệt của khách**, **Tiếng Việt**, **English** | Theo ngôn ngữ WordPress (website cài từ trước 0.2.0 giữ ngôn ngữ trình duyệt) | | Người dùng đã đăng nhập | "Gửi tên và email của người dùng vào hộp thư" | Bật | - **Theo ngôn ngữ WordPress** đọc ngôn ngữ của trang đang được phục vụ (`determine_locale()`), nên chạy đúng từng trang với Polylang và WPML. Locale bắt đầu bằng `vi` cho tiếng Việt; mọi locale khác cho tiếng Anh. - **Theo ngôn ngữ trình duyệt** để widget tự chọn: theo trình duyệt của khách, hoặc ngôn ngữ mặc định đặt cho site trong dashboard Hạ Tầng. - Mọi thứ khác (màu, giờ làm việc, lời chào, form offline) lấy từ cài đặt website trong dashboard Hạ Tầng. Với tuỳ chọn mà plugin không có, như `offset` hay `hideLauncher`, tự thêm `window.HATANG_CONFIG` trước script widget, hoặc dùng [JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) trong theme. ## Người dùng đã đăng nhập {#identify} Khi bật **Người dùng đã đăng nhập**, plugin gọi `identify` cho khách đã đăng nhập với tên hiển thị và email WordPress. Hộp thư coi đó là thông tin khách tự khai, không phải danh tính đã xác minh. Để hộp thư đánh dấu người dùng là **đã xác minh** và gộp hội thoại với các phiên trước của cùng người (kể cả trong ứng dụng di động), trả về chữ ký từ filter `hatang_chat_identity_hash`. Chữ ký là HMAC-SHA256 của user id WordPress với identity secret của site. Lấy secret trong dashboard Hạ Tầng: **Hỗ trợ → Kênh** → chọn website → **Mã bí mật định danh** → **Tạo mới & hiển thị**. Giữ nó trong `wp-config.php`, đừng để trong tệp theme mà người khác đọc được. ```php title="wp-config.php" define( 'HATANG_IDENTITY_SECRET', 'identity-secret-cua-ban' ); ``` ```php title="functions.php" add_filter( 'hatang_chat_identity_hash', function ( $hash, $user ) { return hash_hmac( 'sha256', (string) $user->ID, HATANG_IDENTITY_SECRET ); }, 10, 2 ); ``` Khi filter trả về giá trị, plugin thêm `userId` (user id WordPress dạng chuỗi) và `hash` vào lệnh `identify`. Ý nghĩa của chữ ký, cách gộp phiên và lưu ý riêng tư ở [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ## Filter cho lập trình viên {#filters} | Filter | Tham số | Giá trị trả về | |---|---|---| | `hatang_chat_locale` | `$locale` (`"vi"`, `"en"` hoặc `null`), `$setting` (`"wp"`, `""`, `"vi"` hoặc `"en"`) | `"vi"`, `"en"`, hoặc `null` để widget tự chọn | | `hatang_chat_identity_hash` | `$hash` (`null`), `$user` (`WP_User`) | Chuỗi hex HMAC-SHA256, hoặc `null` cho danh tính chưa xác minh | Ép tiếng Anh trên một trang, bất kể cài đặt: ```php title="functions.php" add_filter( 'hatang_chat_locale', function ( $locale, $setting ) { return is_page( 'en' ) ? 'en' : $locale; }, 10, 2 ); ``` ## Plugin gửi gì tới Hạ Tầng {#data} Script widget được tải từ `chat.hatang.dev` trên các trang công khai của website. Khi khách mở một trang, nó gửi URL và tiêu đề trang, ngôn ngữ trình duyệt của khách và, nếu bạn bật, tên và email của người dùng đã đăng nhập. Xem [Điều khoản](https://hatang.dev/legal/terms) và [Chính sách quyền riêng tư](https://hatang.dev/legal/privacy). ## Bản dịch trang quản trị {#translation} Trang cài đặt có bản tiếng Việt (`languages/hatang-chat-vi.po`). WordPress tự chọn bản dịch theo ngôn ngữ quản trị. ## Gỡ bỏ {#uninstall} Huỷ kích hoạt rồi xoá plugin trong **Plugins**. Plugin không có bước dọn dẹp khi gỡ, nên cài đặt vẫn nằm trong option `hatang_chat`; xoá bằng `wp option delete hatang_chat` nếu muốn cơ sở dữ liệu sạch. Hội thoại vẫn nằm trong tài khoản Hạ Tầng cho đến khi bạn xoá ở đó. ## Changelog {#changelog} - **0.2.3**: link tác giả trỏ về hatang.dev; readme dẫn tới hướng dẫn cài đặt và hướng dẫn cho lập trình viên này, và trả lời những hội thoại nào dùng chung hộp thư. - **0.2.2**: plugin đổi tên thành Ha Tang Chat (Hạ Tầng trong tiếng Việt); trang chủ plugin là bài trợ giúp hatang.dev/vi/help/integrations/wordpress; trang cài đặt và readme gọi đúng **Hỗ trợ khách hàng → Kênh**. - **0.2.1**: trang cài đặt và readme trỏ tới **Hỗ trợ → Kênh**, nơi dashboard Hạ Tầng giờ liệt kê các website; readme liệt kê mọi kênh dùng chung hộp thư. - **0.2.0**: ngôn ngữ có thể theo ngôn ngữ WordPress của từng trang (chạy với Polylang và WPML); filter mới `hatang_chat_locale`; trang cài đặt có bản tiếng Việt. - **0.1.1**: nạp widget qua `wp_enqueue_script`. - **0.1.0**: bản đầu tiên: đoạn mã widget, cài đặt vị trí và ngôn ngữ, nhận diện người dùng đã đăng nhập. ## Bước tiếp theo - [Widget web và JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md): lệnh, sự kiện và `HATANG_CONFIG`. - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md): danh tính có chữ ký và gộp phiên. - [Cài widget](https://hatang.dev/vi/docs/guides/support/web-widget/install.md): nền tảng khác, hoặc WordPress không dùng plugin. Source: https://hatang.dev/vi/docs/guides/integrations/wordpress --- # Liên kết ngân hàng Kết nối ngân hàng (bank connection) là liên kết giữa Hạ Tầng và tài khoản ngân hàng khách hàng chuyển tiền vào. Hạ Tầng chỉ đọc giao dịch tiền vào của tài khoản, không bao giờ chuyển tiền đi. Trang này hướng dẫn liên kết từng ngân hàng, cấu hình khớp mã, hủy liên kết, và nhập sao kê khi ngân hàng bỏ sót giao dịch. ## Trước khi bắt đầu - API key có `payments:write`, hoặc quyền vào **Cài đặt thanh toán → Tài khoản ngân hàng** trên dashboard. - Với ngân hàng thật: số tài khoản, tên chủ tài khoản đúng như ngân hàng ghi nhận, và (với MB Bank) CCCD hoặc mã số thuế dùng khi mở tài khoản. - Chọn đúng môi trường. Ngân hàng thật chỉ có ở `production`; ngân hàng giả lập chỉ có ở `development`. Chọn sai sẽ nhận `403 environment_mismatch`. ## Endpoint | Method | Endpoint | Chức năng | |---|---|---| | `GET` | `/payments/bank-connections` | Danh sách tài khoản đã liên kết (bỏ qua tài khoản đã hủy) | | `POST` | `/payments/bank-connections` | Bắt đầu liên kết | | `GET` | `/payments/bank-connections/{id}` | Chi tiết | | `PATCH` | `/payments/bank-connections/{id}` | Đổi nhãn hoặc cấu hình khớp mã | | `POST` | `/payments/bank-connections/{id}/confirm` | Xác nhận liên kết bằng mã của ngân hàng | | `POST` | `/payments/bank-connections/{id}/unlink` | Bắt đầu hủy liên kết | | `POST` | `/payments/bank-connections/{id}/unlink/confirm` | Xác nhận hủy liên kết bằng mã của ngân hàng | | `POST` | `/payments/statements` | Nhập sao kê (CSV) | | `GET` | `/payments/statements/{id}` | Tiến độ nhập sao kê | Endpoint đọc cần `payments:read`, endpoint thay đổi cần `payments:write`. ## Cách liên kết từng ngân hàng | Ngân hàng | `provider` | `link_mode` | Bạn cần làm | |---|---|---|---| | MB Bank | `mbbank` | `otp` | Gửi thông tin tài khoản; MB nhắn mã tới số điện thoại đăng ký của tài khoản; xác nhận bằng mã đó | | BIDV | `bidv` | `bank_registered` | Đăng ký dịch vụ thông báo biến động số dư của BIDV cho tài khoản tại BIDV, rồi gửi số tài khoản. Kết nối kích hoạt khi BIDV gửi tin đầu tiên cho tài khoản | | ACB | `acb` | `redirect` | Gửi số tài khoản, rồi mở `challenge.authorize_url` để chủ tài khoản đồng ý trên trang của ACB. Họ được đưa về dashboard và tài khoản chuyển sang hoạt động | | Pay2S | `pay2s` | `bank_registered` | Liên kết tài khoản trên Pay2S, tạo Hook trên đó trỏ tới `webhook_url`, rồi gửi số tài khoản, `bank_code` và token của Hook. Kết nối kích hoạt khi có giao dịch đầu tiên | | Ngân hàng giả lập | `simulator` | `otp` | Xác nhận bằng mã `000000` | ## Liên kết tài khoản MB Bank {#link-an-account} ```mermaid sequenceDiagram participant S as Bạn participant H as Hạ Tầng participant B as Ngân hàng S->>H: POST /payments/bank-connections H->>B: Đối chiếu tài khoản với giấy tờ chủ tài khoản B-->>S: SMS mã xác thực tới chủ tài khoản H-->>S: status pending_otp + challenge S->>H: POST /payments/bank-connections/{id}/confirm H->>B: Xác nhận H-->>S: status active + webhook bank_connection.activated ``` ### 1. Bắt đầu liên kết **cURL** ```bash curl https://api.hatang.dev/payments/bank-connections \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "mbbank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "account_type": "personal", "holder_id": "001099012345", "label": "Tài khoản bán hàng" }' ``` **Node.js** ```ts const { data: conn, error } = await hatang.payments.bankConnections.create({ provider: "mbbank", account_number: "910452658", account_name: "NGUYEN VAN A", account_type: "personal", holder_id: "001099012345", label: "Tài khoản bán hàng", }); if (error) throw new Error(`${error.name}: ${error.message}`); ``` **Python** ```python conn = requests.post("https://api.hatang.dev/payments/bank-connections", headers=HEADERS, json={ "provider": "mbbank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "account_type": "personal", "holder_id": "001099012345", "label": "Tài khoản bán hàng", }, timeout=30).json() ``` **PHP** ```php $conn = json_decode($client->post('/payments/bank-connections', ['json' => [ 'provider' => 'mbbank', 'account_number' => '910452658', 'account_name' => 'NGUYEN VAN A', 'account_type' => 'personal', 'holder_id' => '001099012345', 'label' => 'Tài khoản bán hàng', ]])->getBody(), true); ``` | Trường | Mô tả | |---|---| | `provider` | **Bắt buộc.** `mbbank`, `bidv`, `acb` hoặc `pay2s` ở production; `simulator` ở development | | `account_number` | Bắt buộc với ngân hàng thật. Chữ số (Pay2S cho phép cả chữ in hoa), 4 đến 30 ký tự. Ngân hàng giả lập tự sinh nếu bạn bỏ trống | | `account_name` | Tên chủ tài khoản đúng như ngân hàng ghi nhận. Bắt buộc với MB Bank | | `account_type` | `personal` (mặc định) hoặc `business` | | `holder_id` | CCCD (cá nhân) hoặc mã số thuế (doanh nghiệp) dùng khi mở tài khoản. Bắt buộc với MB Bank; được lưu mã hóa | | `bank_code` | Với nhà cung cấp phục vụ nhiều ngân hàng (Pay2S, ngân hàng giả lập): tài khoản ở ngân hàng nào, vd `BIDV`, `MB`, `VCB`. Quyết định ngân hàng ghi trên VietQR. Các nhà cung cấp khác sẽ từ chối trường này | | `webhook_token` | Chỉ dùng và bắt buộc với Pay2S: token của Hook bạn tạo trên Pay2S | | `label` | Tên bạn đặt cho tài khoản, tối đa 100 ký tự | | `config` | Cấu hình khớp mã; xem [Cấu hình khớp mã](#config) | Phản hồi là `201` với kết nối ở `pending_otp` kèm `challenge`: ```json { "object": "bank_connection", "id": "bcn_01a0b90428fd7c499145a12c133abe41", "environment": "production", "provider": "mbbank", "bank": { "bin": "970422", "name": "MB Bank" }, "account_number": "910452658", "account_name": "NGUYEN VAN A", "account_type": "personal", "status": "pending_otp", "label": "Tài khoản bán hàng", "config": { "code_prefix": "HT", "payment_code_pattern": null, "transaction_events": { "direction": "in", "require_code": false, "min_amount": 0 } }, "capabilities": { "pushNotifications": true, "history": false, "accountInfo": false, "virtualAccounts": false, "refunds": false }, "link_mode": "otp", "webhook_url": null, "consent": null, "last_transaction_at": null, "activated_at": null, "revoked_at": null, "created_at": "2026-09-19T09:29:51.402Z", "challenge": { "id": "lch_01a0b90428e17d2c8b5f0a4e3c2d1b0a", "expires_at": "2026-09-19T09:34:51.402Z", "delivery": "sms_otp" } } ``` `challenge.delivery` cho biết bước tiếp theo: `sms_otp` (mã đang được gửi), `redirect` (mở `challenge.authorize_url`) hoặc `bank_registration` (chờ tin đầu tiên của ngân hàng). ### 2. Xác nhận bằng mã **cURL** ```bash curl https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "otp": "123456" }' ``` **Node.js** ```ts const { data, error } = await hatang.payments.bankConnections.confirm(conn.id, "123456"); ``` **Python** ```python requests.post(f"https://api.hatang.dev/payments/bank-connections/{conn['id']}/confirm", headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status() ``` **PHP** ```php $client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '123456']]); ``` `otp` gồm 4 đến 8 chữ số. Khi thành công, kết nối chuyển sang `active` và webhook `bank_connection.activated` được gửi. Mỗi lần nhập sai được tính một lượt; sau 5 lượt sai, mã hết hiệu lực và bạn phải bắt đầu lại. ## Liên kết tài khoản BIDV, ACB hoặc Pay2S - **BIDV.** Đăng ký dịch vụ thông báo biến động số dư cho tài khoản tại BIDV, rồi `POST` với `provider: "bidv"` và `account_number`. Kết nối ở trạng thái `pending_otp` (dashboard hiển thị "Chờ ngân hàng") cho tới khi BIDV gửi tin đầu tiên cho tài khoản, lúc đó kết nối được kích hoạt. Gọi `confirm` sẽ nhận `409 invalid_state`. - **ACB.** `POST` với `provider: "acb"` và `account_number`, rồi đưa chủ tài khoản tới `challenge.authorize_url` trong vài phút. Sau khi họ đồng ý trên trang của ACB, ACB đưa họ về **Cài đặt thanh toán → Tài khoản ngân hàng** và tài khoản chuyển sang `active`. Sự đồng ý này cho phép đọc lịch sử tài khoản, có hiệu lực tối đa 180 ngày và hiển thị ở trường `consent` của kết nối. ACB không đẩy giao dịch; Hạ Tầng đọc lịch sử mỗi 10 phút. - **Pay2S.** Liên kết tài khoản trên Pay2S trước. Lấy `webhook_url` từ một kết nối Pay2S bất kỳ (hoặc từ form trên dashboard), tạo Hook trên Pay2S trỏ tới URL đó, và sao chép token của Hook. Sau đó `POST` với `provider: "pay2s"`, `bank_code`, `account_number` (đúng như Pay2S ghi) và `webhook_token`. Kết nối kích hoạt khi Pay2S đẩy giao dịch đầu tiên của tài khoản. Pay2S đọc được tài khoản tại `VCB`, `ICB`, `TCB`, `BIDV`, `ACB`, `MB` và `TPB`. ## Trạng thái | `status` | Ý nghĩa | |---|---| | `pending_otp` | Đang chờ: chờ mã, chờ chủ tài khoản đồng ý, hoặc chờ tin đầu tiên của ngân hàng | | `active` | Đang nhận giao dịch | | `needs_reauth` | Ngân hàng yêu cầu liên kết lại | | `revoked` | Đã hủy liên kết | | `error` | Ngân hàng báo lỗi với liên kết | ## Cấu hình khớp mã (`config`) {#config} Đổi bằng `PATCH`, hoặc trên dashboard tại **Cài đặt thanh toán → Tài khoản ngân hàng → Khớp lệnh và thông báo**. Chỉ những trường bạn gửi mới thay đổi. **cURL** ```bash curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "Cửa hàng chính", "config": { "code_prefix": "SHOP", "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" }, "transaction_events": { "direction": "in", "require_code": true, "min_amount": 10000 } } }' ``` **Node.js** ```ts await hatang.payments.bankConnections.update("bcn_123", { label: "Cửa hàng chính", config: { code_prefix: "SHOP", payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" }, transaction_events: { direction: "in", require_code: true, min_amount: 10_000 }, }, }); ``` **Python** ```python requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={ "label": "Cửa hàng chính", "config": { "code_prefix": "SHOP", "payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"}, "transaction_events": {"direction": "in", "require_code": True, "min_amount": 10000}, }, }, timeout=10).raise_for_status() ``` **PHP** ```php $client->patch('/payments/bank-connections/bcn_123', ['json' => [ 'label' => 'Cửa hàng chính', 'config' => [ 'code_prefix' => 'SHOP', 'payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits'], 'transaction_events' => ['direction' => 'in', 'require_code' => true, 'min_amount' => 10000], ], ]]); ``` | Trường | Mặc định | Mô tả | |---|---|---| | `code_prefix` | `HT` | Tiền tố của mã thanh toán tự sinh, 2 đến 5 chữ in hoa (luồng đơn hàng) | | `payment_code_pattern` | `null` | Định dạng mã đơn của bạn, được tìm trong mọi giao dịch (luồng chỉ nhận giao dịch). `null` để tắt | | `payment_code_pattern.prefix` | – | 1 đến 10 chữ in hoa hoặc chữ số | | `payment_code_pattern.min_length` / `max_length` | `3` / `10` | Độ dài phần sau tiền tố, từ 1 đến 30 | | `payment_code_pattern.charset` | `digits` | `digits` hoặc `alnum` (chữ và số) | | `transaction_events.direction` | `in` | `in`, hoặc `all` để báo cả tiền ra | | `transaction_events.require_code` | `false` | Chỉ báo giao dịch tìm thấy mã hoặc khớp với một payment | | `transaction_events.min_amount` | `0` | Không báo giao dịch dưới số tiền này (VND) | `transaction_events` chỉ lọc `transaction.received`. Payment luôn được khớp và báo. ## Hủy liên kết **cURL** ```bash curl -X POST https://api.hatang.dev/payments/bank-connections/bcn_123/unlink \ -H "Authorization: Bearer $HATANG_API_KEY" # Chỉ khi phản hồi có challenge: curl https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "otp": "123456" }' ``` **Node.js** ```ts const { data } = await hatang.payments.bankConnections.unlink("bcn_123"); if (data?.status !== "revoked") await hatang.payments.bankConnections.confirmUnlink("bcn_123", "123456"); ``` **Python** ```python conn = requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink", headers=HEADERS, timeout=30).json() if conn.get("challenge"): requests.post("https://api.hatang.dev/payments/bank-connections/bcn_123/unlink/confirm", headers=HEADERS, json={"otp": "123456"}, timeout=30).raise_for_status() ``` **PHP** ```php $conn = json_decode($client->post('/payments/bank-connections/bcn_123/unlink')->getBody(), true); if (!empty($conn['challenge'])) { $client->post('/payments/bank-connections/bcn_123/unlink/confirm', ['json' => ['otp' => '123456']]); } ``` MB Bank xác nhận hủy liên kết bằng mã, giống khi liên kết. Các ngân hàng khác, và tài khoản đang chờ liên kết, được hủy ngay: phản hồi đã ở `revoked` và `challenge` là `null`. Hủy liên kết tài khoản ACB cũng thu hồi sự đồng ý tại ACB. Để BIDV hoặc Pay2S ngừng đẩy giao dịch, hãy tắt dịch vụ ở phía họ. Hủy liên kết sẽ gửi `bank_connection.revoked`. ## Nhập sao kê {#statement-import} Nhập sao kê giúp khôi phục giao dịch mà ngân hàng không đẩy về, với mọi ngân hàng. Tải sao kê từ ngân hàng, chuyển sang CSV theo mẫu dưới đây, rồi tải lên trên dashboard (**Cài đặt thanh toán → Tài khoản ngân hàng → Nhập sao kê ngân hàng**) hoặc qua API. ```csv reference,booked_at,direction,amount,description,counterparty_account,counterparty_name FT1789810452930810,2026-09-19T09:34:12Z,in,99000,DH20250919 chuyen tien,0987654321,NGUYEN VAN KHACH ``` **cURL** ```bash curl https://api.hatang.dev/payments/statements \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg csv "$(cat sao-ke.csv)" \ '{ connection_id: "bcn_123", filename: "sao-ke-thang-9.csv", content: $csv }')" ``` **Node.js** ```ts import { readFile } from "node:fs/promises"; const { data: job } = await hatang.payments.statements.import({ connection_id: "bcn_123", filename: "sao-ke-thang-9.csv", content: await readFile("sao-ke.csv", "utf8"), }); const { data: progress } = await hatang.payments.statements.get(job.id); ``` **Python** ```python with open("sao-ke.csv", encoding="utf-8") as f: job = requests.post("https://api.hatang.dev/payments/statements", headers=HEADERS, json={ "connection_id": "bcn_123", "filename": "sao-ke-thang-9.csv", "content": f.read(), }, timeout=30).json() ``` **PHP** ```php $job = json_decode($client->post('/payments/statements', ['json' => [ 'connection_id' => 'bcn_123', 'filename' => 'sao-ke-thang-9.csv', 'content' => file_get_contents('sao-ke.csv'), ]])->getBody(), true); ``` Lời gọi trả về `202` và các dòng được xử lý ở nền. Theo dõi qua `GET /payments/statements/{id}`: ```json { "object": "statement_import", "id": "stm_01a0b9042a1c7f0e9d8c7b6a5f4e3d2c", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "filename": "sao-ke-thang-9.csv", "status": "done", "rows": 1, "processed_rows": 1, "error": null, "created_at": "2026-09-20T02:10:00.000Z", "completed_at": "2026-09-20T02:10:01.250Z" } ``` | Quy tắc | Chi tiết | |---|---| | Cột | `reference`, `booked_at`, `direction`, `amount`, `description` là bắt buộc; `counterparty_account` và `counterparty_name` không bắt buộc. Dòng tiêu đề ghi tên cột, thứ tự tùy ý | | `direction` | `in`, `out`, `c`, `d`, `credit` hoặc `debit` | | `amount` | Số nguyên dương, đơn vị VND; chấp nhận dấu phân cách hàng nghìn (`99.000`, `99,000`) | | `booked_at` | Ngày giờ theo ISO 8601. Giá trị không đọc được sẽ lấy thời điểm nhập | | Dung lượng | `content` tối đa 5.000.000 ký tự | | Trùng lặp | Giao dịch Hạ Tầng đã có (cùng mã tham chiếu và số tiền trên tài khoản) được bỏ qua | | `status` | `queued`, `processing`, `done` hoặc `failed` (kèm `error`) | Giao dịch nhập từ sao kê đi qua cùng quy trình khớp mã như giao dịch được đẩy về: giao dịch có mã thanh toán sẽ cập nhật payment đó và gửi các sự kiện như bình thường. Thiếu cột hoặc dòng sai sẽ bị từ chối ngay với `422 validation_error`, ví dụ `Statement is missing the "amount" column` hoặc `Statement row 3 is invalid`. Khi đó không có gì được lưu. ## Lỗi | Lỗi | Khi nào | |---|---| | `403 environment_mismatch` | Ngân hàng thật ở development, hoặc ngân hàng giả lập ở production | | `422 missing_required_field` | MB Bank thiếu `account_number`, `account_name` hoặc `holder_id`; BIDV hoặc Pay2S thiếu `account_number`; Pay2S thiếu `webhook_token` | | `422 validation_error` | `provider` không tồn tại, số tài khoản sai định dạng, `bank_code` với ngân hàng không nhận trường này, hoặc `bank_code` mà Pay2S không đọc được | | `422 bank_link_failed` | Ngân hàng từ chối: không có tài khoản, tên hoặc giấy tờ không khớp, tài khoản đã liên kết với doanh nghiệp khác, hoặc chủ tài khoản không đồng ý | | `422 invalid_otp` | Sai mã | | `410 otp_expired` | Mã hết hạn; bắt đầu liên kết lại | | `409 invalid_state` | Tài khoản đã liên kết, đang được liên kết, đã hủy, hoặc không xác nhận bằng mã | | `503 provider_unavailable` | Ngân hàng không phản hồi; thử lại sau | ## Bước tiếp theo - [Nhận giao dịch không cần tạo đơn](https://hatang.dev/vi/docs/guides/payments/transactions.md) với `payment_code_pattern`. - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md) vào tài khoản đã liên kết. - [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md): các sự kiện `bank_connection.*`. Source: https://hatang.dev/vi/docs/guides/payments/bank-connections --- # Cách người mua thanh toán Sau khi bạn [tạo payment](https://hatang.dev/vi/docs/guides/payments/create-payment.md), người mua có thể quét VietQR, dùng trang checkout có sẵn, hoặc chuyển khoản thủ công. Trang này mô tả từng cách, các endpoint công khai để tự xây trang thanh toán, và điều gì xảy ra khi người mua chuyển sai. ## Quét VietQR Mọi app ngân hàng tại Việt Nam đều quét được (NAPAS 247: tức thì, 24/7). QR điền sẵn ngân hàng, số tài khoản, số tiền và mã thanh toán; người mua chỉ cần xác nhận.
Mã VietQR mẫu có logo hatang.dev
VietQR mẫu: 910452658 · 150.000 ₫ · HTBF7RDGVQ
- `qr.image_url`: ảnh SVG, đặt thẳng vào thẻ ``. Ảnh có logo hatang.dev ở giữa và được vẽ ở mức sửa lỗi H, nên logo không ảnh hưởng việc quét. Thêm `?logo=0` để lấy mã trơn, ví dụ khi in kèm thương hiệu của bạn. - `qr.payload`: chuỗi EMVCo. Render bằng thư viện QR của bạn để tùy biến giao diện. ## Dùng trang checkout có sẵn
Trang checkout Hạ Tầng
Chuyển người mua tới `checkout_url`. Trang hiển thị QR, thông tin ngân hàng kèm nút sao chép, và đồng hồ đếm ngược tới `expires_at`. Trang cập nhật trực tiếp khi tiền về (kèm số hóa đơn khi đã có), rồi chuyển người mua về `return_url`. Không cần đăng nhập: payment id là thông tin duy nhất. Ở development, trang có thêm nút giả lập chuyển khoản. > **Note** > > Người mua quay lại `return_url` chỉ có nghĩa là họ đã rời trang checkout. Hãy xác nhận thanh toán ở server trước khi giao hàng (xem [Xác nhận trước khi giao hàng](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md#confirm-before-you-fulfil)). ## Chuyển khoản thủ công Hiển thị `bank.name`, `bank.account_number`, `bank.account_name`, số tiền và `code`, rồi nhắc người mua ghi mã vào nội dung chuyển khoản. Cách này dùng được từ mọi ngân hàng, kể cả internet banking trên máy tính. Hãy hiển thị mã thật nổi bật: thiếu mã, giao dịch không khớp được với payment. ## Tự xây trang thanh toán Các endpoint này không cần API key, nên gọi được từ trình duyệt: | Endpoint | Trả về | |---|---| | `GET /pay/{id}` | Những gì trang checkout hiển thị, và chỉ những gì người mua cần | | `GET /pay/{id}/qr.svg` | Ảnh VietQR (`?logo=0` để lấy mã trơn) | | `GET /pay/{id}/events` | Server-sent events, một sự kiện mỗi khi trạng thái đổi | `GET /pay/{id}` trả về: ```json { "object": "checkout", "id": "pay_01a0b904297873e8b3524a26fdca0ab5", "environment": "production", "status": "pending", "amount": 150000, "paid_amount": 0, "currency": "VND", "code": "HTBF7RDGVQ", "description": "Đơn hàng #1001", "merchant": { "name": "Cửa hàng mẫu" }, "bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A", "simulated": false }, "qr": { "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC", "image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg" }, "return_url": "https://shop.example.vn/orders/1001", "expires_at": "2026-09-19T10:34:12.856Z", "paid_at": null, "invoice": null } ``` `bank.simulated` là `true` với tài khoản development tự sinh. `invoice`, khi đơn có yêu cầu hóa đơn, chỉ gồm `requested`, `status`, `number`, `series`, `lookup_code` và `pdf_url`. Lắng nghe thay đổi trạng thái: ```js const es = new EventSource(`https://api.hatang.dev/pay/${paymentId}/events`); es.addEventListener("status", (e) => { const { status, paid_amount } = JSON.parse(e.data); if (status === "paid" || status === "overpaid") showThankYou(); // chỉ để hiển thị if (status === "partially_paid") showRemaining(paid_amount); }); ``` Stream gửi trạng thái hiện tại trước, sau đó một sự kiện `status` cho mỗi lần thay đổi, và một dòng keep-alive mỗi 15 giây. Stream đóng sau 15 phút; mở lại nếu người mua vẫn ở trên trang. Nếu stream bị từ chối (mỗi payment và mỗi server có giới hạn số kết nối trực tiếp), hãy chuyển sang gọi `GET /pay/{id}` vài giây một lần. | Giới hạn | Giá trị | |---|---| | Request tới `/pay/*` mỗi địa chỉ client | 600 mỗi phút (vượt quá nhận `429 rate_limit_exceeded`, kèm `Retry-After`) | | Module Thanh toán bị tắt | `403 product_disabled` | > **Danger: Không giao hàng theo trình duyệt** > > Ai cũng có thể mở một trang ghi "đã thanh toán". Hãy xác nhận ở server bằng IPN, webhook hoặc `GET /payments/{id}`. ## Khi người mua chuyển sai | Tình huống | Kết quả | |---|---| | Sai số tiền | Payment thành `partially_paid` hoặc `overpaid`. Giao dịch tiếp theo cùng mã sẽ cộng dồn | | Quên ghi mã | Giao dịch đến dưới dạng `transaction.received` với `payment_id: null`. Kiểm tra rồi [đánh dấu payment đã thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md) | | Trả sau `expires_at` | Tiền vẫn được ghi nhận và payment chuyển sang `paid` | | Trả hai lần | Payment thành `overpaid`; hoàn phần chênh lệch từ ngân hàng của bạn | | Chuyển nhầm tài khoản | Hạ Tầng chỉ thấy tài khoản đã liên kết; payment vẫn chưa thanh toán | ## Bước tiếp theo - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md) trên server. - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md): trạng thái và tình huống đặc biệt. - [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md): trang checkout hiển thị gì về hóa đơn. Source: https://hatang.dev/vi/docs/guides/payments/buyer-flow --- # Tạo thanh toán Payment là yêu cầu thu tiền cho một đơn hàng. Mỗi payment có mã chuyển khoản, VietQR và trang checkout riêng, và Hạ Tầng chuyển nó sang `paid` khi có giao dịch mang mã đó. Trang này hướng dẫn tạo payment, các trạng thái, mã thanh toán, và các lời gọi hủy hoặc ghi nhận thanh toán thủ công. ## Trước khi bắt đầu - Một [kết nối ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md) đang hoạt động ở môi trường bạn dùng. - API key có `payments:write`. - Một endpoint HTTPS cho [IPN](https://hatang.dev/vi/docs/guides/payments/payment-results.md), nếu bạn muốn nhận lời gọi cho payment này. ## 1. Tạo payment Tạo khi khách bấm thanh toán. Gửi `Idempotency-Key` (mã đơn là lựa chọn tốt) để request thử lại trả về cùng một payment thay vì tạo cái thứ hai. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Idempotency-Key: order-1001" \ -H "Content-Type: application/json" \ -d '{ "amount": 150000, "description": "Đơn hàng #1001", "expires_in": 3600, "return_url": "https://shop.example.vn/orders/1001", "ipn_url": "https://shop.example.vn/hatang/ipn", "customer": { "name": "Trần Thị B", "email": "b@example.vn" }, "metadata": { "order_id": "1001" } }' ``` **Node.js** ```ts const { data: payment, error } = await hatang.payments.create( { amount: 150_000, description: "Đơn hàng #1001", expires_in: 3600, return_url: "https://shop.example.vn/orders/1001", ipn_url: "https://shop.example.vn/hatang/ipn", customer: { name: "Trần Thị B", email: "b@example.vn" }, metadata: { order_id: "1001" }, }, { idempotencyKey: "order-1001" }, ); if (error) throw new Error(`${error.name}: ${error.message}`); ``` **Python** ```python payment = hatang.payments.create( { "amount": 150000, "description": "Đơn hàng #1001", "expires_in": 3600, "return_url": "https://shop.example.vn/orders/1001", "ipn_url": "https://shop.example.vn/hatang/ipn", "customer": {"name": "Trần Thị B", "email": "b@example.vn"}, "metadata": {"order_id": "1001"}, }, idempotency_key="order-1001", ) # lỗi thì ném hatang.HatangError, có .name và .message ``` **PHP** ```php post('/payments', [ 'headers' => ['Idempotency-Key' => 'order-1001'], 'json' => [ 'amount' => 150000, 'description' => 'Đơn hàng #1001', 'expires_in' => 3600, 'return_url' => 'https://shop.example.vn/orders/1001', 'ipn_url' => 'https://shop.example.vn/hatang/ipn', 'customer' => ['name' => 'Trần Thị B', 'email' => 'b@example.vn'], 'metadata' => ['order_id' => '1001'], ], ]); $payment = json_decode($res->getBody(), true); } catch (ClientException $e) { $err = json_decode($e->getResponse()->getBody(), true); throw new RuntimeException("{$err['name']}: {$err['message']}"); } ``` | Trường | Ý nghĩa | |---|---| | `amount` | **Bắt buộc.** VND, đã gồm VAT: số tiền người mua chuyển. Số nguyên từ 1 đến 100.000.000.000 | | `description` | Hiển thị cho người mua trên trang checkout, tối đa 200 ký tự. Đây không phải nội dung chuyển khoản | | `code` | Mã chuyển khoản của bạn thay cho mã tự sinh; xem [Mã của riêng bạn](#your-own-code) | | `connection_id` | Tài khoản ngân hàng nhận tiền. Chỉ cần khi có nhiều tài khoản đang hoạt động | | `expires_in` | Số giây tới khi hết hạn: 1 đến 2.592.000 (30 ngày). Mặc định 86.400 (24 giờ) | | `return_url` | Trang checkout chuyển người mua về đây sau khi trả | | `ipn_url` | Endpoint nhận IPN của payment này (xem [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md)). Chỉ chấp nhận `https` công khai | | `customer` | `name`, `email`, `phone`. Của bạn; trả lại nguyên văn | | `metadata` | Dữ liệu key-value của bạn. Key tối đa 40 ký tự; giá trị là chuỗi (tối đa 500 ký tự), số, boolean hoặc `null`. Trả lại nguyên văn | | `invoice` | Phát hành hóa đơn điện tử khi đã trả: [yêu cầu xuất hóa đơn](https://hatang.dev/vi/docs/guides/payments/einvoices.md#invoice-request) (người mua, dòng hàng, VAT). Xem [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md) | ### Idempotency Cùng `Idempotency-Key` với cùng body sẽ trả về payment tạo lần đầu, mã `201`. Cùng key nhưng body **khác** sẽ trả về `400 invalid_idempotent_request`. Key dài tối đa 256 ký tự và có phạm vi theo doanh nghiệp và môi trường. ### Phản hồi {#response} ```json { "object": "payment", "id": "pay_01a0b904297873e8b3524a26fdca0ab5", "environment": "production", "status": "pending", "amount": 150000, "paid_amount": 0, "currency": "VND", "code": "HTBF7RDGVQ", "description": "Đơn hàng #1001", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "bank": { "bin": "970422", "name": "MB Bank", "account_number": "910452658", "account_name": "NGUYEN VAN A" }, "qr": { "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA530370454061500005802VN62140810HTBF7RDGVQ63044DEC", "image_url": "https://api.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5/qr.svg" }, "checkout_url": "https://app.hatang.dev/pay/pay_01a0b904297873e8b3524a26fdca0ab5", "return_url": "https://shop.example.vn/orders/1001", "ipn_url": "https://shop.example.vn/hatang/ipn", "customer": { "name": "Trần Thị B", "email": "b@example.vn" }, "metadata": { "order_id": "1001" }, "invoice": null, "expires_at": "2026-09-19T10:34:12.856Z", "paid_at": null, "canceled_at": null, "created_at": "2026-09-19T09:34:12.856Z" } ``` Lưu `id` cùng đơn hàng của bạn. Webhook `payment.created` được gửi (sự kiện này không có IPN). ### Lỗi | Lỗi | Khi nào | Cách xử lý | |---|---|---| | `422 validation_error` | Thiếu hoặc sai trường; thông báo nêu tên trường (`amount: Number must be greater than or equal to 1`) | Sửa request | | `422 validation_error` ở `ipn_url` | URL trỏ tới địa chỉ nội bộ, hoặc không phải `https` | Dùng URL HTTPS công khai | | `422 validation_error` "Several bank accounts are linked; pass connection_id" | Có nhiều hơn một tài khoản đang hoạt động | Gửi `connection_id` | | `422 no_bank_connection` | Không có tài khoản đang hoạt động, hoặc `connection_id` không hoạt động | Liên kết hoặc liên kết lại tài khoản | | `409 code_taken` | `code` của bạn đã được một payment khác vào tài khoản này dùng | Dùng mã khác | | `400 invalid_idempotent_request` | Key đã dùng với body khác | Dùng key mới cho đơn mới | | `429 rate_limit_exceeded` | Hơn 20 request Thanh toán mỗi giây cho doanh nghiệp của bạn | Thử lại sau thời gian trong header `Retry-After` | ## 2. Để người mua thanh toán Chuyển tới `checkout_url`, hoặc tự hiển thị `qr` và thông tin `bank`. Xem [Cách người mua thanh toán](https://hatang.dev/vi/docs/guides/payments/buyer-flow.md). ## 3. Nhận kết quả Chờ lời gọi IPN hoặc webhook (xem [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md)), rồi giao hàng khi `status` là `paid` hoặc `overpaid` và `paid_amount` đủ cho đơn. Không bao giờ giao hàng dựa trên trình duyệt. ## Trạng thái ```mermaid stateDiagram-v2 [*] --> pending pending --> paid: đủ tiền pending --> partially_paid: thiếu tiền pending --> overpaid: thừa tiền partially_paid --> paid: chuyển nốt cùng mã partially_paid --> overpaid: chuyển nhiều hơn phần còn thiếu paid --> overpaid: thêm giao dịch cùng mã pending --> expired: quá expires_at expired --> paid: tiền về muộn pending --> canceled: POST /cancel ``` | `status` | Ý nghĩa | Sự kiện | |---|---|---| | `pending` | Đang chờ chuyển khoản | `payment.created` | | `paid` | Đã nhận đủ; `paid_at` được đặt | `payment.succeeded` | | `partially_paid` | Nhận ít hơn số tiền. Giao dịch tiếp theo cùng mã sẽ cộng dồn | `payment.partially_paid` | | `overpaid` | Nhận nhiều hơn số tiền | `payment.overpaid` | | `expired` | Không có tiền nào trước `expires_at` | `payment.expired` | | `canceled` | Bạn đã hủy khi payment còn `pending` | `payment.canceled` | `payment.succeeded`, `payment.partially_paid` và `payment.overpaid` kèm theo `transaction` ngân hàng tương ứng. ### Tình huống đặc biệt - **Tiền về muộn.** Giao dịch đến sau `expires_at` vẫn được ghi nhận: payment chuyển từ `expired` sang `paid` (hoặc `partially_paid`, `overpaid`) và bạn được báo. Tự quyết định có giao đơn trễ hay không. - **Payment trả thiếu không hết hạn.** Chỉ payment `pending` mới chuyển sang `expired`. Payment `partially_paid` giữ nguyên trạng thái cho tới khi phần còn lại về hoặc bạn ghi nhận thủ công. - **Trả hai lần.** Giao dịch thứ hai mang mã của payment đã `paid` sẽ chuyển nó sang `overpaid`, và `paid_amount` là tổng. Hoàn tiền thực hiện ngoài Hạ Tầng. - **Payment đã hủy** không còn được khớp. Giao dịch mang mã của nó đến dưới dạng `transaction.received` chưa khớp. - **Thời điểm hết hạn.** Việc xử lý hết hạn chạy mỗi 30 giây, nên `payment.expired` đến ngay sau `expires_at`. ## Mã thanh toán {#payment-code} `code` gồm tiền tố (`HT` mặc định, đổi được theo tài khoản ngân hàng qua `config.code_prefix`) và 8 ký tự không dễ nhầm: không có `0`, `O`, `1`, `I`. Người mua phải ghi mã vào nội dung chuyển khoản. Xung quanh có gì cũng được (`CK HTBF7RDGVQ thanh toan`); chữ hoa thường, khoảng trắng, dấu câu và dấu tiếng Việt đều được bỏ qua. VietQR đã chứa sẵn mã, nên người quét QR không phải gõ. ### Mã của riêng bạn {#your-own-code} Gửi `code` và mã đó sẽ là nội dung chuyển khoản thay cho mã tự sinh: ví dụ số đơn hàng của bạn, để sao kê ngân hàng và đơn hàng khớp nhau. ```json { "amount": 275000, "code": "DH1002" } ``` - Chỉ gồm chữ và số, từ 6 đến 20 ký tự; được viết hoa. - Mã phải chưa được dùng trên tài khoản ngân hàng đó, nếu không lời gọi trả về `409 code_taken`. Thử lại với cùng `Idempotency-Key` sẽ trả về payment đã có. - **Mã dài an toàn hơn.** Mã được tìm bằng cách dò trong nội dung chuyển khoản, nên một mã ngắn, phổ biến (`ORDER1`) có thể khớp nhầm giao dịch tình cờ chứa nó. Mã có tiền tố riêng của bạn (`DH1002`) là đủ đặc trưng. - **Khi một mã nằm trong mã khác** (`DH1002` và `DH10024`), mã dài hơn được nhận giao dịch chứa nó. ## Đọc và liệt kê payment ```bash curl https://api.hatang.dev/payments/pay_123 -H "Authorization: Bearer $HATANG_API_KEY" curl "https://api.hatang.dev/payments?status=paid,overpaid&limit=50" -H "Authorization: Bearer $HATANG_API_KEY" ``` | Tham số | Ý nghĩa | |---|---| | `limit` | 1 đến 100, mặc định 20 | | `after` | `next_cursor` của trang trước | | `status` | Một hoặc nhiều trạng thái, cách nhau bởi dấu phẩy | | `connection_id` | Một tài khoản ngân hàng | Danh sách trả về `{ "object": "list", "data": [...], "has_more": false, "next_cursor": null }`, mới nhất trước. ## Hủy payment **cURL** ```bash curl -X POST https://api.hatang.dev/payments/pay_123/cancel \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data, error } = await hatang.payments.cancel("pay_123"); ``` **Python** ```python hatang.payments.cancel("pay_123") ``` **PHP** ```php $client->post('/payments/pay_123/cancel'); ``` Chỉ hủy được payment `pending`. Trạng thái khác trả về `409 invalid_state` ("A paid payment cannot be canceled"). ## Ghi nhận tiền nhận bằng cách khác Khi tiền đến ngoài ngân hàng (tiền mặt, hoặc chuyển khoản không có mã), hãy ghi nhận thủ công. Dashboard có thao tác tương tự: **Đánh dấu đã thanh toán** trên trang payment. **cURL** ```bash curl https://api.hatang.dev/payments/pay_123/mark-paid \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Khách trả tiền mặt tại quầy" }' ``` **Node.js** ```ts await hatang.payments.markPaid("pay_123", { note: "Khách trả tiền mặt tại quầy" }); ``` **Python** ```python requests.post("https://api.hatang.dev/payments/pay_123/mark-paid", headers=HEADERS, json={"note": "Khách trả tiền mặt tại quầy"}, timeout=10).raise_for_status() ``` **PHP** ```php $client->post('/payments/pay_123/mark-paid', ['json' => ['note' => 'Khách trả tiền mặt tại quầy']]); ``` | Trường | Ý nghĩa | |---|---| | `amount` | Số tiền đã nhận. Mặc định là phần còn thiếu (`amount - paid_amount`) | | `note` | Tối đa 500 ký tự, được lưu trong sự kiện | Áp dụng cho payment `pending`, `partially_paid` và `expired` (trạng thái khác trả về `409 invalid_state`). Payment chuyển sang `paid`, `partially_paid` hoặc `overpaid` như khi có giao dịch thật, và `data` của sự kiện webhook có thêm `manual: { by, note, amount }` để bạn phân biệt. ## Bước tiếp theo - [Cách người mua thanh toán](https://hatang.dev/vi/docs/guides/payments/buyer-flow.md): trang checkout, QR và trang thanh toán của riêng bạn. - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): IPN và webhook. - [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md): thêm khối `invoice`. Source: https://hatang.dev/vi/docs/guides/payments/create-payment --- # Hóa đơn điện tử Hạ Tầng phát hành hóa đơn điện tử theo mã số thuế của bạn, qua tài khoản riêng của bạn tại nhà cung cấp hóa đơn. Thêm khối `invoice` vào payment và hóa đơn được tạo khi đơn đã thanh toán; hoặc tự tạo hóa đơn cho bất kỳ payment hay giao dịch ngân hàng nào. Trang này hướng dẫn kết nối nhà cung cấp, vòng đời hóa đơn, xử lý lỗi, và đối soát tiền vào với hóa đơn. ## Trước khi bắt đầu - Tài khoản hóa đơn điện tử Mắt Bão có quyền dùng API. Mã số thuế, tài khoản và mật khẩu API nằm trong mục "Cấu hình API" trên cổng Mắt Bão. - Ít nhất một mẫu số và ký hiệu đã đăng ký trên tài khoản đó. - API key có `payments:write`. Hóa đơn điện tử dùng chung scope và môi trường với Thanh toán. Ở `development`, hãy kết nối tài khoản sandbox demo của Mắt Bão: hóa đơn là hóa đơn sandbox thật nhưng không bao giờ gửi tới cơ quan thuế. ## 1. Kết nối nhà cung cấp Làm một lần cho mỗi môi trường, trên dashboard (**Cài đặt thanh toán → Nhà cung cấp hoá đơn**) hoặc qua API. Trước tiên kiểm tra thông tin đăng nhập và liệt kê mẫu hóa đơn của tài khoản; bước này không lưu gì: **cURL** ```bash curl https://api.hatang.dev/einvoices/connections/check \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": "..." }' ``` **Node.js** ```ts const { data: check, error } = await hatang.einvoices.connections.check({ provider: "matbao", tax_code: "0109876543", username: "api-user", password: process.env.MATBAO_PASSWORD!, }); ``` **Python** ```python check = requests.post("https://api.hatang.dev/einvoices/connections/check", headers=HEADERS, json={ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": os.environ["MATBAO_PASSWORD"], }, timeout=30).json() ``` **PHP** ```php $check = json_decode($client->post('/einvoices/connections/check', ['json' => [ 'provider' => 'matbao', 'tax_code' => '0109876543', 'username' => 'api-user', 'password' => getenv('MATBAO_PASSWORD'), ]])->getBody(), true); ``` ```json { "object": "einvoice_connection_check", "ok": true, "provider": "matbao", "seller_tax_code": "0109876543", "seller_name": "CONG TY TNHH VI DU", "templates": [ { "pattern": "1", "serial": "C26TAT", "name": "Hóa đơn giá trị gia tăng", "remaining": 4980 } ] } ``` Sau đó kết nối với mẫu bạn chọn. Body gồm: | Trường | Kiểu | Bắt buộc | Mặc định | Ý nghĩa | |---|---|---|---|---| | `provider` | chuỗi | Có | – | `matbao` (`GET /einvoices/providers` liệt kê các nhà cung cấp) | | `tax_code`, `username`, `password` | chuỗi | Có với `matbao` | – | Mã số thuế người bán và tài khoản API của nhà cung cấp; được lưu mã hóa | | `pattern` | chuỗi, 1 đến 6 | Có | – | Mẫu số mặc định cho hóa đơn mới; phải là một mẫu của tài khoản | | `serial` | chuỗi, đúng 6 | Có | – | Ký hiệu mặc định, ví dụ `C26TAT` | | `default_mode` | `draft` \| `publish` | Không | `draft` | Khi đơn đã thanh toán yêu cầu hóa đơn mà không có `mode`: `draft` dừng ở nháp, `publish` gửi ký luôn | | `is_default` | boolean | Không | `true` với nhà cung cấp đầu tiên | Hóa đơn mới dùng nhà cung cấp mặc định | | `import_external` | boolean | Không | `false` | Nhập cả hóa đơn tạo trực tiếp trên trang nhà cung cấp | | `label` | chuỗi, tối đa 100 | Không | – | Tên bạn đặt cho kết nối | Schema OpenAPI: [`POST /einvoices/connections`](https://hatang.dev/vi/docs/reference/api/post-einvoices-connections) ```bash curl https://api.hatang.dev/einvoices/connections \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider": "matbao", "tax_code": "0109876543", "username": "api-user", "password": "...", "pattern": "1", "serial": "C26TAT", "default_mode": "publish" }' ``` Phản hồi (`201`) là kết nối, kèm `callback_url`. Gửi URL này cho bộ phận hỗ trợ Mắt Bão để thay đổi phía họ về Hạ Tầng ngay; nếu không, Hạ Tầng kiểm tra lại các hóa đơn còn đang chờ sau 1 giờ kể từ khi tạo, 15 phút một lần, trong tối đa 7 ngày. Bản nháp bạn cố ý để lại (`mode: "draft"`) không được kiểm tra: gọi `POST /einvoices/{id}/sync` để lấy các chỉnh sửa trên trang nhà cung cấp. Thông tin đăng nhập được lưu mã hóa. Nếu nhà cung cấp bắt đầu từ chối thông tin này, kết nối chuyển sang `needs_reauth` và hóa đơn tạm dừng cho tới khi bạn lưu thông tin mới bằng `PATCH /einvoices/connections/{id}`. | Endpoint | Chức năng | |---|---| | `GET /einvoices/providers` | Các nhà cung cấp kết nối được, kèm các trường mỗi bên cần | | `GET /einvoices/connections` / `GET /einvoices/connections/{id}` | Các kết nối của bạn | | `PATCH /einvoices/connections/{id}` | Đổi chế độ mặc định, mẫu, thông tin đăng nhập, nhãn | | `POST /einvoices/connections/{id}/default` | Đặt làm nhà cung cấp mặc định | | `GET /einvoices/connections/{id}/templates` | Các mẫu tài khoản được phép phát hành, đọc trực tiếp | | `POST /einvoices/connections/{id}/sync` | Lấy thay đổi từ nhà cung cấp ngay | | `DELETE /einvoices/connections/{id}` | Gỡ liên kết. Hóa đơn vẫn còn; nhà cung cấp tiếp theo trở thành mặc định | ## 2. Thêm hóa đơn vào payment Thêm khối `invoice` khi [tạo payment](https://hatang.dev/vi/docs/guides/payments/create-payment.md). Không có gì được gửi tới nhà cung cấp cho tới khi payment ở trạng thái `paid` hoặc `overpaid`. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Idempotency-Key: order-1002" \ -H "Content-Type: application/json" \ -d '{ "amount": 275000, "description": "Đơn hàng #1002", "ipn_url": "https://shop.example.vn/hatang/ipn", "metadata": { "order_id": "1002" }, "invoice": { "mode": "publish", "template": { "pattern": "1", "serial": "C26TAT" }, "buyer": { "name": "Nguyễn Văn Khách", "legal_name": "Công ty TNHH Khách Hàng", "tax_code": "0109876543", "address": "1 Tràng Tiền, Hà Nội", "email": "ketoan@khach.vn" }, "items": [{ "name": "Gói dịch vụ tháng 9", "unit": "gói", "quantity": 1, "unit_price": 250000, "vat_rate": 10 }], "payment_method": "CK", "send_email": true } }' ``` **Node.js** ```ts const { data: payment, error } = await hatang.payments.createOrder({ order_id: "1002", amount: 275_000, description: "Đơn hàng #1002", ipn_url: "https://shop.example.vn/hatang/ipn", invoice: { mode: "publish", template: { pattern: "1", serial: "C26TAT" }, buyer: { name: "Nguyễn Văn Khách", legal_name: "Công ty TNHH Khách Hàng", tax_code: "0109876543", address: "1 Tràng Tiền, Hà Nội", email: "ketoan@khach.vn" }, items: [{ name: "Gói dịch vụ tháng 9", unit: "gói", quantity: 1, unit_price: 250_000, vat_rate: 10 }], payment_method: "CK", send_email: true, }, }); ``` **Python** ```python payment = hatang.payments.create({ "amount": 275000, "description": "Đơn hàng #1002", "ipn_url": "https://shop.example.vn/hatang/ipn", "metadata": {"order_id": "1002"}, "invoice": { "mode": "publish", "template": {"pattern": "1", "serial": "C26TAT"}, "buyer": {"name": "Nguyễn Văn Khách", "legal_name": "Công ty TNHH Khách Hàng", "tax_code": "0109876543", "address": "1 Tràng Tiền, Hà Nội", "email": "ketoan@khach.vn"}, "items": [{"name": "Gói dịch vụ tháng 9", "unit": "gói", "quantity": 1, "unit_price": 250000, "vat_rate": 10}], "payment_method": "CK", "send_email": True, }, }, idempotency_key="order-1002") ``` **PHP** ```php $payment = json_decode($client->post('/payments', [ 'headers' => ['Idempotency-Key' => 'order-1002'], 'json' => [ 'amount' => 275000, 'description' => 'Đơn hàng #1002', 'ipn_url' => 'https://shop.example.vn/hatang/ipn', 'metadata' => ['order_id' => '1002'], 'invoice' => [ 'mode' => 'publish', 'template' => ['pattern' => '1', 'serial' => 'C26TAT'], 'buyer' => ['name' => 'Nguyễn Văn Khách', 'legal_name' => 'Công ty TNHH Khách Hàng', 'tax_code' => '0109876543', 'address' => '1 Tràng Tiền, Hà Nội', 'email' => 'ketoan@khach.vn'], 'items' => [['name' => 'Gói dịch vụ tháng 9', 'unit' => 'gói', 'quantity' => 1, 'unit_price' => 250000, 'vat_rate' => 10]], 'payment_method' => 'CK', 'send_email' => true, ], ], ])->getBody(), true); ``` | Trường | Kiểu | Bắt buộc | Mặc định | Ý nghĩa | |---|---|---|---|---| | `mode` | `draft` \| `publish` | Không | `default_mode` của kết nối | `draft` dừng ở bản nháp, bạn xem lại rồi gọi `POST /einvoices/{id}/publish`; `publish` tạo nháp rồi gửi ký luôn | | `template` | object `{ pattern, serial }` | Không | mẫu của kết nối | `pattern`: mẫu số, 1 đến 6 ký tự; `serial`: ký hiệu, đúng 6 ký tự | | `buyer` | [người mua](#buyer) | Có | – | Cần `name` hoặc `legal_name` | | `items` | mảng [dòng hàng](#line-item), 1 đến 200 | Có | – | Các dòng hàng trên hóa đơn | | `payment_method` | chuỗi, tối đa 50 | Không | `TM/CK` | Hình thức thanh toán, in trên hóa đơn | | `note` | chuỗi, tối đa 500 | Không | – | Ghi chú, in trên hóa đơn | | `send_email` | boolean | Không | `false` | Gửi email hóa đơn đã phát hành cho người mua (tới `buyer.email`) | Schema OpenAPI: [`POST /payments`](https://hatang.dev/vi/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/vi/docs/reference/api/post-einvoices) > **Caution: Tổng tiền** > > Hãy để tổng hóa đơn (sau VAT) bằng `amount` của payment: ở ví dụ trên 250.000 + 10% = 275.000. Hạ Tầng không kiểm tra điều này. Mỗi dòng được làm tròn tới đồng riêng; khi giá của bạn đã gồm VAT, hãy gửi `vat_amount` cho từng dòng để tổng khớp tới từng đồng. Xem [Tổng tiền và làm tròn VAT](#totals). ## 3. Sau khi thanh toán {#after-payment} Mỗi hóa đơn đi qua cùng hai bước, tạo nháp và gửi ký; mỗi bước được xếp hàng và tự thử lại độc lập: ```mermaid flowchart LR A[waiting_for_payment] -->|đã trả| B[creating] B --> C[draft] C -->|mode publish| D[publishing] D --> E[issued] E --> F[tax_accepted] E --> G[tax_rejected] B -.-> X[failed] D -.-> X ``` | `invoice.status` | Ý nghĩa | |---|---| | `waiting_for_payment` | Chưa trả; chưa gửi gì (chỉ có trên payment) | | `creating` | Đã trả; đang tạo nháp tại nhà cung cấp | | `draft` | Đã có nháp. Dừng ở đây nếu `mode` là `draft` | | `publishing` | Đã gửi ký | | `issued` | Đã ký: có `number` và `lookup_code`; chờ cơ quan thuế | | `tax_accepted` / `tax_rejected` | Phản hồi của cơ quan thuế (`tax_authority_code` khi chấp nhận) | | `failed` | Nhà cung cấp từ chối một bước: `error` cho biết lý do, `failed_step` cho biết bước nào | | `canceled` / `replaced` / `adjusted` | Thay đổi sau đó, trên trang nhà cung cấp hoặc bằng hóa đơn thay thế | | `deleted` | Nháp đã bị xóa | Trường `invoice` của payment ([các trường](#payment-invoice)) luôn phản ánh trạng thái hiện tại của hóa đơn, và mọi thay đổi được gửi qua IPN và webhook `invoice.*`: ```json "invoice": { "requested": true, "mode": "publish", "status": "issued", "id": "einv_01a0b90429f678e68ed7de882720a2b4", "number": "8003", "series": "1C26TAT", "template": { "pattern": "1", "serial": "C26TAT" }, "lookup_code": "HTXAEM9P3AYXSM", "tax_authority_code": null, "pdf_url": "https://…", "sync_state": "in_sync", "issued_at": "2026-09-18T17:00:00.000Z", "error": null, "failed_step": null } ``` Nếu payment được trả khi chưa kết nối nhà cung cấp nào ở môi trường đó, `invoice.status` là `failed` với `id: null` và lỗi "No e-invoice provider is connected in this environment". Trang checkout chỉ cho người mua biết có hóa đơn hay không, và khi đã phát hành thì số hóa đơn và mã tra cứu. ## Xem lại nháp trước khi ký Với `mode: "draft"`, hóa đơn dừng ở `draft`. Khi đó bạn có thể: | Lời gọi | Chức năng | |---|---| | `PATCH /einvoices/{id}` | Sửa `buyer`, `items`, `payment_method`, `note` | | `POST /einvoices/{id}/publish` | Gửi ký | | `DELETE /einvoices/{id}` | Xóa nháp | Chỉ nháp mới sửa hoặc gửi ký được; trạng thái khác trả về `409 invalid_state`. Ngay cả `mode: "publish"` cũng không phát hành trong một lời gọi: gửi ký luôn là một bước riêng, được xếp hàng. ## Khi một bước lỗi Timeout và sự cố của nhà cung cấp được thử lại tự động. Khi bị từ chối (ví dụ mã số thuế người mua không hợp lệ), hóa đơn dừng ở `failed` kèm thông báo của nhà cung cấp: 1. Sửa theo thông báo. Nếu lỗi ở bước tạo nháp (`failed_step: "create_draft"`), sửa người mua hoặc dòng hàng bằng `PATCH /einvoices/{id}`. 2. `POST /einvoices/{id}/retry` chạy lại đúng bước đó. 3. `GET /einvoices/{id}/history` liệt kê mọi lần đổi trạng thái, cũ nhất trước, kèm phía thực hiện (`source`) và thời điểm. ```json { "object": "list", "data": [ { "from": null, "to": "creating", "source": "hatang", "detail": { "mode": "publish", "template": "1/C26TAT" }, "at": "2026-09-18T16:59:58.120Z" }, { "from": "draft", "to": "publishing", "source": "hatang", "detail": null, "at": "2026-09-18T16:59:59.010Z" } ] } ``` ## Hóa đơn không gắn payment {#invoices-without-a-payment} `POST /einvoices` nhận cùng các trường như khối `invoice`, thêm: | Trường | Kiểu | Bắt buộc | Mặc định | Ý nghĩa | |---|---|---|---|---| | `payment_id` | chuỗi `pay_…` | Không | – | Gắn với một payment. Mỗi payment có tối đa một hóa đơn; hóa đơn thứ hai trả về `409 invalid_state` | | `transaction_id` | chuỗi `txn_…` | Không | – | Gắn với một giao dịch ngân hàng [nhận không qua đơn](https://hatang.dev/vi/docs/guides/payments/transactions.md) | | `connection_id` | chuỗi `icn_…` | Không | kết nối mặc định | Kết nối nhà cung cấp phát hành hóa đơn | Schema OpenAPI: [`POST /einvoices`](https://hatang.dev/vi/docs/reference/api/post-einvoices) **cURL** ```bash curl https://api.hatang.dev/einvoices \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "publish", "transaction_id": "txn_123", "buyer": { "name": "Nguyễn Văn Khách" }, "items": [{ "name": "Cà phê hạt 1 kg", "quantity": 1, "unit_price": 91667, "vat_rate": 8 }] }' ``` **Node.js** ```ts const { data: invoice, error } = await hatang.einvoices.create({ mode: "publish", transaction_id: "txn_123", buyer: { name: "Nguyễn Văn Khách" }, items: [{ name: "Cà phê hạt 1 kg", quantity: 1, unit_price: 91_667, vat_rate: 8 }], }); ``` **Python** ```python invoice = requests.post("https://api.hatang.dev/einvoices", headers=HEADERS, json={ "mode": "publish", "transaction_id": "txn_123", "buyer": {"name": "Nguyễn Văn Khách"}, "items": [{"name": "Cà phê hạt 1 kg", "quantity": 1, "unit_price": 91667, "vat_rate": 8}], }, timeout=10).json() ``` **PHP** ```php $invoice = json_decode($client->post('/einvoices', ['json' => [ 'mode' => 'publish', 'transaction_id' => 'txn_123', 'buyer' => ['name' => 'Nguyễn Văn Khách'], 'items' => [['name' => 'Cà phê hạt 1 kg', 'quantity' => 1, 'unit_price' => 91667, 'vat_rate' => 8]], ]])->getBody(), true); ``` Phản hồi (`201`) là [object hóa đơn](#einvoice-object) (`object: "einvoice"`) ở trạng thái `creating`, kèm `buyer`, `items` và `totals` đã tính (`subtotal`, `vat`, `total`). Theo dõi bằng `GET /einvoices/{id}` hoặc webhook `invoice.*`. `GET /einvoices` liệt kê hóa đơn, lọc theo `status`, `payment_id` hoặc `sync_state`. ## Thay thế, hủy và xung đột - **Thay thế.** Hóa đơn đã gửi cơ quan thuế (`issued`, `tax_accepted`, `tax_rejected`) không thể hủy hay xóa qua Hạ Tầng. `POST /einvoices/{id}/replace` kèm `reason`, `buyer` và `items` đã sửa sẽ tạo hóa đơn thay thế: một bản nháp ghi rõ hóa đơn gốc, rồi gửi ký. Hóa đơn gốc chuyển sang `replaced` khi nhà cung cấp đã có hóa đơn thay thế. - **Xóa** nháp (hoặc hóa đơn lỗi) bằng `DELETE /einvoices/{id}`. - **Thay đổi trên trang nhà cung cấp** được đồng bộ về Hạ Tầng: ký, mã của cơ quan thuế, hủy, thay thế, sửa và xóa nháp. Sự kiện của chúng có `source: "provider"`. Hóa đơn tạo trực tiếp trên trang nhà cung cấp chỉ được nhập khi kết nối bật `import_external: true`. - **Xung đột.** Nháp bị sửa ở cả Hạ Tầng và trang nhà cung cấp sẽ có `sync_state: "conflict"` và sự kiện `invoice.conflict`. Giải quyết bằng `POST /einvoices/{id}/resolve` với `{ "keep": "hatang" }` hoặc `{ "keep": "provider" }`. Trước khi giải quyết, không sửa hay gửi ký được. - **Kiểm tra ngay.** `POST /einvoices/{id}/sync` hỏi nhà cung cấp về một hóa đơn ngay lập tức. ## Đối soát tiền vào với hóa đơn Mọi giao dịch tiền vào tài khoản đều nên có hóa đơn, trừ khi không cần. Giao dịch được tính là *đã có hóa đơn* khi một hóa đơn còn hiệu lực (không lỗi, không bị xóa, hủy hay bị cơ quan thuế từ chối) trỏ tới nó, trực tiếp hoặc qua payment mà nó đã trả. Ngược lại là *thiếu hóa đơn*, trừ khi bạn đánh dấu *không cần hóa đơn*. ```bash curl "https://api.hatang.dev/einvoices/reconciliation?from=2026-09-01&to=2026-09-30" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` `from` và `to` là ngày theo giờ Việt Nam (`YYYY-MM-DD`), cách nhau tối đa 400 ngày; mặc định là 30 ngày gần nhất. Phản hồi có `totals` (`money_in`, `invoiced`, `missing`, `exempt`, mỗi mục có `count` và `amount`), một dòng cho mỗi ngày, cùng danh sách giao dịch `missing` và `exempt`. Dashboard hiển thị cùng báo cáo tại **Thanh toán → Đối soát hoá đơn**. Đánh dấu giao dịch không cần hóa đơn (hoàn tiền, chuyển giữa các tài khoản của chính bạn), hoặc bỏ đánh dấu: ```bash curl https://api.hatang.dev/payments/transactions/txn_123/invoice-exempt \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Chuyển giữa các tài khoản của công ty" }' curl -X DELETE https://api.hatang.dev/payments/transactions/txn_123/invoice-exempt \ -H "Authorization: Bearer $HATANG_API_KEY" ``` Chủ sở hữu và quản trị viên của doanh nghiệp có nhà cung cấp đang hoạt động còn nhận email hằng ngày lúc 08:00 (giờ Việt Nam) liệt kê tiền vào hôm trước còn thiếu hóa đơn, và email khi một hóa đơn bị lỗi, bị cơ quan thuế từ chối, bị xung đột, hoặc sau 1 giờ kể từ khi tạo vẫn chưa tới cơ quan thuế. Bản nháp bạn cố ý để lại (`mode: "draft"`) không bị báo. ## Các object và cách chúng liên kết {#objects} Khi một đơn hàng được thanh toán và xuất hóa đơn, có ba object tham gia: **payment** bạn tạo, **transaction** ngân hàng báo về, và **hóa đơn điện tử** (einvoice). Mỗi object trỏ tới các object kia bằng id, nên bạn bắt đầu từ object nào mà sự kiện gửi tới cũng được. ```mermaid flowchart LR O["Đơn hàng của bạn"] -->|"metadata.order_id"| P["payment
pay_…"] T["transaction
txn_…"] -->|"payment_id"| P P -->|"invoice.id"| I["einvoice
einv_…"] I -->|"payment_id"| P I -->|"transaction_id
(không có payment)"| T I -->|"reference"| M["Hóa đơn ở nhà cung cấp"] ``` | Lưu vào đơn hàng | Lấy từ đâu | Dùng để | |---|---|---| | `id` của payment (`pay_…`) | Phản hồi của `POST /payments` | `GET /payments/{id}`; là `data.id` trong mọi lần gọi IPN | | `id` của hóa đơn (`einv_…`) | `invoice.id` của payment khi hóa đơn đã có (`null` trước khi payment được trả), hoặc phản hồi của `POST /einvoices` | `GET`, `PATCH`, `publish`, `retry`, `replace` trên `/einvoices/{id}`; là `data.id` trong webhook `invoice.*` | | `number`, `series`, `lookup_code` | Hóa đơn, khi đã `issued` | Cho người mua biết hóa đơn của họ và nơi tra cứu | | `reference` | Hóa đơn, ngay từ đầu | Mã tham chiếu Hạ Tầng dùng ở nhà cung cấp; tìm hóa đơn trên trang của nhà cung cấp bằng mã này | - **Qua payment.** Thêm khối `invoice` vào `POST /payments`, hoặc gọi `POST /einvoices` kèm `payment_id`. Mỗi payment có một hóa đơn đang hiệu lực; hóa đơn thay thế giữ nguyên `payment_id`, và trường `invoice` của payment luôn là hóa đơn đang hiệu lực. `GET /einvoices?payment_id=pay_…` liệt kê tất cả. - **Qua giao dịch, không có payment.** Nếu bạn chỉ nhận webhook `transaction.received`, gọi `POST /einvoices` kèm `transaction_id` của giao dịch đó. - **Sự kiện.** Lần gọi IPN mang payment, trạng thái hóa đơn nằm ở `data.invoice`. Webhook `invoice.*` mang nguyên object hóa đơn, kèm `source` (`hatang`, hoặc `provider` khi thay đổi đến từ trang của nhà cung cấp). ### Yêu cầu xuất hóa đơn {#invoice-request} Cùng một object, cùng quy tắc, được gửi ở bốn chỗ: | Ở đâu | Nhận gì | |---|---| | `invoice` trong `POST /payments` | Mọi trường bên dưới | | `POST /einvoices` | Mọi trường bên dưới, thêm `payment_id`, `transaction_id`, `connection_id` | | `PATCH /einvoices/{id}` (bản nháp) | `buyer`, `items`, `payment_method`, `note`; giá trị gửi lên thay hẳn giá trị cũ; đều không bắt buộc, chỉ gửi trường cần đổi | | `POST /einvoices/{id}/replace` | `reason`, `buyer`, `items`, `payment_method`, `note` | Một yêu cầu đầy đủ: người mua là công ty, hai dòng hàng (một dòng tự nhập tiền thuế, một dòng không chịu thuế): ```json { "mode": "publish", "template": { "pattern": "1", "serial": "C26TAT" }, "buyer": { "name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543", "address": "1 Trang Tien, Ha Noi", "email": "accounting@example.com", "phone": "0901234567", "buyer_code": "KH-0042" }, "items": [ { "name": "Coffee 1 kg", "code": "CF-1KG", "unit": "kg", "quantity": 1, "unit_price": 45455, "vat_rate": 10, "vat_amount": 4545 }, { "name": "Delivery", "unit": "order", "quantity": 1, "unit_price": 20000, "vat_rate": -1 } ], "payment_method": "CK", "note": "Order #1002", "send_email": true } ``` Tổng của nó: tiền hàng 65.455, VAT 4.545, tổng cộng 70.000. | Trường | Kiểu | Bắt buộc | Mặc định | Ý nghĩa | |---|---|---|---|---| | `mode` | `draft` \| `publish` | Không | `default_mode` của kết nối | `draft` dừng ở bản nháp, bạn xem lại rồi gọi `POST /einvoices/{id}/publish`; `publish` tạo nháp rồi gửi ký luôn | | `template` | object `{ pattern, serial }` | Không | mẫu của kết nối | `pattern`: mẫu số, 1 đến 6 ký tự; `serial`: ký hiệu, đúng 6 ký tự | | `buyer` | [người mua](#buyer) | Có | – | Cần `name` hoặc `legal_name` | | `items` | mảng [dòng hàng](#line-item), 1 đến 200 | Có | – | Các dòng hàng trên hóa đơn | | `payment_method` | chuỗi, tối đa 50 | Không | `TM/CK` | Hình thức thanh toán, in trên hóa đơn | | `note` | chuỗi, tối đa 500 | Không | – | Ghi chú, in trên hóa đơn | | `send_email` | boolean | Không | `false` | Gửi email hóa đơn đã phát hành cho người mua (tới `buyer.email`) | Schema OpenAPI: [`POST /payments`](https://hatang.dev/vi/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/vi/docs/reference/api/post-einvoices) #### Người mua {#buyer} Cần `name`, `legal_name` hoặc cả hai. Với công ty, `legal_name` là tên công ty và `name` là người mua hàng; với cá nhân, chỉ cần `name`. | Trường | Kiểu | Bắt buộc | Mặc định | Ý nghĩa | |---|---|---|---|---| | `name` | chuỗi, tối đa 200 | Một trong hai | – | Người mua hàng, in ở ô "Họ tên người mua hàng" | | `legal_name` | chuỗi, tối đa 400 | Một trong hai | – | Tên công ty theo đăng ký, in ở ô "Tên đơn vị". Khách cá nhân để trống | | `tax_code` | 10 đến 14 chữ số hoặc dấu gạch | Không | – | Mã số thuế người mua, ví dụ `0109876543` hoặc `0109876543-001` | | `address` | chuỗi, tối đa 400 | Không | – | Địa chỉ người mua | | `email` | email | Không | – | Nơi `send_email` gửi hóa đơn tới | | `phone` | chuỗi, tối đa 20 | Không | – | Số điện thoại người mua | | `buyer_code` | chuỗi, tối đa 50 | Không | – | Mã khách hàng của bạn, in trên hóa đơn | Schema OpenAPI: [`POST /payments`](https://hatang.dev/vi/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/vi/docs/reference/api/post-einvoices) #### Dòng hàng {#line-item} | Trường | Kiểu | Bắt buộc | Mặc định | Ý nghĩa | |---|---|---|---|---| | `name` | chuỗi, 1 đến 500 | Có | – | Tên hàng hóa, dịch vụ | | `quantity` | số lớn hơn 0 | Có | – | Được có số lẻ, ví dụ `1.5` kg | | `unit_price` | số, từ 0 | Có | – | Đơn giá **trước VAT**, tính bằng đồng. Được có số lẻ | | `vat_rate` | `10` \| `8` \| `5` \| `0` \| `-1` \| `-2` | Không | `10` | `-1`: không chịu thuế (KCT); `-2`: không kê khai, tính nộp thuế (KKKNT) | | `vat_amount` | số đồng chẵn, từ 0 | Không | tính từ `vat_rate` | Tiền thuế của dòng, dùng đúng như gửi. Chỉ dùng khi `vat_rate` lớn hơn 0. Xem [Tổng tiền và làm tròn VAT](#totals) | | `discount` | số, từ 0 | Không | `0` | Chiết khấu cho cả dòng, tính bằng đồng trước VAT | | `unit` | chuỗi, tối đa 50 | Không | – | Đơn vị tính, ví dụ `kg`, `gói` | | `code` | chuỗi, tối đa 50 | Không | – | Mã hàng (SKU) của bạn | Schema OpenAPI: [`POST /payments`](https://hatang.dev/vi/docs/reference/api/post-payments), [`POST /einvoices`](https://hatang.dev/vi/docs/reference/api/post-einvoices) Trong object hóa đơn, mỗi dòng trả về những gì bạn đã gửi, thêm ba số tiền: `amount` (thành tiền trước VAT), `vat_amount` và `total`. ### Tổng tiền và làm tròn VAT {#totals} Đồng không có đơn vị nhỏ hơn, nên từng dòng được làm tròn riêng, rồi hóa đơn cộng các dòng đã làm tròn. Nhà cung cấp in đúng các số này. | Số tiền | Cách tính | |---|---| | `amount` của dòng | `quantity × unit_price − discount`, làm tròn tới đồng | | `vat_amount` của dòng | `vat_amount` nếu bạn gửi; nếu không thì `amount × vat_rate / 100`, làm tròn; bằng `0` khi `vat_rate` là `0`, `-1` hoặc `-2` | | `total` của dòng | `amount + vat_amount` | | `totals.subtotal` / `totals.vat` / `totals.total` | Tổng `amount` / `vat_amount` / `total` của các dòng | Số lẻ 0,5 làm tròn lên. Hạ Tầng không so tổng hóa đơn với `amount` của payment: bạn cần tự để hai số này bằng nhau. **Khi giá đã gồm VAT.** Cửa hàng thường biết số tiền người mua trả (đã gồm VAT) rồi tính ngược ra giá trước thuế. Làm tròn giá đó rồi làm tròn tiền thuế có thể lệch một đồng. Ví dụ 50.000 đồng, thuế 10%: | | `unit_price` | `vat_amount` | `total` | |---|---|---|---| | Hạ Tầng tự tính VAT | 45.455 | 4.546 (45.455 × 10%, làm tròn) | **50.001** | | Gửi kèm `vat_amount` | 45.455 | 4.545 | **50.000** | Vì vậy, với mỗi dòng, gửi `vat_amount` bằng phần còn lại của số tiền người mua trả sau khi đã làm tròn giá trước thuế: ```ts // gross: số tiền người mua trả cho cả dòng, đã gồm VAT, tính bằng đồng. function lineFromGross(name: string, quantity: number, gross: number, vatRate: 10 | 8 | 5) { const unit_price = Math.round(gross / quantity / (1 + vatRate / 100)); const amount = Math.round(quantity * unit_price); return { name, quantity, unit_price, vat_rate: vatRate, vat_amount: gross - amount }; } lineFromGross("Coffee 1 kg", 1, 50_000, 10); // { name: "Coffee 1 kg", quantity: 1, unit_price: 45455, vat_rate: 10, vat_amount: 4545 } ``` Quy tắc của `vat_amount`: - Số đồng chẵn, từ 0. `4545.5` bị từ chối với `422 validation_error`; Hạ Tầng không tự làm tròn. - Chỉ dùng cho dòng có `vat_rate` lớn hơn 0; dòng `0`, `-1` hoặc `-2` gửi kèm sẽ bị từ chối với `422 validation_error`. - Hạ Tầng không đối chiếu với `vat_rate`; chỉ nên lệch đúng phần làm tròn bạn cần. - Số này được gửi sang nhà cung cấp làm tiền thuế của dòng và được giữ nguyên khi đọc hóa đơn về, nên đồng bộ sau đó hay sửa trên trang nhà cung cấp đều không biến nó thành xung đột. - Trên dashboard, ô **Tiền thuế VAT** trong **Hoá đơn mới** nhận cùng giá trị, viết `4545`, `4.545` hay `4,545` đều được. ### Object hóa đơn {#einvoice-object} Là phản hồi của `POST /einvoices` và `GET /einvoices/{id}`, và là `data` của mọi webhook `invoice.*`. Hóa đơn từ yêu cầu ở trên (chỉ dòng đầu), đã thanh toán và phát hành: ```json { "object": "einvoice", "id": "einv_01a178887ba6771caef28863e6f55879", "environment": "production", "connection_id": "icn_01a178887b357a9c8bc4ad098f6b8855", "provider": "matbao", "payment_id": "pay_01a178887b59783cb5f4f18f06071ed1", "transaction_id": null, "origin": "hatang", "mode": "publish", "status": "issued", "sync_state": "in_sync", "version": 1, "buyer": { "name": "Nguyen Van Khach", "legal_name": "Cong ty TNHH Khach Hang", "tax_code": "0109876543", "address": "1 Trang Tien, Ha Noi", "email": "accounting@example.com" }, "items": [ { "name": "Coffee 1 kg", "unit": "kg", "quantity": 1, "unit_price": 45455, "vat_rate": 10, "amount": 45455, "vat_amount": 4545, "total": 50000 } ], "totals": { "subtotal": 45455, "vat": 4545, "total": 50000 }, "currency": "VND", "payment_method": "CK", "note": null, "template": { "pattern": "1", "serial": "C26TAT" }, "reference": "HT45MSKX4UY3AA", "provider_invoice_id": "4/AUx/syzogcoBsIReMUk6…", "number": "8001", "series": "1C26TAT", "lookup_code": "HT45MSKX4UY3AA", "tax_authority_code": null, "pdf_url": "https://…", "issued_at": "2026-10-25T17:00:00.000Z", "related_invoice_id": null, "relation": null, "conflict": null, "error": null, "failed_step": null, "created_at": "2026-10-26T14:06:12.895Z", "updated_at": "2026-10-26T14:06:13.004Z" } ``` | Trường | Kiểu | Có thể `null` | Ý nghĩa | |---|---|---|---| | `object` | chuỗi | Không | Luôn là `einvoice` | | `id` | chuỗi | Không | `einv_…` | | `environment` | `development` \| `production` | Không | Môi trường của API key đã tạo hóa đơn | | `connection_id` | chuỗi | Không | Kết nối nhà cung cấp phát hành hóa đơn (`icn_…`) | | `provider` | chuỗi | Có | Nhà cung cấp, ví dụ `matbao` | | `payment_id` | chuỗi | Có | Payment của hóa đơn | | `transaction_id` | chuỗi | Có | Giao dịch ngân hàng của hóa đơn, khi không có payment | | `origin` | `hatang` \| `provider` | Không | `hatang` khi tạo qua Hạ Tầng; `provider` khi được đưa về từ trang nhà cung cấp (`import_external`) | | `mode` | `draft` \| `publish` | Không | Như đã yêu cầu | | `status` | chuỗi | Không | Trạng thái hiện tại: xem [Sau khi thanh toán](#after-payment) | | `sync_state` | `in_sync` \| `pending_push` \| `conflict` | Không | `pending_push` khi một thay đổi đang được gửi sang nhà cung cấp; `conflict` khi bản nháp bị sửa ở cả hai phía | | `version` | số nguyên | Không | Tăng một sau mỗi thay đổi qua Hạ Tầng | | `buyer` | [người mua](#buyer) | Không | Như đã gửi; chỉ có các trường bạn đã gửi | | `items` | mảng [dòng hàng](#line-item) | Không | Như đã gửi, mỗi dòng kèm `amount`, `vat_amount` và `total` đã tính | | `totals` | object `{ subtotal, vat, total }` | Không | Tính bằng đồng | | `currency` | chuỗi | Không | `VND` | | `payment_method`, `note` | chuỗi | Có | Như đã gửi | | `template` | object `{ pattern, serial }` | Không | Mẫu số và ký hiệu dùng để phát hành | | `reference` | chuỗi, tối đa 20 | Không | Mã tham chiếu Hạ Tầng dùng ở nhà cung cấp | | `provider_invoice_id` | chuỗi | Có | Id của chính nhà cung cấp, khi bản nháp đã có | | `number` | chuỗi | Có | Số hóa đơn, khi đã `issued` | | `series` | chuỗi | Có | Mẫu số và ký hiệu ghép lại, ví dụ `1C26TAT` | | `lookup_code` | chuỗi | Có | Mã người mua nhập ở trang tra cứu của nhà cung cấp | | `tax_authority_code` | chuỗi | Có | Mã của cơ quan thuế, khi đã `tax_accepted` | | `pdf_url` | URL | Có | File PDF của nhà cung cấp, khi đã có | | `issued_at` | chuỗi ISO 8601 | Có | Ngày ghi trên hóa đơn | | `related_invoice_id` | chuỗi | Có | Ở hóa đơn thay thế: hóa đơn bị thay | | `relation` | `replaces` \| `adjusts` | Có | Ở hóa đơn thay thế (`replaces`) hoặc điều chỉnh (`adjusts`) | | `conflict` | object | Có | Khi `sync_state: "conflict"`: bản của nhà cung cấp, nằm trong `content` | | `error` | chuỗi | Có | Lý do lỗi | | `failed_step` | `create_draft` \| `update_draft` \| `publish` \| `delete_draft` | Có | Bước bị lỗi | | `created_at`, `updated_at` | chuỗi ISO 8601 | Không | | Schema OpenAPI: [`GET /einvoices/{id}`](https://hatang.dev/vi/docs/reference/api/get-einvoices-by-id) ### Tóm tắt hóa đơn trên payment {#payment-invoice} Object payment giữ một bản tóm tắt hóa đơn của nó trong `invoice`, để handler IPN thấy hóa đơn mà không phải gọi thêm. Trường này là `null` khi không yêu cầu xuất hóa đơn và cũng chưa có hóa đơn nào. | Trường | Kiểu | Có thể `null` | Ý nghĩa | |---|---|---|---| | `requested` | boolean | Không | `true` khi payment được tạo kèm khối `invoice` | | `mode` | `draft` \| `publish` | Có | Như đã yêu cầu | | `status` | chuỗi | Không | Như trên hóa đơn, thêm `waiting_for_payment` khi payment chưa được trả. `failed` với `id: null` khi đã trả mà chưa kết nối nhà cung cấp | | `id` | chuỗi | Có | Id `einv_…` của hóa đơn, `null` cho tới khi hóa đơn được tạo | | `number`, `series`, `template`, `lookup_code`, `tax_authority_code`, `pdf_url`, `sync_state`, `issued_at`, `error`, `failed_step` | như trên hóa đơn | Có | Như trên [hóa đơn](#einvoice-object); `null` cho tới khi có hóa đơn | Schema OpenAPI: [`GET /payments/{id}`](https://hatang.dev/vi/docs/reference/api/get-payments-by-id) Để đọc các dòng hàng và tổng tiền, lấy hóa đơn: `GET /einvoices/{invoice.id}`. ## Lỗi | Lỗi | Khi nào | |---|---| | `422 validation_error` | Trường sai (người mua không có tên, mã số thuế sai, quá 200 dòng), hoặc mẫu chưa đăng ký trên tài khoản | | `422 missing_required_field` | Thiếu thông tin đăng nhập của nhà cung cấp | | `422 invoice_provider_error` | Nhà cung cấp từ chối request; thông báo nêu lý do | | `409 invalid_state` | Chưa kết nối nhà cung cấp, payment đã có hóa đơn, hóa đơn không ở trạng thái cho phép lời gọi này, hoặc cần giải quyết xung đột trước | | `403 environment_mismatch` | Nhà cung cấp không có ở môi trường này | | `503 provider_unavailable` | Nhà cung cấp không phản hồi; thử lại | ## Bước tiếp theo - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): các lời gọi IPN mang thông tin hóa đơn. - [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md): mọi sự kiện `invoice.*`. - [Nhận giao dịch không cần tạo đơn](https://hatang.dev/vi/docs/guides/payments/transactions.md). Source: https://hatang.dev/vi/docs/guides/payments/einvoices --- # Môi trường và sandbox Thanh toán là sản phẩm duy nhất của Hạ Tầng có môi trường. Mỗi doanh nghiệp có môi trường `development` và `production`, và dữ liệu thanh toán của hai bên **tách biệt hoàn toàn**: tài khoản ngân hàng, payment, giao dịch, nhà cung cấp hóa đơn, hóa đơn và khoá tích hợp. API key và webhook endpoint dùng chung. Trang này giải thích cách chọn môi trường và cách test trọn luồng mà không cần tiền thật. ## Khác nhau ở đâu | | `development` | `production` | |---|---|---| | Ngân hàng | Ngân hàng giả lập (`simulator`): mã xác nhận `000000`, chuyển khoản giả lập bằng API | Tài khoản ngân hàng thật của bạn | | Hóa đơn điện tử | Sandbox của nhà cung cấp (Mắt Bão demo); không gửi cơ quan thuế | Hóa đơn thật theo mã số thuế của bạn | | Giả lập chuyển khoản | `POST /payments/simulate/transfer`, cùng nút trên trang checkout và trang payment trên dashboard | Không có (`403 environment_mismatch`) | | Khoá tích hợp | `ak_test_…` / `sk_test_…` | `ak_…` / `sk_…` | | Chọn bằng | `Hatang-Environment: development` | Không gửi header (mặc định), hoặc `Hatang-Environment: production` | ## Chọn môi trường cho từng request {#choose-the-environment} Gửi header `Hatang-Environment` trong mỗi request: | Header | Kết quả | |---|---| | `Hatang-Environment: development` | Request đọc và ghi dữ liệu development | | `Hatang-Environment: production` hoặc không gửi | Production | | Giá trị khác | `422 validation_error`: "Hatang-Environment must be development or production" | Mọi phản hồi lặp lại môi trường đã dùng trong header `Hatang-Environment`, và mọi object thanh toán (payment, giao dịch, kết nối ngân hàng, hóa đơn) có trường `environment`. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" ``` **Node.js** ```ts import { Hatang } from "@hatang/sdk"; // Tùy chọn này được gửi dưới dạng header Hatang-Environment trong mọi request. // Không có tùy chọn, SDK đọc biến môi trường HATANG_ENVIRONMENT. const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", environment: "development", }); const { data } = await hatang.payments.list(); ``` **Python** ```python import os from hatang import Hatang # environment= gửi header Hatang-Environment trong mọi lần gọi dev = Hatang(os.environ["HATANG_API_KEY"], environment="development") payments = dev.payments.list() # chỉ thanh toán của môi trường development ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->get('/payments', ['headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Hatang-Environment' => 'development', ]]); echo $res->getHeaderLine('Hatang-Environment'); // development ``` > **Tip** > > Đọc môi trường từ cấu hình (`HATANG_ENVIRONMENT=development` trên server staging, không đặt gì ở production) thay vì viết cứng trong code, để cùng một code chạy được ở cả hai nơi. > **Note: Key development cũ** > > Key bắt đầu bằng `ht_test_` gắn với development: không gửi header thì dùng development, còn yêu cầu production sẽ trả về `403 environment_mismatch`. Id của môi trường này không tồn tại ở môi trường kia: đọc một payment development khi đang chọn production sẽ trả về `404 not_found`. ## Trên dashboard Thanh trên cùng của mọi trang Thanh toán hiển thị môi trường hiện tại và cho phép chuyển. Production có màu đỏ, và chuyển sang production sẽ hỏi xác nhận. Các sản phẩm khác không có nút chuyển này. ## Ngân hàng giả lập Liên kết bằng `{ "provider": "simulator" }` và xác nhận bằng mã `000000` (xem [Bắt đầu nhanh](https://hatang.dev/vi/docs/guides/payments/quickstart.md)). Mặc định, mã QR ghi ngân hàng MB và số tài khoản là số tự sinh (bắt đầu bằng `SIM`), nên không ngân hàng thật nào nhận ra. Để có VietQR thật cho ngân hàng của bạn, truyền `bank_code` và số tài khoản: ```json { "provider": "simulator", "bank_code": "BIDV", "account_number": "96247001234", "account_name": "NGUYEN VAN A" } ``` `bank_code` nhận các mã ngắn quen thuộc: `MB` (mặc định), `BIDV`, `ACB`, `VCB`, `ICB`, `TCB`, `VPB`, `TPB`, `STB` và các ngân hàng VietQR khác. Với số tài khoản thật, app ngân hàng của người mua sẽ chuyển tiền thật cho bạn. Nhưng development không tích hợp ngân hàng nào, nên Hạ Tầng không thấy tiền về: payment chỉ chuyển trạng thái khi bạn giả lập chuyển khoản hoặc đánh dấu đã thanh toán. ## Giả lập chuyển khoản {#simulate-a-transfer} **cURL** ```bash # Trả đủ một payment curl https://api.hatang.dev/payments/simulate/transfer \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_123" }' # Trả thiếu curl https://api.hatang.dev/payments/simulate/transfer \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_123", "amount": 50000 }' ``` **Node.js** ```ts await hatang.payments.simulateTransfer({ payment_id: "pay_123" }); // trả đủ await hatang.payments.simulateTransfer({ payment_id: "pay_123", amount: 50_000 }); // trả thiếu // Giao dịch không gắn đơn, cho luồng chỉ nhận giao dịch: await hatang.payments.simulateTransfer({ connection_id: "bcn_123", amount: 99_000, content: "DH20250919 chuyen tien" }); ``` **Python** ```python HEADERS = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}", "Hatang-Environment": "development"} requests.post("https://api.hatang.dev/payments/simulate/transfer", headers=HEADERS, json={"payment_id": "pay_123", "amount": 50000}, timeout=10).raise_for_status() ``` **PHP** ```php $client->post('/payments/simulate/transfer', [ 'headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Hatang-Environment' => 'development', ], 'json' => ['payment_id' => 'pay_123', 'amount' => 50000], ]); ``` | Trường | Ý nghĩa | |---|---| | `payment_id` | Payment cần trả. Tự điền tài khoản ngân hàng, số tiền còn thiếu và mã | | `connection_id` | Tài khoản ngân hàng, cho giao dịch không gắn payment. Cần `payment_id` hoặc `connection_id` | | `amount` | Số tiền khác, để test trả thiếu và trả thừa. Mặc định là số tiền còn thiếu | | `content` | Nội dung chuyển khoản như ngân hàng báo. Mặc định `CHUYEN TIEN ` | | `direction` | `in` (mặc định) hoặc `out` | | `reference` | Mã tham chiếu ngân hàng. Dùng lại một mã để giả lập ngân hàng đẩy cùng giao dịch hai lần | | `counterparty_name` | Tên người chuyển. Mặc định `NGUYEN VAN KHACH` | Lời gọi trả về `202`: ```json { "object": "simulated_transfer", "reference": "SIM1758274452930A1B2C3", "amount": 50000, "direction": "in", "description": "CHUYEN TIEN HTBF7RDGVQ", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "duplicate": false } ```
Trang checkout ở môi trường development có nút giả lập
Ở development, trang checkout có thêm nút giả lập chuyển khoản
Giao dịch giả lập đi qua **đúng quy trình** như giao dịch từ ngân hàng thật: khớp mã, sự kiện, IPN và webhook. Handler của bạn nhận đúng những gì sẽ nhận ở production. ### Lỗi | Lỗi | Khi nào | |---|---| | `403 environment_mismatch` | Request ở production | | `422 validation_error` | Không có `payment_id` lẫn `connection_id`, hoặc tài khoản không phải tài khoản ngân hàng giả lập | | `409 invalid_state` | Tài khoản ngân hàng không ở trạng thái `active` | | `422 missing_required_field` | Không có `amount` và payment không còn thiếu tiền | ## Kịch bản test | Kịch bản | Cách làm | |---|---| | Trả đủ | Giả lập chỉ với `payment_id` | | Trả thiếu rồi trả nốt | Giả lập `amount` nhỏ hơn tổng, rồi giả lập lần nữa không có `amount` | | Trả thừa | Giả lập `amount` lớn hơn tổng | | Hết hạn | Tạo với `expires_in: 60`, đợi vài phút, kiểm tra `payment.expired` | | Tiền về sau khi hết hạn | Giả lập trên payment đã hết hạn: payment chuyển sang `paid` | | Người mua quên ghi mã | Giả lập với `connection_id` và `content` không chứa mã | | Ngân hàng đẩy trùng | Giả lập hai lần cùng `reference` và `amount`: lần hai trả về `duplicate: true` và không thay đổi gì | | Endpoint của bạn bị lỗi | Đặt `ipn_url` trỏ tới URL trả `500`, rồi xem `GET /payments/{id}/ipn-deliveries` | > **Danger** > > Handler của bạn phải kiểm tra `environment`. Một payment `development` không bao giờ được mở khóa đơn hàng thật. ## Bước tiếp theo - [Liên kết tài khoản ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md) ở production. - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md). - [Lên production](https://hatang.dev/vi/docs/guides/payments/go-live.md): chuyển sang production từng bước. Source: https://hatang.dev/vi/docs/guides/payments/environments --- # Lên production Khi tích hợp đã chạy trọn luồng ở `development`, chỉ còn vài bước để chuyển sang production: liên kết tài khoản ngân hàng thật, kết nối tài khoản hóa đơn thật, đổi key và môi trường, đảm bảo handler chặt chẽ, rồi test bằng một giao dịch thật nhỏ. Trang này đi qua từng bước và kết thúc bằng những gì cần theo dõi khi đã chạy thật. ## Trước khi bắt đầu Hãy chắc chắn toàn bộ luồng chạy được ở development: - Payment tạo từ trang thanh toán của bạn chuyển sang `paid` sau khi [giả lập chuyển khoản](https://hatang.dev/vi/docs/guides/payments/environments.md#simulate-a-transfer). - Handler IPN xác minh chữ ký, cập nhật đơn đúng một lần, và trả `2xx`. - Trả thiếu, trả thừa, hết hạn và tiền về muộn được xử lý đúng như bạn muốn (xem [kịch bản test](https://hatang.dev/vi/docs/guides/payments/environments.md)). - Nếu bạn phát hành hóa đơn, một hóa đơn sandbox đã tới `issued`. Không có gì được chuyển giữa hai môi trường: tài khoản ngân hàng, cấu hình khớp mã, payment, nhà cung cấp hóa đơn và khoá tích hợp đều phải có ở production. ## 1. Liên kết tài khoản ngân hàng thật Chuyển dashboard sang production (thanh môi trường trên cùng các trang Thanh toán, **Chuyển sang chính thức**), rồi mở **Cài đặt thanh toán → Tài khoản ngân hàng → Thêm tài khoản ngân hàng** và chọn ngân hàng: | Ngân hàng | Bạn cần | |---|---| | MB Bank | Số tài khoản, tên chủ tài khoản đúng như MB ghi nhận, và CCCD hoặc mã số thuế dùng khi mở tài khoản. MB nhắn mã tới số điện thoại đăng ký của chủ tài khoản | | BIDV | Đăng ký dịch vụ thông báo biến động số dư của BIDV cho tài khoản tại BIDV, rồi thêm tài khoản. Tài khoản kích hoạt khi BIDV gửi tin đầu tiên | | ACB | Số tài khoản. Sau đó chủ tài khoản đồng ý trên trang của ACB; sự đồng ý có hiệu lực tối đa 180 ngày | | Pay2S | Tài khoản đã liên kết trên Pay2S, một Hook trỏ tới URL hiển thị trong form, và token của Hook | Chi tiết ở [Liên kết ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md). Sau đó đặt lại **cấu hình khớp mã** cho tài khoản production: tiền tố mã thanh toán, định dạng mã đơn của bạn, và những giao dịch `transaction.received` sẽ báo. > **Caution** > > Người nhận mã của ngân hàng hoặc đồng ý trên trang ngân hàng phải là chủ tài khoản, hoặc người được họ ủy quyền. Hãy chọn thời điểm liên kết khi họ có mặt. ## 2. Kết nối nhà cung cấp hóa đơn điện tử Nếu bạn phát hành hóa đơn, mở **Cài đặt thanh toán → Nhà cung cấp hoá đơn** ở production và kết nối tài khoản Mắt Bão thật: mã số thuế, tài khoản và mật khẩu API trong mục "Cấu hình API" trên cổng Mắt Bão. Chọn mẫu số và ký hiệu đã đăng ký cho doanh nghiệp, và chọn điều gì xảy ra khi đơn đã thanh toán yêu cầu hóa đơn: dừng ở nháp, hay gửi ký luôn. Sau đó gửi **callback URL** của kết nối cho bộ phận hỗ trợ Mắt Bão, để thay đổi phía họ về Hạ Tầng ngay. Xem [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md). ## 3. Đổi key và môi trường | Cấu hình | Development | Production | |---|---|---| | Môi trường | `Hatang-Environment: development` hoặc SDK `environment: "development"` | Bỏ header và tùy chọn (hoặc gửi `production`) | | Secret key cho IPN | `sk_test_…` | `sk_…` tại **Cài đặt thanh toán → Khoá tích hợp** ở production | | Webhook secret | `whsec_…` | Cùng một endpoint phục vụ cả hai môi trường; giữ nguyên secret | | API key | `ht_…` có `payments:read`, `payments:write` | Dùng được cùng key; có thể tạo key riêng cho từng server | Giữ các giá trị này trong cấu hình, không viết vào code: ```bash HATANG_API_KEY=ht_123... HATANG_SECRET_KEY=sk_123... # secret key production, cho IPN HATANG_WEBHOOK_SECRET=whsec_123... # secret của webhook endpoint # Không đặt HATANG_ENVIRONMENT ở production ``` ## 4. Kiểm tra endpoint - `ipn_url` và webhook endpoint là **https** trên internet công khai, trả `2xx` trong 10 giây (15 giây với webhook), và không redirect. - Endpoint đọc **body gốc** để xác minh (không có middleware JSON chạy trước). - Webhook endpoint đã đăng ký các sự kiện bạn cần, ví dụ `payment.succeeded`, `payment.overpaid`, `transaction.received`, `bank_connection.revoked`, `invoice.failed`. - Tin vào chữ ký, không tin vào địa chỉ IP của bên gọi. ## 5. Siết chặt handler Handler của bạn phải: 1. Xác minh chữ ký bằng key **production**, và từ chối mọi lời gọi không hợp lệ. 2. Kiểm tra `environment` là `production`; payment development không bao giờ được mở khóa đơn thật. 3. So `paid_amount` với tổng tiền của đơn. 4. Chống trùng theo `id` của IPN hoặc `svix-id` của webhook. 5. Chỉ đưa đơn tiến lên, bất kể lời gọi đến theo thứ tự nào. Trước khi giao hàng cho người mua quay lại `return_url`, hãy xác nhận ở server bằng `GET /payments/{id}` hoặc `payments.verify()` của SDK. ## 6. Test bằng giao dịch thật nhỏ 1. Tạo payment ở production với số tiền nhỏ, ví dụ 10.000 VND, kèm `ipn_url` thật. 2. Trả bằng app ngân hàng của bạn bằng cách quét QR. 3. Kiểm tra payment chuyển sang `paid` (với ACB, chờ tới 10 phút vì Hạ Tầng đọc lịch sử theo chu kỳ đó), IPN đã tới (`GET /payments/{id}/ipn-deliveries`), và giao dịch có trong **Thanh toán → Giao dịch**. 4. Nếu đơn có yêu cầu hóa đơn, kiểm tra hóa đơn tới `issued` và có số. 5. Sau đó test tình huống sai: trả một payment nhỏ khác mà không ghi mã, và kiểm tra `transaction.received` đến với `payment_id: null`. Tiền về chính tài khoản của bạn; không cần hoàn tiền qua Hạ Tầng. ## 7. Theo dõi khi đã chạy thật | Cần theo dõi | Ở đâu | |---|---| | Lời gọi IPN lỗi liên tục | `GET /payments/{id}/ipn-deliveries` (`status: "exhausted"`), và trang payment | | Webhook gửi lỗi | `GET /webhooks/{id}/deliveries`, và **Webhooks** trên dashboard | | Tài khoản ngân hàng ngừng hoạt động | Webhook `bank_connection.revoked` và `bank_connection.needs_reauth`; trạng thái và giao dịch gần nhất của tài khoản tại **Tài khoản ngân hàng** | | Sự đồng ý của ACB sắp hết hạn | `consent.expires_at` của kết nối; liên kết lại trước thời điểm đó | | Lệnh gọi tới ngân hàng hoặc nhà cung cấp hóa đơn bị kẹt | **Thanh toán → Tình trạng tích hợp** | | Hóa đơn lỗi hoặc bị từ chối | Webhook `invoice.failed`, `invoice.tax_rejected`, và email cảnh báo Hạ Tầng gửi cho chủ sở hữu và quản trị viên | | Tiền vào chưa có hóa đơn | **Thanh toán → Đối soát hoá đơn**, và email hằng ngày lúc 08:00 | | Giao dịch ngân hàng không báo về | [Nhập sao kê](https://hatang.dev/vi/docs/guides/payments/bank-connections.md#statement-import) cho những ngày bị thiếu | ## Danh sách kiểm tra - [ ] Tài khoản ngân hàng thật đã liên kết và `active` ở production. - [ ] Đã đặt cấu hình khớp mã cho tài khoản production. - [ ] Đã kết nối nhà cung cấp hóa đơn ở production với đúng mẫu, và Mắt Bão đã có callback URL. - [ ] Đã cấu hình **secret key production** và webhook secret trên server. - [ ] Đã bỏ header `Hatang-Environment: development` (hoặc tùy chọn của SDK) ở production. - [ ] Handler kiểm tra chữ ký, `environment`, số tiền, chống trùng, và chỉ đưa đơn tiến lên. - [ ] URL IPN và webhook là `https` công khai, trả `2xx` trong 10 giây. - [ ] Một giao dịch thật nhỏ đã chạy trọn luồng. - [ ] Có người theo dõi lần gửi lỗi, sự kiện `bank_connection.*` và cảnh báo hóa đơn. ## Bước tiếp theo - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md). - [Liên kết ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md). - [Lỗi](https://hatang.dev/vi/docs/reference/errors.md) và [Giới hạn](https://hatang.dev/vi/docs/reference/limits.md). Source: https://hatang.dev/vi/docs/guides/payments/go-live --- # Tổng quan **Hạ Tầng Thanh toán** nhận chuyển khoản VietQR thẳng vào **tài khoản ngân hàng của chính bạn**, báo cho server của bạn ngay khi tiền về, và có thể phát hành hóa đơn điện tử cho đơn hàng. Hạ Tầng không giữ tiền. Người mua chuyển vào tài khoản của bạn; Hạ Tầng đọc biến động số dư của tài khoản và khớp với đơn hàng. Tiền không nằm trong ví chờ rút về, và không có tài khoản trung gian nào ở giữa. ## Dành cho ai - **Website bán hàng:** mã QR riêng cho từng đơn, tự xác nhận khi khách chuyển khoản. - **Cửa hàng, quầy thu ngân:** một mã QR in sẵn, và thông báo cho mọi giao dịch có mã đơn của bạn. - **SaaS, dịch vụ thuê bao:** thu tiền gói và tự phát hành hóa đơn điện tử khi đã thanh toán. - **Nền tảng, phần mềm kế toán:** đồng bộ giao dịch ngân hàng và hóa đơn qua API và webhook. ## Người mua thấy gì
Trang checkout Hạ Tầng đang chờ thanh toán
Trang checkout: VietQR, thông tin chuyển khoản, đếm ngược
Trang checkout Hạ Tầng sau khi thanh toán thành công
Tự cập nhật khi tiền về, kèm số hóa đơn điện tử
## Luồng hoạt động ```mermaid sequenceDiagram autonumber participant S as Server của bạn participant H as Hạ Tầng participant P as Người mua participant B as Ngân hàng của bạn participant M as Mắt Bão (HĐĐT) S->>H: POST /payments H-->>S: payment (code, qr, checkout_url) S->>P: Chuyển tới checkout_url hoặc hiển thị QR P->>B: Chuyển khoản (NAPAS 247) B->>H: Biến động số dư H->>H: Khớp mã thanh toán H->>S: IPN / webhook payment.succeeded opt Có yêu cầu hóa đơn H->>M: Tạo nháp, gửi ký M-->>H: Số hóa đơn, mã tra cứu H->>S: IPN / webhook invoice.issued end ``` 1. Bạn liên kết tài khoản ngân hàng một lần (dashboard → **Cài đặt thanh toán → Tài khoản ngân hàng**). 2. Bạn tạo một **payment** cho mỗi đơn, hoặc chỉ cần đưa số tài khoản và để Hạ Tầng báo mọi giao dịch. 3. Người mua quét VietQR bằng app ngân hàng bất kỳ tại Việt Nam, hoặc chuyển khoản thủ công với **mã thanh toán** trong nội dung. 4. Ngân hàng báo cho Hạ Tầng. Hạ Tầng khớp mã với payment và gọi server của bạn: **IPN** tới URL bạn khai báo cho payment đó, và **webhook** cho toàn doanh nghiệp. 5. Nếu bạn yêu cầu, Hạ Tầng tạo hóa đơn tại nhà cung cấp của bạn, gửi ký, rồi trả về số hóa đơn và mã tra cứu. ## Ngân hàng Mỗi ngân hàng liên kết theo cách riêng. Những ngân hàng bạn chọn được ở mỗi môi trường được liệt kê tại **Cài đặt thanh toán → Tài khoản ngân hàng → Thêm tài khoản ngân hàng**. | Ngân hàng | `provider` | Cách liên kết | Hạ Tầng biết tiền về bằng cách | Môi trường | |---|---|---|---|---| | MB Bank | `mbbank` | MB nhắn mã tới chủ tài khoản | Thông báo biến động số dư theo thời gian thực | `production` | | BIDV | `bidv` | Bạn đăng ký dịch vụ thông báo biến động số dư tại BIDV; tài khoản kích hoạt khi BIDV gửi tin đầu tiên | Thông báo biến động số dư theo thời gian thực | `production` | | ACB | `acb` | Chủ tài khoản đồng ý trên trang của ACB (Open API theo Thông tư 64/2024/TT-NHNN) | Đọc lịch sử tài khoản mỗi 10 phút | `production` | | Pay2S | `pay2s` | Bạn tạo Hook trên Pay2S và dán token của nó; tài khoản kích hoạt khi có giao dịch đầu tiên | Pay2S đẩy về theo thời gian thực | `production` | | Ngân hàng giả lập | `simulator` | Mã `000000` | Giả lập bằng API | `development` | Pay2S đọc được tài khoản tại Vietcombank, VietinBank, Techcombank, BIDV, ACB, MB Bank và TPBank. Với bất kỳ ngân hàng nào, bạn cũng có thể khôi phục giao dịch bằng cách [nhập sao kê](https://hatang.dev/vi/docs/guides/payments/bank-connections.md#statement-import). ## Nhà cung cấp hóa đơn điện tử | Nhà cung cấp | `development` | `production` | |---|---|---| | Mắt Bão | Sandbox demo của nhà cung cấp, không gửi cơ quan thuế | Hóa đơn thật theo mã số thuế của bạn | ## Ba cách tích hợp | | Chỉ nhận giao dịch | Đơn hàng | Đơn hàng + hóa đơn điện tử | |---|---|---|---| | Tạo gì cho mỗi đơn | Không | `POST /payments` | `POST /payments` kèm `invoice` | | Người mua ghi gì vào nội dung | Mã đơn của bạn (vd `DH1001`) | Mã thanh toán (vd `HTBF7RDGVQ`, hoặc `code` của bạn) | Như bên trái | | Hạ Tầng báo gì | `transaction.received` kèm mã tìm được | Đã trả, trả thiếu, trả thừa, hết hạn | Như bên trái, kèm số hóa đơn | | Trang checkout và hạn thanh toán | Không | Có | Có | | Hóa đơn điện tử | Chỉ khi bạn tự tạo (`POST /einvoices` kèm `transaction_id`) | Không | Tự tạo khi đã thanh toán | | Hướng dẫn | [Nhận giao dịch](https://hatang.dev/vi/docs/guides/payments/transactions.md) | [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md) | [Hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md) | Có thể dùng lẫn các cách trên cùng một tài khoản ngân hàng. Giao dịch luôn được khớp với payment trước, nên một payment không bao giờ bị báo hai lần. ## Bạn có gì - **Tiền về thẳng tài khoản của bạn**, không qua ví, không chờ rút. - **Xác nhận trong vài giây** với ngân hàng đẩy biến động số dư. - **Tự động khớp** mã thanh toán, xử lý cả trả thiếu, trả thừa, hết hạn và tiền về muộn. - **Tự phát hành hóa đơn điện tử** khi đơn đã thanh toán, kèm báo cáo đối soát tiền vào chưa có hóa đơn. - **Trang checkout có sẵn**, cập nhật trực tiếp, không cần đăng nhập. - **Hai môi trường tách biệt**, có ngân hàng giả lập để test trọn luồng. - **Lời gọi có chữ ký:** HMAC-SHA256 cho IPN, chữ ký Svix cho webhook, mỗi loại gửi tối đa 8 lần. - **SDK Node.js** có các hàm xác minh chạy được cả trên Bun, Deno, Vercel và Cloudflare Workers. ## Bước tiếp theo - [Bắt đầu nhanh](https://hatang.dev/vi/docs/guides/payments/quickstart.md): nhận khoản thanh toán đầu tiên ở môi trường development trong khoảng mười phút. - [Môi trường và sandbox](https://hatang.dev/vi/docs/guides/payments/environments.md): development và production khác nhau thế nào. - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md): mọi trường, trạng thái và tình huống đặc biệt. - [Tham chiếu REST API](https://hatang.dev/vi/docs/reference/api.md): toàn bộ endpoint. Source: https://hatang.dev/vi/docs/guides/payments/overview --- # Nhận kết quả Hạ Tầng báo cho server của bạn về payment theo ba cách: lời gọi **IPN** tới `ipn_url` của từng payment, **webhook** cho mọi thứ trong doanh nghiệp, và **polling** API bất cứ lúc nào. Trang này so sánh ba cách, cho thấy chính xác một lời gọi IPN trông thế nào, và liệt kê những gì handler của bạn phải kiểm tra. ## Chọn kênh | | IPN | Webhook | Polling | |---|---|---|---| | Phạm vi | Một payment (`ipn_url` khai báo khi tạo) | Mọi thứ trong doanh nghiệp, cả hai môi trường | Bất kỳ thứ gì, bất cứ lúc nào | | Gửi khi | Mọi thay đổi `payment.*` trừ `created`, và mọi thay đổi `invoice.*` của payment đó | Các loại sự kiện endpoint đăng ký | – | | Body | `{ id, event_id, type, environment, created_at, data: }` | `{ type, created_at, data }` | Object | | Chữ ký | `Hatang-Signature`: HMAC-SHA256 của body với **secret key** | Svix: `svix-id`, `svix-timestamp`, `svix-signature` | API key | | Thử lại | 1 phút, 5 phút, 15 phút, 1 giờ, 3 giờ, 6 giờ, 12 giờ (8 lần) | 5 giây, 5 phút, 30 phút, 2 giờ, 5 giờ, 10 giờ, 10 giờ (8 lần) | – | | Timeout | 10 giây | 15 giây | – | | Nhật ký gửi | `GET /payments/{id}/ipn-deliveries` | `GET /webhooks/{id}/deliveries` | – | Phần lớn cửa hàng dùng IPN cho đơn hàng, vì IPN mang theo toàn bộ payment và chỉ cần khai báo `ipn_url`. Dùng webhook cho giao dịch không qua đơn (`transaction.received`), sự kiện tài khoản ngân hàng, và mọi thứ bạn muốn nhận cho cả doanh nghiệp. Polling là phương án dự phòng. ## Yêu cầu với endpoint - URL IPN và webhook phải là **https** trên internet công khai. Các địa chỉ như `localhost`, `10.x`, `192.168.x` hay `169.254.169.254` bị từ chối, cả khi lưu URL lẫn khi Hạ Tầng kết nối. - Lời gọi được tính là đã gửi khi endpoint trả **2xx**. Redirect không được theo, nên `301` hay `302` đều tính là lỗi. - Trả lời nhanh và xử lý sau (qua queue). Trả lời chậm bị tính là lỗi và lời gọi sẽ được gửi lại. ## Handler của bạn phải 1. **Xác minh chữ ký trên body gốc**, trước khi parse JSON ([IPN](https://hatang.dev/vi/docs/guides/payments/verify-ipn.md), [webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md)). 2. **Chống trùng.** `id` (và `event_id`) của IPN và `svix-id` của webhook giữ nguyên qua mọi lần thử lại. 3. **Tin trạng thái, không tin thứ tự đến.** Lời gọi có thể đến sai thứ tự hoặc hai lần. Đọc `data.status` (hoặc gọi `GET /payments/{id}`), và chỉ đưa đơn hàng tiến lên. 4. **Kiểm tra số tiền và môi trường.** So `data.paid_amount` với đơn, và kiểm tra `environment`: payment development không bao giờ được hoàn tất đơn thật. ```ts // Chỉ đưa đơn tiến lên; bỏ qua mọi lời gọi khiến đơn lùi lại. const RANK = { pending: 0, expired: 1, partially_paid: 2, paid: 3, overpaid: 3, canceled: 0 } as const; async function applyIpn(ipn: IpnEvent) { if (ipn.environment !== process.env.EXPECTED_ENVIRONMENT) return; const order = await db.orders.find(ipn.data.metadata.order_id); if (!order || RANK[ipn.data.status] <= RANK[order.paymentStatus]) return; if ((ipn.data.status === "paid" || ipn.data.status === "overpaid") && ipn.data.paid_amount >= order.total) { await db.orders.markPaid(order.id, ipn.data.id); } else { await db.orders.setPaymentStatus(order.id, ipn.data.status); } } ``` ## Một lời gọi IPN Đây là những gì endpoint của bạn nhận được: ```http POST /hatang/ipn HTTP/1.1 Content-Type: application/json User-Agent: Hatang-IPN/1.0 Hatang-Access-Key: ak_Example0AccessKey0000000 Hatang-Signature: 8f0c3b1e5d7a9c2f4e6b8a0d1c3e5f7a9b2d4f6e8a0c2e4f6b8d0a2c4e6f8b0d Hatang-Signature-Algorithm: HMAC-SHA256 Hatang-Event: payment.succeeded Hatang-Delivery-Id: ipn_01a0b90429d87cf89e37fc2f66040ec5 Hatang-Environment: production {"id":"ipn_01a0b90429d87cf89e37fc2f66040ec5","event_id":"de_01a0b90429b57b41a2ef3510d6bf8036","type":"payment.succeeded","environment":"production","created_at":"2026-09-19T09:34:12.952Z","data":{"object":"payment","id":"pay_01a0b904297873e8b3524a26fdca0ab5","status":"paid","amount":150000,"paid_amount":150000,"code":"HTBF7RDGVQ","metadata":{"order_id":"1001"},"invoice":null, …}} ``` | Header | Ý nghĩa | |---|---| | `Hatang-Access-Key` | Lời gọi thuộc tài khoản và môi trường nào (`ak_…`, hoặc `ak_test_…` ở development) | | `Hatang-Signature` | HMAC-SHA256 dạng hex thường của body gốc | | `Hatang-Signature-Algorithm` | Luôn là `HMAC-SHA256` | | `Hatang-Event` | Loại sự kiện, giống `type` trong body | | `Hatang-Delivery-Id` | Giống `id` trong body; dùng để chống trùng | | `Hatang-Environment` | `development` hoặc `production` | | Trường trong body | Ý nghĩa | |---|---| | `id` | Mã lần gửi (`ipn_…`), giữ nguyên qua mọi lần thử lại | | `event_id` | Sự kiện gây ra lời gọi | | `type` | `payment.succeeded`, `payment.partially_paid`, `payment.overpaid`, `payment.expired`, `payment.canceled`, hoặc một sự kiện `invoice.*` của payment này | | `environment` | `development` hoặc `production` | | `created_at` | Thời điểm tạo lời gọi | | `data` | Toàn bộ [object payment](https://hatang.dev/vi/docs/guides/payments/create-payment.md#response), gồm cả tóm tắt `invoice` | `data` là payment tại thời điểm tạo lời gọi. Với sự kiện `invoice.*`, `data` vẫn là payment, với trạng thái mới nằm trong `data.invoice`. ## Thử lại và nhật ký gửi Lời gọi không nhận được `2xx` trong 10 giây sẽ được thử lại sau 1 phút, 5 phút, 15 phút, 1 giờ, 3 giờ, 6 giờ và 12 giờ: 8 lần trong khoảng một ngày. Sau lần cuối, lần gửi chuyển sang `exhausted`. Các lần thử lại được ký bằng secret key hiện hành tại thời điểm gửi. `GET /payments/{id}/ipn-deliveries` liệt kê 100 lời gọi gần nhất của một payment, mới nhất trước, kèm `status` (`pending`, `success`, `failed`, `exhausted`), `attempt`, `response_status`, `response_ms`, `last_error` và `next_attempt_at`. Trang payment trên dashboard hiển thị cùng danh sách. Nếu endpoint của bạn bị lỗi lâu hơn thời gian thử lại, hãy polling các payment bị ảnh hưởng. ## Polling API Đọc payment khi bạn cần trạng thái hiện tại, ví dụ khi người mua quay lại `return_url`, hoặc trong job định kỳ rà soát đơn chưa thanh toán. **cURL** ```bash curl https://api.hatang.dev/payments/pay_123 \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data: check } = await hatang.payments.verify("pay_123", { order_id: "1001", amount: 150_000, environment: "production" }); if (check?.verified) await fulfil("1001"); ``` **Python** ```python p = hatang.payments.get("pay_123") if p["status"] in ("paid", "overpaid") and p["paid_amount"] >= 150000 and p["environment"] == "production": fulfil("1001") ``` **PHP** ```php $p = json_decode($client->get('/payments/pay_123')->getBody(), true); if (in_array($p['status'], ['paid', 'overpaid'], true) && $p['paid_amount'] >= 150000 && $p['environment'] === 'production') { fulfil('1001'); } ``` Để rà soát nhiều payment, dùng `GET /payments?status=paid,overpaid` và phân trang bằng `after`. Polling được tính vào giới hạn 20 request Thanh toán mỗi giây. ## Bước tiếp theo - [Xác minh IPN](https://hatang.dev/vi/docs/guides/payments/verify-ipn.md) bằng Node.js, PHP, Python và C#. - [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md): tạo endpoint và danh sách sự kiện. - [Lên production](https://hatang.dev/vi/docs/guides/payments/go-live.md). Source: https://hatang.dev/vi/docs/guides/payments/payment-results --- # Bắt đầu nhanh Nhận khoản thanh toán đầu tiên ở môi trường `development`: liên kết ngân hàng giả lập, tạo payment, giả lập người mua chuyển tiền, rồi nhận lời gọi có chữ ký trên server của bạn. Bạn không cần tài khoản ngân hàng thật. ## Trước khi bắt đầu - Tài khoản Hạ Tầng đã bật module **Thanh toán** (Cài đặt doanh nghiệp → Module). Khi module tắt, mọi request Thanh toán trả về `403 product_disabled`. - Một server nhận được `POST` HTTPS từ internet, dùng ở bước 6. Khi chạy trên máy cá nhân, dùng tunnel như ngrok hoặc Cloudflare Tunnel. Mọi request trên trang này đều gửi `Hatang-Environment: development`. Không có header này, request sẽ đi vào production. Xem [Môi trường và sandbox](https://hatang.dev/vi/docs/guides/payments/environments.md). ## Bước 1: Tạo API key Trên dashboard, mở **API Keys** và tạo key có scope `payments:read` và `payments:write`. Chỉ giữ key trên server. ```bash export HATANG_API_KEY=ht_123... ``` Với Node.js, cài SDK: ```bash npm install @hatang/sdk ``` ```ts title="lib/hatang.ts" import { Hatang } from "@hatang/sdk"; export const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", environment: "development", // gửi dưới dạng header Hatang-Environment }); ``` ## Bước 2: Liên kết ngân hàng giả lập Bắt đầu liên kết, rồi xác nhận bằng mã `000000`. Ngân hàng giả lập luôn dùng mã này. **cURL** ```bash curl https://api.hatang.dev/payments/bank-connections \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "provider": "simulator", "label": "Tài khoản thử" }' curl https://api.hatang.dev/payments/bank-connections/bcn_123/confirm \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "otp": "000000" }' ``` **Node.js** ```ts import { hatang } from "./lib/hatang"; const { data: conn, error } = await hatang.payments.bankConnections.create({ provider: "simulator", label: "Tài khoản thử" }); if (error) throw new Error(`${error.name}: ${error.message}`); await hatang.payments.bankConnections.confirm(conn.id, "000000"); ``` **Python** ```python import os, requests API = "https://api.hatang.dev" HEADERS = {"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}", "Hatang-Environment": "development"} conn = requests.post(f"{API}/payments/bank-connections", headers=HEADERS, json={"provider": "simulator", "label": "Tài khoản thử"}, timeout=10).json() requests.post(f"{API}/payments/bank-connections/{conn['id']}/confirm", headers=HEADERS, json={"otp": "000000"}, timeout=10).raise_for_status() ``` **PHP** ```php 'https://api.hatang.dev', 'headers' => [ 'Authorization' => 'Bearer ' . getenv('HATANG_API_KEY'), 'Hatang-Environment' => 'development', ], ]); $conn = json_decode($client->post('/payments/bank-connections', [ 'json' => ['provider' => 'simulator', 'label' => 'Tài khoản thử'], ])->getBody(), true); $client->post("/payments/bank-connections/{$conn['id']}/confirm", ['json' => ['otp' => '000000']]); ``` Lời gọi đầu trả về `201` với kết nối ở trạng thái `pending_otp` kèm `challenge`; sau khi xác nhận, kết nối chuyển sang `active`: ```json { "object": "bank_connection", "id": "bcn_01a0b90428fd7c499145a12c133abe41", "environment": "development", "provider": "simulator", "bank": { "bin": "970422", "name": "MBBank" }, "account_number": "SIM4821937506", "account_name": "TAI KHOAN GIA LAP", "account_type": "personal", "status": "active", "label": "Tài khoản thử", "link_mode": "otp", "activated_at": "2026-09-19T09:30:02.114Z", "created_at": "2026-09-19T09:29:51.402Z" } ``` Bạn cũng làm được việc này trên dashboard: **Cài đặt thanh toán → Tài khoản ngân hàng → Thêm tài khoản ngân hàng**. ## Bước 3: Tạo payment Gửi `Idempotency-Key` (mã đơn là lựa chọn tốt) để request thử lại trả về cùng một payment thay vì tạo cái thứ hai. **cURL** ```bash curl https://api.hatang.dev/payments \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Idempotency-Key: order-1001" \ -H "Content-Type: application/json" \ -d '{ "amount": 150000, "description": "Đơn hàng #1001", "ipn_url": "https://shop.example.vn/hatang/ipn", "metadata": { "order_id": "1001" } }' ``` **Node.js** ```ts // createOrder dùng mã đơn làm Idempotency-Key và lưu vào metadata.order_id. const { data: payment, error } = await hatang.payments.createOrder({ order_id: "1001", amount: 150_000, description: "Đơn hàng #1001", ipn_url: "https://shop.example.vn/hatang/ipn", }); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(payment.checkout_url); ``` **Python** ```python from hatang import Hatang # pip install hatang hatang = Hatang(environment="development") # gửi dưới dạng header Hatang-Environment payment = hatang.payments.create({ "amount": 150000, "description": "Đơn hàng #1001", "ipn_url": "https://shop.example.vn/hatang/ipn", "metadata": {"order_id": "1001"}, }, idempotency_key="order-1001") print(payment["checkout_url"]) ``` **PHP** ```php $payment = json_decode($client->post('/payments', [ 'headers' => ['Idempotency-Key' => 'order-1001'], 'json' => [ 'amount' => 150000, 'description' => 'Đơn hàng #1001', 'ipn_url' => 'https://shop.example.vn/hatang/ipn', 'metadata' => ['order_id' => '1001'], ], ])->getBody(), true); echo $payment['checkout_url']; ``` Phản hồi là `201` với payment ở trạng thái `pending`, kèm `code`, `qr` và `checkout_url`. Object đầy đủ được mô tả ở [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md#response). ## Bước 4: Mở trang checkout Mở `checkout_url` trên trình duyệt. Trang hiển thị VietQR, thông tin ngân hàng và đồng hồ đếm ngược. Ở development, trang có thêm nút giả lập chuyển khoản. ## Bước 5: Giả lập chuyển khoản **cURL** ```bash curl https://api.hatang.dev/payments/simulate/transfer \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" \ -H "Content-Type: application/json" \ -d '{ "payment_id": "pay_123" }' ``` **Node.js** ```ts await hatang.payments.simulateTransfer({ payment_id: payment.id }); ``` **Python** ```python requests.post(f"{API}/payments/simulate/transfer", headers=HEADERS, json={"payment_id": payment["id"]}, timeout=10).raise_for_status() ``` **PHP** ```php $client->post('/payments/simulate/transfer', ['json' => ['payment_id' => $payment['id']]]); ``` Lời gọi trả về `202`. Trong vài giây, payment chuyển sang `paid`, trang checkout chuyển sang trạng thái đã thanh toán, và `ipn_url` của bạn nhận lời gọi `payment.succeeded`. ## Bước 6: Xử lý IPN Endpoint của bạn phải kiểm tra header `Hatang-Signature` với body gốc trước khi tin bất cứ điều gì, rồi trả `2xx` trong 10 giây. Secret key nằm ở **Cài đặt thanh toán → Khoá tích hợp** (key development bắt đầu bằng `sk_test_`). ```ts title="app/api/hatang/ipn/route.ts (Next.js)" import { constructIpnEvent, HatangSignatureError } from "@hatang/sdk"; export async function POST(req: Request) { const raw = await req.text(); // body gốc, trước khi parse JSON try { const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!); if (ipn.data.status === "paid" || ipn.data.status === "overpaid") { await markOrderPaid(ipn.data.metadata.order_id as string, ipn.data.paid_amount); } return new Response(null, { status: 200 }); } catch (err) { if (err instanceof HatangSignatureError) return new Response(null, { status: 401 }); throw err; // trả 5xx để Hatang thử lại sau } } ``` [Xác minh IPN](https://hatang.dev/vi/docs/guides/payments/verify-ipn.md) có handler tương tự bằng PHP, Python và C#. ## Bước 7: Xem những gì đã gửi Mọi lời gọi IPN và kết quả của nó được liệt kê theo payment, mới nhất trước: ```bash curl https://api.hatang.dev/payments/pay_123/ipn-deliveries \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Hatang-Environment: development" ``` ```json { "object": "list", "data": [ { "object": "ipn_delivery", "id": "ipn_01a0b90429d87cf89e37fc2f66040ec5", "payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5", "event_type": "payment.succeeded", "url": "https://shop.example.vn/hatang/ipn", "status": "success", "attempt": 1, "response_status": 200, "response_ms": 84, "last_error": null, "next_attempt_at": null, "delivered_at": "2026-09-19T09:34:13.041Z", "created_at": "2026-09-19T09:34:12.952Z" } ] } ``` Trang chi tiết payment trên dashboard hiển thị cùng danh sách này. ## Lỗi thường gặp | Lỗi | Nguyên nhân | Cách xử lý | |---|---|---| | `422 no_bank_connection` | Chưa có tài khoản ngân hàng nào đang hoạt động ở môi trường này | Làm bước 2, hoặc kiểm tra đã gửi `Hatang-Environment: development` chưa | | `422 validation_error` kèm "Several bank accounts are linked; pass connection_id" | Có nhiều hơn một tài khoản đang hoạt động | Gửi `connection_id` khi tạo payment | | `422 validation_error` ở `ipn_url` | URL là địa chỉ nội bộ (`localhost`, `10.x`, `192.168.x`…) hoặc không phải `https` | Dùng URL HTTPS công khai, ví dụ qua tunnel | | `401 restricted_api_key` | Key thiếu scope Thanh toán | Tạo key có `payments:read` và `payments:write` | | `403 environment_mismatch` | Giả lập chuyển khoản ngoài môi trường development | Gửi `Hatang-Environment: development` | | Không nhận được IPN | Endpoint không trả `2xx` | Xem `last_error` ở bước 7; Hạ Tầng thử lại trong khoảng một ngày | ## Bước tiếp theo - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md): mọi trường, trạng thái và tình huống đặc biệt. - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): so sánh IPN, webhook và polling. - [SDK JavaScript](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md): toàn bộ luồng trong vài lời gọi. - [Lên production](https://hatang.dev/vi/docs/guides/payments/go-live.md) khi đã sẵn sàng nhận tiền thật. Source: https://hatang.dev/vi/docs/guides/payments/quickstart --- # SDK JavaScript `@hatang/sdk` gói gọn toàn bộ tích hợp Thanh toán: tạo đơn, xác nhận thanh toán, xác minh IPN và webhook. Mọi hàm API trả về `{ data, error }` và không throw khi API báo lỗi. Các hàm xác minh chỉ dùng Web Crypto nên chạy được cả trên Bun, Deno, Vercel và Cloudflare Workers. Trang này nói về phần thanh toán. Bản thân client (tùy chọn, lỗi, các sản phẩm khác) được mô tả ở [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md). ## Cài đặt và cấu hình ```bash npm install @hatang/sdk ``` ```ts title="lib/hatang.ts" import { Hatang } from "@hatang/sdk"; export const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", // Bỏ ở production, hoặc đặt HATANG_ENVIRONMENT=development thay cho dòng này. environment: "development", }); ``` | Tùy chọn | Biến môi trường | Ý nghĩa | |---|---|---| | tham số đầu tiên | `HATANG_API_KEY` | API key (`ht_…`) có `payments:read` và `payments:write` | | `baseUrl` | `HATANG_BASE_URL` | `https://api.hatang.dev` | | `environment` | `HATANG_ENVIRONMENT` | `development` hoặc `production`, gửi dưới dạng header `Hatang-Environment` | ## 1. Tạo payment cho đơn hàng `createOrder` dùng mã đơn làm idempotency key: gọi hai lần cho cùng một đơn (bấm đúp, thử lại request) trả về cùng một payment. Mã đơn cũng được lưu vào `metadata.order_id`. ```ts const { data: payment, error } = await hatang.payments.createOrder({ order_id: "SO-1001", amount: 150_000, description: "Đơn hàng #1001", return_url: "https://shop.example.vn/orders/SO-1001", ipn_url: "https://shop.example.vn/api/hatang/ipn", // Tùy chọn: phát hành hóa đơn điện tử khi đã thanh toán. invoice: { buyer: { name: "Nguyễn Văn Khách", tax_code: "0109876543", email: "ketoan@khach.vn" }, items: [{ name: "Gói dịch vụ", quantity: 1, unit_price: 136_364, vat_rate: 10 }], }, }); if (error) throw new Error(`${error.name}: ${error.message}`); // Chuyển người mua tới trang checkout… redirect(payment.checkout_url); // …hoặc tự hiển thị QR: , ngân hàng ở payment.bank, mã ở payment.code. ``` Thêm `code: "SO1001"` để dùng mã đơn của bạn làm nội dung chuyển khoản thay cho mã `HT…` tự sinh. Xem [Mã thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md#payment-code). Gọi lại `createOrder` cho cùng `order_id` nhưng với trường **khác** sẽ trả về `invalid_idempotent_request`. Khi đơn thay đổi, hãy dùng mã đơn mới. ## 2. Nhận IPN Truyền body gốc và headers. `constructIpnEvent` kiểm tra chữ ký HMAC và trả về lời gọi đã parse, hoặc throw `HatangSignatureError`. ```ts title="app/api/hatang/ipn/route.ts (Next.js)" import { constructIpnEvent, HatangSignatureError } from "@hatang/sdk"; export async function POST(req: Request) { const raw = await req.text(); // trước khi parse JSON try { const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!); if (await alreadyHandled(ipn.id)) return new Response(null, { status: 200 }); const order = ipn.data.metadata.order_id as string; if (ipn.data.status === "paid" || ipn.data.status === "overpaid") await markOrderPaid(order, ipn.data.paid_amount); if (ipn.data.invoice?.status === "issued") await saveInvoiceNumber(order, ipn.data.invoice.number); return new Response(null, { status: 200 }); } catch (err) { if (err instanceof HatangSignatureError) return new Response(null, { status: 401 }); throw err; // 5xx khiến Hatang thử lại sau } } ``` ```ts title="Express" import express from "express"; import { constructIpnEvent } from "@hatang/sdk"; app.post("/api/hatang/ipn", express.raw({ type: "application/json" }), async (req, res) => { const ipn = await constructIpnEvent(req.body, req.headers, process.env.HATANG_SECRET_KEY!).catch(() => null); if (!ipn) return res.sendStatus(401); await queue.add("hatang-ipn", ipn); // xử lý nền, trả lời nhanh res.sendStatus(200); }); ``` > **Caution** > > `express.json()` sẽ parse rồi serialize lại body, khiến chữ ký không còn khớp. Hãy dùng `express.raw()` cho route IPN. ## 3. Xác nhận trước khi giao hàng {#confirm-before-you-fulfil} Khi người mua quay lại `return_url`, hỏi Hạ Tầng thay vì tin trình duyệt. `verify` chỉ trả `verified: true` khi payment đã `paid` (hoặc `overpaid`) và khớp với thông tin bạn truyền: ```ts const { data: check, error } = await hatang.payments.verify(paymentId, { order_id: "SO-1001", amount: 150_000, environment: "production", // payment development không bao giờ mở khóa đơn thật }); if (error) return showError(error.message); if (check.verified) await fulfil("SO-1001"); else console.log(check.reasons); // vd ["status is partially_paid"], ["belongs to another order"] ``` | Giá trị trong `reasons` | Ý nghĩa | |---|---| | `status is ` | Chưa `paid` hoặc `overpaid` | | `paid , expected ` | Đã trả, nhưng ít hơn số tiền bạn cần | | `belongs to another order` | `metadata.order_id` khác `order_id` bạn truyền | | `is a payment` | Payment thuộc môi trường kia | ## 4. Webhook (tùy chọn) Webhook nhận mọi sự kiện của doanh nghiệp, không chỉ một payment: ```ts import { constructWebhookEvent, type Payment } from "@hatang/sdk"; const event = await constructWebhookEvent(raw, req.headers, process.env.HATANG_WEBHOOK_SECRET!); // whsec_… if (event.type === "payment.succeeded") await markOrderPaid(event.data.metadata.order_id as string, event.data.paid_amount); ``` `constructWebhookEvent` throw `HatangSignatureError` khi chữ ký sai hoặc timestamp lệch quá 5 phút. Truyền `{ toleranceSec }` làm tham số thứ tư để đổi khoảng này. ## 5. Test ở development Với `environment: "development"` và đã liên kết ngân hàng giả lập: ```ts await hatang.payments.simulateTransfer({ payment_id: payment.id }); // trả đủ await hatang.payments.simulateTransfer({ payment_id: payment.id, amount: 50_000 }); // trả thiếu ``` ## Xử lý lỗi ```ts const { data, error } = await hatang.payments.create({ amount: 150_000, code: "SO1001" }); if (error) { switch (error.name) { case "code_taken": // 409: mã đã được dùng trên tài khoản ngân hàng này case "no_bank_connection": // 422: cần liên kết tài khoản ngân hàng trước case "validation_error": // 422: error.message nêu trường bị sai return showError(error.message); case "rate_limit_exceeded": // 429: giảm tốc độ rồi thử lại return retryLater(); default: throw new Error(`${error.statusCode} ${error.name}: ${error.message}`); } } ``` Lỗi mạng trả về `error.name === "application_error"` với `statusCode: 0`. Danh sách đầy đủ ở [Lỗi](https://hatang.dev/vi/docs/reference/errors.md). ## Danh sách hàm | Hàm | Chức năng | |---|---| | `payments.createOrder({ order_id, amount, … })` | Tạo payment cho đơn; an toàn khi gọi lại | | `payments.create(payload, { idempotencyKey })` | Như trên, với key và metadata của bạn | | `payments.verify(id, { order_id, amount, environment })` | `{ verified, paid, reasons, payment }` | | `payments.get(id)` / `list(params)` | Đọc payment | | `payments.cancel(id)` / `markPaid(id, { amount, note })` | Hủy payment đang chờ, hoặc ghi nhận tiền nhận ngoài ngân hàng | | `payments.ipnDeliveries(id)` | Các lần gửi IPN của một payment | | `payments.qr({ amount, content, connection_id })` | VietQR cho luồng chỉ nhận giao dịch | | `payments.simulateTransfer(…)` | Chỉ ở development | | `payments.transactions.list(params)` / `get(id)` | Giao dịch ngân hàng | | `payments.bankConnections.list` / `get` / `create` / `confirm` / `update` / `unlink` / `confirmUnlink` | Tài khoản ngân hàng | | `payments.statements.import({ connection_id, content })` / `get(id)` | Nhập sao kê | | `einvoices.create` / `get` / `list` / `update` / `remove` / `publish` / `retry` / `replace` / `resolve` / `sync` / `history` | Hóa đơn điện tử | | `einvoices.providers()` / `einvoices.connections.*` | Tài khoản nhà cung cấp hóa đơn | | `constructIpnEvent(raw, headers, secretKey)` / `verifyIpn(raw, signature, secretKey)` | Chữ ký IPN (HMAC-SHA256) | | `constructWebhookEvent(raw, headers, secret)` / `verifyWebhook(raw, headers, secret)` | Chữ ký webhook (Svix), cửa sổ 5 phút | ## Bước tiếp theo - [Xác minh IPN](https://hatang.dev/vi/docs/guides/payments/verify-ipn.md) không cần SDK. - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md): mọi trường và trạng thái. - [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md): client cho mọi sản phẩm Hạ Tầng. Source: https://hatang.dev/vi/docs/guides/payments/sdk-javascript --- # Nhận giao dịch không cần tạo đơn Dùng luồng này khi hệ thống của bạn đã có mã đơn và chỉ cần biết khi tiền cho đơn đó về, ví dụ mã QR tĩnh ở quầy thu ngân. Bạn không tạo payment cho từng đơn. Hạ Tầng báo mọi giao dịch vào tài khoản dưới dạng `transaction.received`, kèm mã đơn của bạn nếu tìm thấy trong nội dung. ## Trước khi bắt đầu - Một [kết nối ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md) đang hoạt động. - Một [webhook endpoint](https://hatang.dev/vi/docs/guides/payments/webhooks.md) đăng ký sự kiện `transaction.received`. Luồng này không có IPN, vì không có payment nào để gắn `ipn_url`. ## 1. Khai báo định dạng mã đơn Đặt `payment_code_pattern` trên tài khoản ngân hàng: tiền tố và khoảng độ dài. Hạ Tầng sẽ tìm mã này trong nội dung mọi giao dịch. **cURL** ```bash curl -X PATCH https://api.hatang.dev/payments/bank-connections/bcn_123 \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "config": { "payment_code_pattern": { "prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits" } } }' ``` **Node.js** ```ts await hatang.payments.bankConnections.update("bcn_123", { config: { payment_code_pattern: { prefix: "DH", min_length: 4, max_length: 8, charset: "digits" } }, }); ``` **Python** ```python requests.patch("https://api.hatang.dev/payments/bank-connections/bcn_123", headers=HEADERS, json={ "config": {"payment_code_pattern": {"prefix": "DH", "min_length": 4, "max_length": 8, "charset": "digits"}}, }, timeout=10).raise_for_status() ``` **PHP** ```php $client->patch('/payments/bank-connections/bcn_123', ['json' => [ 'config' => ['payment_code_pattern' => ['prefix' => 'DH', 'min_length' => 4, 'max_length' => 8, 'charset' => 'digits']], ]]); ``` ### Cách tìm mã 1. Nội dung được chuẩn hóa: bỏ dấu, `đ` thành `D`, viết hoa toàn bộ, và bỏ mọi ký tự không phải chữ hoặc số. `ck dh 2025-0919 cam on` thành `CKDH20250919CAMON`. 2. Hạ Tầng tìm tiền tố theo sau là `min_length` đến `max_length` ký tự thuộc `charset`, và lấy kết quả đầu tiên, dài nhất có thể. Với cấu hình trên, `DH20250919` được tìm thấy trong `ck dh 2025-0919 cam on`. > **Caution** > > Khoảng trắng bị bỏ trước khi tìm, nên với `charset: "alnum"` các chữ phía sau sẽ dính vào mã: `DH1001 cam on` thành `DH1001CAMON` và kết quả là `DH1001CAMO`. Nên dùng `digits`, hoặc cho mọi mã dài đúng `max_length`. ## 2. Hiển thị VietQR chứa mã `GET /payments/qr` tạo VietQR cho số tiền và nội dung bất kỳ. Không lưu gì. **cURL** ```bash # JSON kèm chuỗi EMVCo curl "https://api.hatang.dev/payments/qr?amount=99000&content=DH20250919" \ -H "Authorization: Bearer $HATANG_API_KEY" # Ảnh SVG curl "https://api.hatang.dev/payments/qr?amount=99000&content=DH20250919&format=svg" \ -H "Authorization: Bearer $HATANG_API_KEY" -o qr.svg ``` **Node.js** ```ts const { data: qr } = await hatang.payments.qr({ amount: 99_000, content: "DH20250919" }); // Render qr.payload bằng thư viện QR bất kỳ. ``` **Python** ```python qr = requests.get("https://api.hatang.dev/payments/qr", headers=HEADERS, params={"amount": 99000, "content": "DH20250919"}, timeout=10).json() ``` **PHP** ```php $qr = json_decode($client->get('/payments/qr', ['query' => ['amount' => 99000, 'content' => 'DH20250919']])->getBody(), true); ``` ```json { "object": "vietqr", "payload": "00020101021238530010A0000007270123000697042201099104526580208QRIBFTTA53037045405990005802VN62140810DH202509196304FCC9", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "bank": { "bin": "970422", "account_number": "910452658", "account_name": "NGUYEN VAN A" }, "amount": 99000, "content": "DH20250919" } ``` | Tham số | Ý nghĩa | |---|---| | `amount` | VND. Bỏ trống để người mua tự nhập số tiền (QR dán ở quầy) | | `content` | Nội dung điền sẵn, tối đa 50 ký tự | | `connection_id` | Tài khoản nào; chỉ cần khi có nhiều tài khoản | | `format` | `json` (mặc định) hoặc `svg` | | `logo` | Với `format=svg`: `0` để bỏ logo Hạ Tầng khỏi ảnh | QR chỉ điền sẵn nội dung. Người chuyển khoản thủ công phải tự gõ mã, vì vậy hãy in mã cạnh QR. ## 3. Nhận `transaction.received` ```json { "type": "transaction.received", "created_at": "2026-09-19T09:34:12.946Z", "data": { "object": "transaction", "id": "txn_01a0b90429dc79c7a5689c7a0c485a01", "environment": "production", "connection_id": "bcn_01a0b90428fd7c499145a12c133abe41", "direction": "in", "amount": 99000, "currency": "VND", "description": "DH20250919 chuyen tien", "code": "DH20250919", "merchant_code": "DH20250919", "reference": "FT1789810452930810", "counterparty": { "account": "0987654321", "name": "NGUYEN VAN KHACH", "bank": "970436" }, "booked_at": "2026-09-19T09:34:12.930Z", "source": "push", "payment_id": null, "match_status": "unmatched", "invoice_exempt": null, "created_at": "2026-09-19T09:34:12.946Z" } } ``` | Trường | Ý nghĩa | |---|---| | `merchant_code` | Mã đơn của bạn tìm được trong nội dung; `null` nếu không tìm thấy | | `code` | Mã lưu trên giao dịch: mã thanh toán Hạ Tầng khi giao dịch trả cho một payment, ngược lại là mã đơn của bạn | | `reference` | Mã tham chiếu của ngân hàng | | `source` | Hạ Tầng biết giao dịch bằng cách nào: `push` (ngân hàng báo), `reconcile` (đọc từ lịch sử tài khoản), `import` (sao kê) hoặc `simulator` (development) | | `payment_id`, `match_status` | Có giá trị khi giao dịch trả cho một payment của bạn (`matched`); ngược lại là `unmatched` | | `counterparty.bank` | Mã BIN ngân hàng người chuyển, nếu ngân hàng cung cấp | Handler của bạn nên: 1. Xác minh chữ ký webhook (xem [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md)). 2. Tìm đơn theo `merchant_code`, và so `amount` với số tiền cần thu. 3. Lưu `data.id`. Cùng một giao dịch không bao giờ được báo hai lần, nhưng endpoint có thể nhận lại cùng một lần gửi sau khi timeout. 4. Kiểm tra `environment` và `direction`. ### Tình huống đặc biệt | Tình huống | Bạn nhận được | |---|---| | Người mua quên ghi mã | `merchant_code: null`. Đối chiếu theo số tiền và `counterparty.name`, hoặc hỏi người mua | | Người mua chuyển sai số tiền | Số tiền đúng như đã chuyển; bạn tự quyết định | | Người mua chuyển hai lần | Hai giao dịch với `id` và `reference` khác nhau | | Ngân hàng đẩy cùng giao dịch hai lần | Một giao dịch (cùng mã tham chiếu và số tiền trên tài khoản) | | Giao dịch chứa cả mã thanh toán | Giao dịch cập nhật payment đó (`payment.succeeded`…) và `transaction.received` có `payment_id`. Một giao dịch không bao giờ bị tính hai lần | ## Lọc giao dịch được báo Theo từng tài khoản ngân hàng, trong `transaction_events` của config hoặc trên dashboard: | Cấu hình | Mặc định | Tác dụng | |---|---|---| | `direction` | `in` | `all` báo cả tiền ra | | `require_code` | `false` | `true` chỉ báo giao dịch tìm thấy mã hoặc khớp với payment | | `min_amount` | `0` | Giao dịch dưới số tiền này không được báo | ## Danh sách và tra cứu giao dịch Mọi giao dịch đều được lưu, dù có được báo hay không. **cURL** ```bash curl "https://api.hatang.dev/payments/transactions?direction=in&match_status=unmatched&limit=50" \ -H "Authorization: Bearer $HATANG_API_KEY" ``` **Node.js** ```ts const { data: page } = await hatang.payments.transactions.list({ direction: "in", match_status: "unmatched", limit: 50 }); if (page.has_more) await hatang.payments.transactions.list({ after: page.next_cursor! }); ``` **Python** ```python page = requests.get("https://api.hatang.dev/payments/transactions", headers=HEADERS, params={"direction": "in", "match_status": "unmatched", "limit": 50}, timeout=10).json() ``` **PHP** ```php $page = json_decode($client->get('/payments/transactions', ['query' => [ 'direction' => 'in', 'match_status' => 'unmatched', 'limit' => 50, ]])->getBody(), true); ``` | Tham số | Ý nghĩa | |---|---| | `limit` | 1 đến 100, mặc định 20 | | `after` | `next_cursor` của trang trước | | `connection_id` | Một tài khoản ngân hàng | | `direction` | `in` hoặc `out` | | `match_status` | `matched`, `unmatched` hoặc `ignored` | | `payment_id` | Các giao dịch đã trả cho payment này | Phản hồi có dạng `{ "object": "list", "data": [...], "has_more": true, "next_cursor": "txn_..." }`, mới nhất trước. `GET /payments/transactions/{id}` trả về một giao dịch. ## Bước tiếp theo - [Phát hành hóa đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md#invoices-without-a-payment) cho một giao dịch bằng `transaction_id`. - [Nhập sao kê](https://hatang.dev/vi/docs/guides/payments/bank-connections.md#statement-import) để khôi phục giao dịch ngân hàng bỏ sót. - [Tạo thanh toán](https://hatang.dev/vi/docs/guides/payments/create-payment.md) khi bạn cần trang checkout và hạn thanh toán. Source: https://hatang.dev/vi/docs/guides/payments/transactions --- # Xác minh IPN Mọi lời gọi IPN đều có chữ ký, để endpoint của bạn phân biệt được lời gọi thật từ Hạ Tầng với lời gọi giả mạo. Trang này giải thích chữ ký, đưa ra handler chạy được bằng bốn ngôn ngữ, một test vector để kiểm tra code, và những lỗi hay làm hỏng việc xác minh. ## Các khoá Mỗi doanh nghiệp có một **access key** và một **secret key** cho mỗi môi trường, được tạo cùng doanh nghiệp. Xem trên dashboard tại **Cài đặt thanh toán → Khoá tích hợp**. Key development bắt đầu bằng `ak_test_` và `sk_test_`. - `Hatang-Access-Key` cho biết lời gọi thuộc tài khoản và môi trường nào. - `Hatang-Signature` là **HMAC-SHA256 dạng hex thường của body gốc**, khóa là secret key (dạng byte UTF-8, đúng như hiển thị). Để xác minh: tính HMAC trên đúng các byte nhận được, rồi so với header bằng hàm so sánh thời gian hằng. Chỉ giữ secret key ở server. ## Ví dụ handler **Node.js** ```js import express from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.HATANG_SECRET_KEY; // sk_… function verifyIpn(rawBody, signature, secret) { const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); const got = String(signature ?? "").trim().toLowerCase(); return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected)); } const app = express(); // Byte gốc, không phải JSON đã parse: serialize lại sẽ đổi body và làm sai chữ ký. app.post("/hatang/ipn", express.raw({ type: "application/json" }), async (req, res) => { if (!verifyIpn(req.body, req.get("Hatang-Signature"), SECRET)) return res.status(401).end(); const ipn = JSON.parse(req.body.toString("utf8")); if (await alreadyHandled(ipn.id)) return res.status(200).end(); await queue.add("hatang-ipn", ipn); // cập nhật đơn ở nền res.status(200).end(); }); ``` Với SDK: `constructIpnEvent(rawBody, headers, secretKey)` xác minh và trả về lời gọi đã parse (hoặc throw `HatangSignatureError`), còn `verifyIpn(rawBody, signature, secretKey)` trả về boolean. Xem [SDK JavaScript](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md). **Python** ```python import os from flask import Flask, request, abort from hatang import HatangSignatureError, construct_ipn_event SECRET = os.environ["HATANG_SECRET_KEY"] # sk_… app = Flask(__name__) @app.post("/hatang/ipn") def hatang_ipn(): try: # byte, đúng như nhận được; kiểm tra Hatang-Signature ipn = construct_ipn_event(request.get_data(), request.headers, SECRET) except HatangSignatureError: abort(401) # chống trùng theo ipn["id"], rồi cập nhật đơn theo ipn["data"]["status"] return "", 200 ``` **PHP** ```php { using var ms = new MemoryStream(); await req.Body.CopyToAsync(ms); var raw = ms.ToArray(); var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("HATANG_SECRET_KEY")!); var expected = Convert.ToHexString(HMACSHA256.HashData(secret, raw)).ToLowerInvariant(); var got = req.Headers["Hatang-Signature"].ToString().Trim().ToLowerInvariant(); if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(got))) return Results.Unauthorized(); // chống trùng theo trường "id", rồi cập nhật đơn return Results.Ok(); }); ``` ## Test vector Với secret key `sk_test_example` và body `{"id":"ipn_test","type":"payment.succeeded"}` (đúng các byte đó), chữ ký là: ``` 35c86d268e66be5f34c25d834de9da3ba1f0c6d26fb735266440886df6b5d955 ``` Kiểm tra bằng shell: ```bash printf '%s' '{"id":"ipn_test","type":"payment.succeeded"}' | openssl dgst -sha256 -hmac sk_test_example ``` Và gửi một lời gọi test có chữ ký tới handler trên máy bạn: ```bash BODY='{"id":"ipn_test","type":"payment.succeeded"}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac sk_test_example | awk '{print $NF}') curl -i http://localhost:3000/hatang/ipn \ -H "Content-Type: application/json" \ -H "Hatang-Signature: $SIG" \ --data-raw "$BODY" ``` Với `HATANG_SECRET_KEY=sk_test_example`, handler phải trả `200`; đổi một ký tự trong body thì phải trả `401`. Để test trọn luồng, tạo payment ở development và [giả lập chuyển khoản](https://hatang.dev/vi/docs/guides/payments/environments.md#simulate-a-transfer). ## Lỗi thường gặp - **Parse JSON rồi serialize lại trước khi xác minh.** Khoảng trắng và thứ tự key thay đổi, HMAC không khớp. Hãy xác minh trên byte gốc. Với Express, dùng `express.raw()` cho route này; với Next.js, đọc `await req.text()`. - **Dùng secret key của môi trường kia.** Development và production có key khác nhau. `Hatang-Environment` và tiền tố `sk_test_` cho biết key nào áp dụng. - **So sánh bằng `==`.** Hãy dùng hàm so sánh thời gian hằng. - **Proxy làm thay đổi body.** Một số gateway mã hóa lại JSON hoặc đổi bảng mã. Hãy xác minh trên body đúng như Hạ Tầng gửi. - **Xoay secret key.** Sau khi bấm **Tạo secret key mới** trên dashboard, lời gọi được ký bằng key mới ngay lập tức. Các lần thử lại của lời gọi cũ được ký bằng key hiện hành lúc gửi, nên hãy đổi key trên server ngay sau khi xoay. ## Bước tiếp theo - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): làm gì sau khi lời gọi đã được xác minh. - [Webhook](https://hatang.dev/vi/docs/guides/payments/webhooks.md): cơ chế chữ ký còn lại. - [Lên production](https://hatang.dev/vi/docs/guides/payments/go-live.md). Source: https://hatang.dev/vi/docs/guides/payments/verify-ipn --- # Webhook Webhook gửi các sự kiện Thanh toán của toàn doanh nghiệp tới một hoặc nhiều endpoint: mọi payment, mọi giao dịch ngân hàng, mọi thay đổi tài khoản ngân hàng và hóa đơn điện tử, ở cả hai môi trường. Dùng webhook cho giao dịch không qua đơn (`transaction.received`) và những gì IPN không bao quát. Trang này hướng dẫn tạo endpoint, xác minh lần gửi, và liệt kê mọi sự kiện Thanh toán. Cách webhook hoạt động trên mọi sản phẩm Hạ Tầng được mô tả ở [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## Tạo endpoint Trên dashboard, mở **Webhooks** và thêm endpoint, hoặc gọi `POST /webhooks`. Một endpoint nhận cả hai môi trường; mỗi object thanh toán cho biết môi trường qua trường `environment`. **cURL** ```bash curl https://api.hatang.dev/webhooks \ -H "Authorization: Bearer $HATANG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "endpoint": "https://shop.example.vn/hatang/webhooks", "events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"], "description": "Backend cửa hàng" }' ``` **Node.js** ```ts const { data: hook, error } = await hatang.webhooks.create({ endpoint: "https://shop.example.vn/hatang/webhooks", events: ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"], description: "Backend cửa hàng", }); // Lưu hook.signing_secret (whsec_…) trên server. ``` **Python** ```python hook = requests.post("https://api.hatang.dev/webhooks", headers=HEADERS, json={ "endpoint": "https://shop.example.vn/hatang/webhooks", "events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"], "description": "Backend cửa hàng", }, timeout=10).json() ``` **PHP** ```php $hook = json_decode($client->post('/webhooks', ['json' => [ 'endpoint' => 'https://shop.example.vn/hatang/webhooks', 'events' => ['payment.succeeded', 'payment.partially_paid', 'payment.overpaid', 'transaction.received', 'invoice.issued'], 'description' => 'Backend cửa hàng', ]])->getBody(), true); ``` Phản hồi (`201`) có `signing_secret` (`whsec_…`). Secret chỉ hiển thị khi tạo endpoint hoặc khi xoay secret, nên hãy lưu ngay trên server. ## Xác minh lần gửi Lần gửi theo chuẩn [Standard Webhooks](https://www.standardwebhooks.com/) / Svix. Mỗi lần gửi có `svix-id`, `svix-timestamp` và `svix-signature` (cùng giá trị đó ở `webhook-id`, `webhook-timestamp` và `webhook-signature`). **Node.js** ```ts import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk"; app.post("/hatang/webhooks", express.raw({ type: "application/json" }), async (req, res) => { try { const event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET!); // whsec_… if (await alreadyHandled(req.get("svix-id"))) return res.sendStatus(200); await queue.add("hatang-webhook", event); // { type, created_at, data } res.sendStatus(200); } catch (err) { if (err instanceof HatangSignatureError) return res.sendStatus(401); throw err; } }); ``` Thư viện `svix` cũng dùng được: `new Webhook(secret).verify(rawBody, headers)`. **Python** ```python import os from flask import Flask, request, abort from hatang import HatangSignatureError, construct_webhook_event SECRET = os.environ["HATANG_WEBHOOK_SECRET"] # whsec_... app = Flask(__name__) @app.post("/hatang/webhooks") def hatang_webhooks(): try: event = construct_webhook_event(request.get_data(), request.headers, SECRET) # { type, created_at, data } except HatangSignatureError: abort(401) # chống trùng theo header svix-id, rồi đưa việc vào queue return "", 200 ``` **PHP** ```php 300) { http_response_code(401); exit; } $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $secret, true)); $ok = false; foreach (explode(' ', $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '') as $part) { [$version, $sig] = array_pad(explode(',', $part, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $sig)) { $ok = true; } } if (!$ok) { http_response_code(401); exit; } $event = json_decode($raw, true); // ['type' => ..., 'created_at' => ..., 'data' => ...] // chống trùng theo $id, rồi đưa việc vào queue http_response_code(200); ``` **Tự xác minh:** base64-decode phần sau `whsec_` của secret, tính HMAC-SHA256 của `{svix-id}.{svix-timestamp}.{raw body}`, base64-encode kết quả, rồi so với từng `v1,` trong `svix-signature` (cách nhau bởi khoảng trắng). Từ chối timestamp lệch quá 5 phút so với hiện tại. > **Tip: Xoay secret** > > `POST /webhooks/{id}/rotate` (hoặc dashboard) tạo secret mới và trả về trong trường `secret`. Trong 24 giờ, lần gửi được ký bằng cả secret mới và secret cũ, nên `svix-signature` có hai chữ ký và chữ ký nào cũng xác minh được. Hãy triển khai secret mới trong khoảng thời gian đó. ## Cách gửi | | | |---|---| | Body | `{ "type": "...", "created_at": "...", "data": { ... } }` | | Thành công | Bất kỳ mã `2xx` nào trong 15 giây. Redirect không được theo | | Thử lại | Sau 5 giây, 5 phút, 30 phút, 2 giờ, 5 giờ, 10 giờ và 10 giờ: 8 lần | | Chống trùng | `svix-id` giữ nguyên qua mọi lần thử của một lần gửi | | Nhật ký | `GET /webhooks/{id}/deliveries`, và **Webhooks** trên dashboard | ## Sự kiện Thanh toán ### Payment | Sự kiện | Khi nào | `data` | |---|---|---| | `payment.created` | Tạo payment | Payment | | `payment.succeeded` | Đã nhận đủ tiền | Payment, kèm `transaction` | | `payment.partially_paid` | Nhận thiếu | Payment, kèm `transaction` | | `payment.overpaid` | Nhận thừa | Payment, kèm `transaction` | | `payment.expired` | Quá `expires_at` mà chưa có tiền | Payment | | `payment.canceled` | Bạn đã hủy | Payment | Khi payment được ghi nhận thủ công (`mark-paid`), `data` có `manual: { by, note, amount }` thay cho `transaction`. ### Giao dịch và tài khoản ngân hàng | Sự kiện | Khi nào | `data` | |---|---|---| | `transaction.received` | Có giao dịch mới trên tài khoản đã liên kết, theo bộ lọc của tài khoản | Giao dịch, kèm `merchant_code` | | `bank_connection.activated` | Liên kết tài khoản thành công | Kết nối ngân hàng | | `bank_connection.revoked` | Tài khoản bị hủy liên kết | Kết nối ngân hàng | | `bank_connection.needs_reauth` | Ngân hàng yêu cầu liên kết lại | Kết nối ngân hàng | ### Hóa đơn điện tử | Sự kiện | Khi nào | |---|---| | `invoice.draft_created` / `invoice.draft_updated` / `invoice.draft_deleted` | Nháp thay đổi | | `invoice.issued` | Đã ký, có số hóa đơn | | `invoice.tax_accepted` / `invoice.tax_rejected` | Phản hồi của cơ quan thuế | | `invoice.canceled` / `invoice.replaced` / `invoice.adjusted` | Hóa đơn bị hủy, thay thế hoặc điều chỉnh | | `invoice.failed` | Nhà cung cấp từ chối một bước | | `invoice.conflict` | Nháp bị sửa ở cả hai phía; giải quyết bằng `POST /einvoices/{id}/resolve` | `data` là hóa đơn (`object: "einvoice"`), kèm `payment_id` hoặc `transaction_id` nếu có liên kết. Sự kiện phát sinh trên trang nhà cung cấp có `source: "provider"`. ## Ví dụ: payment thanh toán thành công ```json { "type": "payment.succeeded", "created_at": "2026-09-19T09:34:12.950Z", "data": { "object": "payment", "id": "pay_01a0b904297873e8b3524a26fdca0ab5", "environment": "production", "status": "paid", "amount": 150000, "paid_amount": 150000, "code": "HTBF7RDGVQ", "metadata": { "order_id": "1001" }, "invoice": null, "paid_at": "2026-09-19T09:34:12.940Z", "transaction": { "object": "transaction", "id": "txn_01a0b90429dc79c7a5689c7a0c485a01", "amount": 150000, "description": "CK HTBF7RDGVQ thanh toan", "reference": "FT1789810452930810", "payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5", "match_status": "matched" } } } ``` Object payment và giao dịch ở đây đã được rút gọn; webhook gửi đầy đủ mọi trường. ## Bước tiếp theo - [Nhận kết quả](https://hatang.dev/vi/docs/guides/payments/payment-results.md): so sánh webhook với IPN và polling. - [Nhận giao dịch không cần tạo đơn](https://hatang.dev/vi/docs/guides/payments/transactions.md): `transaction.received`. - [Tham chiếu webhook](https://hatang.dev/vi/docs/reference/webhooks.md). Source: https://hatang.dev/vi/docs/guides/payments/webhooks --- # Xuất hoá đơn điện tử qua API Hạ Tầng xuất hoá đơn điện tử theo mã số thuế của bạn, qua chính tài khoản của bạn ở nhà cung cấp hoá đơn (xem hướng dẫn đầy đủ để biết các nhà cung cấp được hỗ trợ). Kết nối tài khoản một lần ở **Cài đặt thanh toán → Nhà cung cấp hoá đơn**, rồi thêm khối `invoice` vào thanh toán để hoá đơn được xuất khi đơn hàng đã trả tiền, hoặc tạo hoá đơn riêng. **Kèm thanh toán** (xuất khi đã thanh toán) ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // HATANG_API_KEY có payments:write const { data: payment, error } = await hatang.payments.createOrder({ order_id: "SO-1002", amount: 275_000, ipn_url: "https://shop.example.com/api/hatang/ipn", invoice: { mode: "publish", buyer: { name: "Nguyen Van Khach", tax_code: "0109876543", email: "accounting@example.com" }, items: [{ name: "Gói dịch vụ tháng 9", quantity: 1, unit_price: 250_000, vat_rate: 10 }], }, }); ``` IPN và `payments.get` cho biết trạng thái và số hoá đơn (`payment.invoice`). **Xuất riêng**, ví dụ cho một khoản chuyển khoản không gắn với đơn hàng ```ts const { data: invoice, error } = await hatang.einvoices.create({ mode: "publish", transaction_id: "txn_123", buyer: { name: "Nguyen Van Khach" }, items: [{ name: "Cà phê hạt 1 kg", quantity: 1, unit_price: 91_667, vat_rate: 8 }], }); ``` Ở `development`, kết nối tài khoản demo của nhà cung cấp: hoá đơn là hoá đơn sandbox thật nhưng không bao giờ gửi tới cơ quan thuế. ## Tìm hiểu thêm - Kết nối nhà cung cấp, vòng đời hoá đơn, bản nháp, lỗi và thay thế: [Hoá đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md) - Thanh toán mà hoá đơn đi kèm: [Nhận thanh toán VietQR](https://hatang.dev/vi/docs/guides/recipes/vietqr-payments.md) Source: https://hatang.dev/vi/docs/guides/recipes/einvoice-api --- # Thêm chat trực tuyến vào website Next.js hoặc React Widget Hạ Tầng là bong bóng chat mà đội của bạn trả lời từ hộp thư Hạ Tầng, cạnh Zalo, Messenger, Instagram, TikTok Shop, WhatsApp và email, kèm trợ lý AI tuỳ chọn trả lời từ nội dung của chính bạn. Lấy site id ở **Hỗ trợ → Kênh → website của bạn → Cài đặt** (bắt đầu bằng `site_`). **Next.js hoặc React** ```bash npm install @hatang/chat-react ``` ```tsx title="app/layout.tsx" import { HatangChat } from "@hatang/chat-react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Mở chat từ nút của bạn bằng `useHatang()`: `const chat = useHatang(); chat.open();`. Truyền `user={{ userId, email, name, hash }}` để gắn người dùng đã đăng nhập, với `hash` tính trên server. **Website khác** ```html ``` ## Tìm hiểu thêm - Component, hook và mọi tuỳ chọn: [React và Next.js](https://hatang.dev/vi/docs/guides/sdks/react.md) - Ký danh tính người dùng để không ai mạo danh khách: [Xác minh danh tính](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md) - JavaScript API của widget: [JavaScript API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) - Trả lời tự động: [Trợ lý AI](https://hatang.dev/vi/docs/guides/support/ai-assistant.md) - Ứng dụng di động: [Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md), [React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md), [iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md), [Android](https://hatang.dev/vi/docs/guides/sdks/android.md) Source: https://hatang.dev/vi/docs/guides/recipes/live-chat-nextjs --- # Gửi email giao dịch từ Node.js và Next.js Cài `@hatang/sdk`, xác minh tên miền gửi một lần, rồi gọi `emails.send` từ code phía server. API tương thích Resend, và dùng được cả SMTP. ```bash npm install @hatang/sdk ``` ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // đọc HATANG_API_KEY const { data, error } = await hatang.emails.send( { from: "Acme ", // tên miền đã xác minh ở Email → Tên miền to: "lan@example.net", subject: "Đơn hàng #1024 đã được xác nhận", html: "

Chào Lan, đơn hàng #1024 của bạn đã được xác nhận.

", text: "Chào Lan, đơn hàng #1024 của bạn đã được xác nhận.", }, { idempotencyKey: "order-1024-confirmation" }, // gửi lại không bao giờ thành hai email ); if (error) throw new Error(`${error.name}: ${error.message}`); console.log(data.id); ``` Trong Next.js, gọi từ Server Action hoặc Route Handler, không bao giờ từ client component: API key là bí mật. ## Các bước 1. Tạo API key có scope `email:send` ở **Cài đặt → API Keys** và đặt vào `HATANG_API_KEY`. 2. Thêm và xác minh tên miền: thêm các bản ghi DKIM, SPF và return-path được liệt kê. Xem [Tên miền](https://hatang.dev/vi/docs/guides/email/domains.md). 3. Gửi. Theo dõi trạng thái bằng `emails.get(id)` hoặc [webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) `email.delivered`, `email.bounced`. ## Tìm hiểu thêm - Mọi trường, tệp đính kèm, hẹn giờ và gửi hàng loạt: [Gửi email](https://hatang.dev/vi/docs/guides/email/sending.md) - Template dùng lại có biến: [Template](https://hatang.dev/vi/docs/guides/email/templates.md) - Component React Email: truyền `react: ` thay cho `html` ([SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md)) - Chuyển từ Resend, SendGrid hay Postmark: [Chuyển từ Resend](https://hatang.dev/vi/docs/guides/email/migrate-from-resend.md) Source: https://hatang.dev/vi/docs/guides/recipes/send-email-nodejs --- # Nhận thanh toán chuyển khoản VietQR trong ứng dụng Hạ Tầng theo dõi chính tài khoản ngân hàng của bạn. Bạn tạo một thanh toán với số tiền và nội dung chuyển khoản, người mua quét mã VietQR bằng app ngân hàng, Hạ Tầng khớp giao dịch đến và báo cho server của bạn. Không qua cổng thẻ, không bên thứ ba nào giữ tiền. ```bash npm install @hatang/sdk ``` **1. Tạo thanh toán và đưa người mua tới trang thanh toán** ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // HATANG_API_KEY; HATANG_ENVIRONMENT=development trong lúc phát triển const { data: payment, error } = await hatang.payments.createOrder({ order_id: "SO-1001", // cũng là idempotency key amount: 150_000, description: "Đơn hàng #1001", return_url: "https://shop.example.com/orders/SO-1001", ipn_url: "https://shop.example.com/api/hatang/ipn", }); if (error) throw new Error(`${error.name}: ${error.message}`); redirect(payment.checkout_url); // hoặc tự hiển thị payment.qr.image_url ``` **2. Nhận IPN** (`app/api/hatang/ipn/route.ts`) ```ts import { constructIpnEvent, HatangSignatureError } from "@hatang/sdk"; export async function POST(req: Request) { const raw = await req.text(); // body gốc, trước khi parse JSON try { const ipn = await constructIpnEvent(raw, req.headers, process.env.HATANG_SECRET_KEY!); if (ipn.data.status === "paid" || ipn.data.status === "overpaid") { await markOrderPaid(ipn.data.metadata.order_id as string); // idempotent: một IPN có thể đến hai lần } return new Response(null, { status: 200 }); } catch (err) { if (err instanceof HatangSignatureError) return new Response(null, { status: 401 }); throw err; // lỗi 5xx khiến Hạ Tầng gửi lại sau } } ``` **3. Kiểm tra ở trang quay về** ```ts const { data: check } = await hatang.payments.verify(paymentId, { order_id: "SO-1001", amount: 150_000 }); if (check?.verified) await fulfil("SO-1001"); ``` Ở môi trường `development` không có tiền thật: trang thanh toán có nút giả lập chuyển khoản, hoặc gọi `hatang.payments.simulateTransfer({ payment_id })`. ## Tìm hiểu thêm - Toàn bộ quy trình và mọi tuỳ chọn: [Thanh toán với SDK JavaScript](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md) - Liên kết tài khoản ngân hàng: [Kết nối ngân hàng](https://hatang.dev/vi/docs/guides/payments/bank-connections.md) - Tự xuất hoá đơn điện tử khi đã thanh toán: [Hoá đơn điện tử](https://hatang.dev/vi/docs/guides/payments/einvoices.md) - Tạo và giả lập thanh toán thử ngay từ coding agent: [MCP server](https://hatang.dev/vi/docs/guides/sdks/mcp.md) Source: https://hatang.dev/vi/docs/guides/recipes/vietqr-payments --- # Gửi tin Zalo ZNS từ backend ZNS (Zalo Notification Service) gửi một mẫu tin đã duyệt tới số điện thoại qua Zalo Official Account của bạn, kể cả khi khách chưa từng nhắn tin. Hạ Tầng kết nối OA một lần và cho bạn một lệnh gọi API; tin trả lời về chung hộp thư với các kênh khác. **Trước khi bắt đầu:** kết nối Zalo OA ở **Hỗ trợ → Kênh**, đưa OA lên gói Zalo có ZNS, và duyệt mẫu tin ở oa.zalo.me. Dùng API key có scope `crm:write`. ```ts import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(); // HATANG_API_KEY const { data, error } = await hatang.chat.zns.send({ account_id: "cha_123", // OA đã kết nối template_id: "231234", // mẫu tin đã duyệt phone: "0912345678", // 84912345678 và +84 912 345 678 cũng được template_data: { customer_name: "Lan", order_code: "DH1024", amount: "350.000đ" }, }); if (error) throw new Error(error.message); if (data.status === "failed") console.error("Zalo từ chối:", data.error); ``` Lệnh tương đương qua HTTP là `POST https://api.hatang.dev/chat/zns`. Xem các mẫu tin và hạn mức trong ngày của một OA bằng `hatang.chat.zns.templates("cha_123")`. Zalo tính phí từng tin. Để gửi cho nhiều khách một lúc, hoặc gửi từ workflow khi đơn hàng được thanh toán, xem hướng dẫn đầy đủ. ## Tìm hiểu thêm - Mẫu tin, gửi hàng loạt, workflow và lỗi: [Zalo ZNS](https://hatang.dev/vi/docs/guides/support/zns.md) - Mọi thứ khác API hỗ trợ khách hàng làm được: [REST API hỗ trợ khách hàng](https://hatang.dev/vi/docs/guides/support/rest-api.md) Source: https://hatang.dev/vi/docs/guides/recipes/zalo-zns --- # SDK Android `dev.hatang:chat` `dev.hatang:chat` đưa màn hình chat hỗ trợ native vào ứng dụng Android của bạn. SDK nói thẳng [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) với gateway Hạ Tầng (không dùng web view). Giao diện viết bằng Jetpack Compose; ứng dụng dùng View/XML vẫn mở chat bằng một dòng `HatangChat.open(context)`. SDK không phụ thuộc Firebase: ứng dụng tự lấy FCM token rồi chuyển cho SDK. Khách nhắn từ ứng dụng vào cùng hộp thư với khách trên website, Zalo, Messenger, Instagram và TikTok Shop. > **Info: Phiên bản** > > `dev.hatang:chat` **0.4.1** · giao thức v1 · Android 8.0 (API 26)+ · Kotlin 1.9+ · Jetpack Compose (BOM 2024.12+) · [Maven Central](https://central.sonatype.com/artifact/dev.hatang/chat) · [Changelog](https://hatang.dev/vi/docs/changelog) · [Tham chiếu API (Dokka)](https://hatang.dev/docs/reference/android/index.html) > **Note** > > Bản 0.3 có màn hình chat, danh sách hội thoại của khách, nút chat nổi, nhận diện có chữ ký, gửi ảnh và tệp, thẻ sản phẩm/đơn hàng/thanh toán, thông báo đẩy qua Firebase của bạn và deep link khôi phục phiên. API có thể còn thay đổi trước bản 1.0. ## Cài đặt {#installation} ### Lấy khoá ứng dụng trong dashboard SDK cần hai giá trị: **Site ID** và một **khoá ứng dụng** (`hak_…`). Khoá ứng dụng thay cho việc kiểm tra Origin của trình duyệt: ứng dụng gửi khoá khi khởi động, nhờ đó gateway biết phiên đến từ ứng dụng nào. 1. Trong dashboard, mở **Hỗ trợ → Kênh → Thêm kênh → Ứng dụng di động**. 2. Bấm **Tạo khoá ứng dụng**, đặt tên (ví dụ "Mộc Hương Android") và chọn nền tảng Android. Tên package là tuỳ chọn, chỉ để ghi nhớ. 3. Khoá chỉ hiện một lần. Truyền nó vào `HatangConfig(appKey = …)`. Khoá ứng dụng công khai giống site ID (nằm trong APK), nhưng thu hồi được. Muốn đổi khoá an toàn: tạo khoá mới, phát hành bản cập nhật dùng khoá đó, rồi thu hồi khoá cũ. Phiên đang dùng khoá bị thu hồi sẽ bị ngắt trong vòng một phút. ### Thêm thư viện Thư viện nằm trên Maven Central, vốn đã có sẵn trong `settings.gradle.kts` của dự án Android mới. ```kotlin title="app/build.gradle.kts" dependencies { implementation("dev.hatang:chat:0.4.1") } ``` Có hai artifact: | Artifact | Gồm | |---|---| | `dev.hatang:chat` | Facade `HatangChat`, giao diện Compose, lưu trữ an toàn, xử lý vòng đời và đính kèm. Dùng artifact này. | | `dev.hatang:chat-core` | Kotlin/JVM thuần: kiểu dữ liệu giao thức, client REST, WebSocket và `ChatEngine` (dạng `StateFlow`). Được `chat` kéo theo; chỉ dùng riêng khi tự dựng giao diện. | SDK dùng các thư viện phổ biến (OkHttp, kotlinx.serialization, coroutines, Compose Material 3, Coil, DataStore, Tink, và bộ mã hoá ZXing core để vẽ mã QR). Nếu ứng dụng đã có sẵn, Gradle chọn phiên bản cao hơn. ### Đính kèm và quyền Nút đính kèm trong ô nhập cho chọn ảnh qua photo picker (`PickVisualMedia`, chỉ ảnh) và tệp (`OpenDocument`). Ảnh HEIC/HEIF được chuyển sang JPEG và thu về 2048 px trước khi tải lên; tệp tối đa 10 MB. Cả hai bộ chọn là giao diện hệ thống, không cần thêm quyền. Manifest của thư viện chỉ thêm `INTERNET` và `ACCESS_NETWORK_STATE`, và khai báo `HatangChatActivity` riêng (với `adjustResize` để ô nhập luôn nằm trên bàn phím). ## Khởi tạo {#initialization} Gọi `HatangChat.configure` một lần trong `Application.onCreate`. Với `autoConnect` (mặc định), SDK bootstrap khách, lưu token và mở socket ngay. ```kotlin title="App.kt" import android.app.Application import dev.hatang.chat.HatangChat import dev.hatang.chat.HatangConfig import dev.hatang.chat.core.protocol.HatangLocale import dev.hatang.chat.platform.logcatLogger class App : Application() { override fun onCreate() { super.onCreate() HatangChat.configure( this, HatangConfig( siteId = "site_…", appKey = "hak_…", locale = HatangLocale.VI, logger = logcatLogger(), // khi đang tích hợp; mặc định là HatangLogger.SILENT ), ) } } ``` | Trường của `HatangConfig` | Mặc định | Ý nghĩa | |---|---|---| | `siteId`, `appKey` | bắt buộc | Lấy ở tab **Ứng dụng** của website. | | `baseUrl` | `HatangChat.DEFAULT_BASE_URL` (`https://chat.hatang.dev`) | Gateway. Chỉ đổi khi chạy gateway local (xem [Khắc phục sự cố](#troubleshooting)). | | `locale` | `null` | `HatangLocale.VI` hoặc `HatangLocale.EN`. Bỏ trống thì theo lựa chọn khách đã lưu, rồi ngôn ngữ mặc định của site, rồi ngôn ngữ máy. | | `themeMode`, `theme`, `darkTheme` | `SITE`, `null`, `null` | Xem [Tuỳ biến giao diện](#theming). | | `strings` | `null` | Ghi đè câu chữ theo ngôn ngữ; xem [Ngôn ngữ](#languages). | | `autoConnect` | `true` | `false` để tự gọi `HatangChat.connect()` sau (ví dụ sau màn hình đồng ý). | | `openOnProactiveMessage` | `true` | Mở chat khi đội nhắn trước và chọn mở trên máy khách. | | `logger` | `HatangLogger.SILENT` | `logcatLogger()` ghi khung socket và lời gọi HTTP ra Logcat. | | `appInfo` | đọc từ package manager và `Build` | Ghi đè thông tin ứng dụng và thiết bị gửi lên gateway. | | `commerce` | `HatangCommerce.STANDARD` | Cách nút **Mua** và **Thanh toán** trên thẻ hoạt động; xem [Thẻ bán hàng và in-app purchase](#commerce). | Gọi `configure` lần nữa sẽ thay engine cũ. Các thành viên khác của facade: | Thành viên | Ý nghĩa | |---|---| | `HatangChat.isConfigured` | `configure` đã chạy. | | `HatangChat.connect()` / `disconnect()` | Hàm suspend: bootstrap và kết nối, hoặc đóng socket nhưng vẫn giữ cấu hình. | | `HatangChat.dispose()` | Giải phóng engine; gọi lại `configure` để dùng tiếp. | | `HatangChat.state` | `StateFlow` chứa mọi thứ giao diện hiển thị (`connection`, `unread`, `availability`, `locale`…). | | `HatangChat.unreadCount` | `StateFlow` số tin nhận được khi màn hình chat không hiện. | | `HatangChat.session` | Id khách, id hội thoại, ngôn ngữ và thông tin danh tính hiện tại. | | `HatangChat.config`, `HatangChat.engine` | Cấu hình và `ChatEngine` bên dưới; cả hai ném lỗi nếu chưa `configure`. | SDK tự theo vòng đời tiến trình: khi ứng dụng vào nền, SDK báo cho gateway (`app.state`) để trả lời mới đi qua thông báo đẩy thay vì socket. ## Nhận diện khách {#identify-visitors} Khi người dùng đăng nhập, gọi `identify` với id trong hệ thống của bạn. `hash` là HMAC-SHA256 (dạng hex) của `userId` với **identity secret** của site. Hãy tính nó **ở backend** rồi trả về ứng dụng; tuyệt đối không đặt secret trong ứng dụng. Danh tính có chữ ký được gộp với các phiên trước của cùng người (kể cả trên web) và hiện nhãn đã xác minh trong hộp thư; không có chữ ký thì thông tin chỉ được lưu như tự khai. Xem [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ```kotlin import dev.hatang.chat.core.session.HatangUser lifecycleScope.launch { // Backend trả về { userId, hash } sau khi đăng nhập HatangChat.identify( HatangUser( userId = me.id, email = me.email, name = me.name, phone = me.phone, hash = me.hatangHash, data = mapOf("plan" to "pro", "orders" to 12), ), ) } ``` `identify` ném `HatangException` (có `code`, `message` và `status`) khi gateway từ chối. Tính `hash` ở backend chỉ cần một dòng: ```js title="Node.js" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` Khi danh tính đã thuộc về một khách khác (ví dụ người dùng từng chat trên máy khác), gateway gộp phiên: SDK nhận token mới, nạp lại hội thoại và phát `HatangSessionChanged(reason = IDENTITY_MERGED)`. Bạn không phải làm gì thêm. ### Đăng xuất ```kotlin lifecycleScope.launch { HatangChat.clearPushToken() // tuỳ chọn: ngừng gửi đẩy tới máy này HatangChat.reset() } ``` `reset()` thu hồi token cũ và bắt đầu một khách mới, để người tiếp theo trên cùng máy không thấy hội thoại của người trước. ## Mở chat và nhúng {#open-and-embed} ### Mở màn hình chat Từ bất kỳ đâu có `Context`, kể cả ứng dụng dùng View: ```kotlin HatangChat.open(context) // hội thoại đang mở (hoặc hội thoại mới) HatangChat.open(context, conversationId = "cnv_…") // một hội thoại cụ thể HatangChat.openInbox(context) // danh sách hội thoại của khách HatangChat.close() ``` `open` khởi chạy `HatangChatActivity` riêng của SDK. `HatangChat.openIntent(context, conversationId)` trả về đúng `Intent` đó, ví dụ để đặt vào một `PendingIntent`. `HatangChat.isOpen` cho biết màn hình chat có đang hiện không. ### Nút chat nổi `HatangChatLauncher` là nút chat Compose có badge số tin chưa đọc. Nút dùng màu thương hiệu của site và biểu tượng, ảnh, nhãn đã cấu hình ở mục **Nút mở chat** trong cài đặt site, và tự ẩn khi đội vắng mặt nếu site yêu cầu. Trước `configure`, nút không hiện gì. ```kotlin Box(Modifier.fillMaxSize()) { HomeContent() HatangChatLauncher( modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp), ) } ``` | Tham số | Mặc định | Ý nghĩa | |---|---|---| | `modifier` | `Modifier` | Vị trí và lề. | | `onClick` | `HatangChat.open(context)` | Việc cần làm khi bấm. | | `showBadge` | `true` | Hiện số tin chưa đọc. | | `engine` | `HatangChat.engine` | Ghi đè engine (kiểm thử, nhiều site). | | `icon` | `null` | Một composable thay cho biểu tượng của site. | ### Nhúng vào điều hướng của bạn Các màn hình là composable bình thường, nên bạn đặt được vào navigation graph hay tab của mình: | Composable | Tham số | |---|---| | `HatangChatScreen` | `modifier`, `conversationId` (mặc định: hội thoại đang mở), `onClose` (hiện nút đóng), `title` (mặc định: tên site), `engine`, `themeMode`, `theme`, `darkTheme`, `strings` | | `HatangConversationView` | Phần hội thoại và ô nhập, không có top bar, để đặt trong tab của bạn: `modifier`, `engine`, `theme`, `darkTheme`, `strings` | | `HatangChatInbox` | `modifier`, `onOpenConversation`, `onNewConversation`, `onClose`, `showTopBar`, `engine` | ```kotlin NavHost(navController, startDestination = "home") { composable("home") { HomeScreen() } composable("support") { HatangChatScreen(onClose = { navController.popBackStack() }) } composable("support/history") { val context = LocalContext.current HatangChatInbox( onOpenConversation = { conv -> HatangChat.open(context, conv.id) }, onNewConversation = { HatangChat.open(context) }, ) } } ``` `HatangChatScreen` vẽ tràn viền và tự chừa lề cho thanh trạng thái, thanh điều hướng và bàn phím. `HatangChat.showMessage("Chị cần tư vấn size không ạ?", senderName = "Mộc Hương")` thêm một bong bóng trợ lý chỉ hiện cục bộ, không gửi lên gateway, ví dụ lời chào theo từng màn hình. ### Thẻ bán hàng và in-app purchase {#commerce} Nhân viên gửi thẻ sản phẩm, thẻ đơn hàng, yêu cầu thanh toán, nút trả lời nhanh và lời mời đánh giá từ hộp thư; SDK vẽ chúng dưới tin nhắn. Mặc định nút **Mua** trên thẻ sản phẩm điền "Mình muốn mua …" vào ô nhập, còn thẻ thanh toán hiện mã VietQR (SDK tự vẽ từ dữ liệu thanh toán), nội dung chuyển khoản và nút **Thanh toán** mở trang checkout web. - **Hàng hoá vật lý và dịch vụ ngoài đời thực** (trà, quần áo, giao hàng, đặt lịch…): giữ mặc định. Chính sách thanh toán của Google Play không cho dùng Play Billing cho những giao dịch này, và Play Billing cũng không thu được một tổng đơn tuỳ ý. - **Nội dung số và tính năng dùng trong ứng dụng** (gói hội viên, xu, khoá học trong app…): bắt buộc bán qua Google Play Billing. Chuyển việc mua cho mã billing của bạn và tắt thanh toán bên ngoài: ```kotlin HatangChat.configure( this, HatangConfig( siteId = "site_…", appKey = "hak_…", commerce = HatangCommerce( onBuyProduct = { context, product -> // Mã sản phẩm Google Play điền cho sản phẩm này trong dashboard; // null: không phải sản phẩm in-app, giữ hành vi mặc định product.googlePlayProductId?.let { id -> billing.launchPurchase(context, id); true } ?: false }, showExternalCheckout = false, // không QR, không nội dung chuyển khoản, không checkout web ), ), ) ``` | Trường của `HatangCommerce` | Ý nghĩa | |---|---| | `onBuyProduct(context, product)` | Nút **Mua** trên thẻ sản phẩm. Trả `true` khi bạn đã xử lý; `false` quay về việc điền sẵn ô nhập. | | `onPay(context, payment)` | Nút **Thanh toán** trên thẻ thanh toán. Trả `true` khi bạn đã xử lý; `false` quay về checkout web. | | `showExternalCheckout` | Mặc định `true`. `false` ẩn mã VietQR, nội dung chuyển khoản và checkout web. | Mã sản phẩm của cửa hàng điền trong dashboard: **Sản phẩm** → mở sản phẩm → **Giá trên App Store và Google Play**. Thẻ sản phẩm khi đó mang `googlePlayProductId` và `appStoreProductId`. Khi `showExternalCheckout = false` mà không có `onPay`, thẻ thanh toán chỉ hiện số tiền và lời nhắn rằng đội sẽ hướng dẫn cách thanh toán. > Toàn bộ quy trình, gồm kết nối cửa hàng, báo giao dịch gia hạn và khôi phục, nhập khách cũ và thử nghiệm, có trong [Mua trong ứng dụng và gói thuê bao](https://hatang.dev/vi/docs/guides/support/in-app-purchases.md). **Gói thuê bao.** Khách trong ứng dụng Android thấy giá Google Play bạn đặt; thẻ gói thuê bao có thêm `basePlanId`, `offerId`, `billingPeriod` (`P1M`, `P1Y`…) và `trialDays`. Để thông báo thời gian thực của Google Play tới đúng khách trong Hạ Tầng, truyền mã tài khoản của khách khi mở luồng thanh toán rồi báo cho đội: ```kotlin val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(details)) .setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id)) // id bạn dùng khi identify .build() // khi onPurchasesUpdated thành công: HatangChat.reportPurchase(storeProductId = "pro", purchaseToken = purchase.purchaseToken, productType = "subs") // "inapp" cho sản phẩm mua một lần ``` Hạ Tầng đọc giao dịch bằng service account Play của bạn trước khi ghi vào hồ sơ khách (token bịa bị từ chối) và **xác nhận (acknowledge)** giao dịch thay bạn (Play hoàn tiền giao dịch không được xác nhận sau ba ngày). Với sản phẩm mua một lần, truyền `productType = "inapp"` và `consumable = true` hoặc `false`; sản phẩm dùng dần vẫn consume trong ứng dụng như thường. Giao dịch mua trước khi khách đăng nhập sẽ chuyển sang khách khi bạn gọi `identify`. Sau đó kết nối ứng dụng ở **Kênh → ứng dụng của bạn → Thuê bao từ App Store và Google Play**: gói, gia hạn, huỷ và hoàn tiền của khách hiện trên hồ sơ, và trợ lý AI cũng biết. Thẻ thanh toán chuyển sang "Đã thanh toán" ngay khi dòng thời gian có tin hoặc sự kiện `order_paid` của đơn hay khoản thanh toán đó, và khi ấy ẩn mã QR, nội dung chuyển khoản và nút thanh toán. ## Sự kiện và dữ liệu {#events-and-data} `HatangChat.events` là một `Flow`, vẫn chạy đúng khi cấu hình lại. Thu thập nó trong một scope theo vòng đời: ```kotlin lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { HatangChat.events.collect { event -> when (event) { is HatangReady -> Log.d("chat", "ready ${event.visitorId}") is HatangMessageReceived -> showInAppBanner(event.message.content) is HatangConversion -> analytics.log(event.kind.name, event.data) is HatangProtocolOutdated -> promptUpdate() else -> Unit } } } } ``` | Sự kiện | Khi nào | |---|---| | `HatangReady(visitorId, conversationId)` | Bootstrap và xác thực xong. | | `HatangOpened`, `HatangClosed` | Màn hình chat hiện hoặc đóng. | | `HatangMessageReceived(message)`, `HatangMessageSent(message)` | Tin từ đội hoặc bot; tin của khách đã được lưu (kể cả gửi từ máy khác). | | `HatangConversationStatus(conversationId, status)` | Hội thoại chuyển sang `open`, `pending`, `resolved` hoặc `closed`. | | `HatangConversion(kind, data)` | `CHAT_STARTED`, `LEAD` hoặc `PURCHASE` (kèm `value`, `currency`, `transaction_id` trong `data`). | | `HatangSessionChanged(visitorId, reason)` | `IDENTITY_MERGED`, `RESET`, `RESTORED` hoặc `TOKEN_REJECTED`. | | `HatangProtocolOutdated(reason)` | Gateway không còn phục vụ phiên bản SDK này; hãy nhắc người dùng cập nhật ứng dụng. | Để hiện badge, thu thập `HatangChat.unreadCount`: ```kotlin val unread by HatangChat.unreadCount.collectAsStateWithLifecycle() BadgedBox(badge = { if (unread > 0) Badge { Text("$unread") } }) { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = "Hỗ trợ") } ``` Gửi ngữ cảnh lên hộp thư: ```kotlin lifecycleScope.launch { HatangChat.setData(mapOf("cart_total" to 450000, "last_order" to "SO-1001")) // hiện cho nhân viên, tối đa 8 KB } HatangChat.pushEvent("viewed_product", mapOf("sku" to "TS-100")) // dòng thời gian của khách HatangChat.setScreen("ProductPage", mapOf("sku" to "TS-100")) // khách đang ở đâu trong ứng dụng ``` ## Thông báo đẩy {#push-notifications} Hạ Tầng gửi thông báo đẩy qua **dự án Firebase của chính bạn** (FCM HTTP v1). Tải tài khoản dịch vụ Firebase lên tab **Ứng dụng** của website, mục **Thông báo đẩy (Firebase)**; xem [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md). Sau đó thêm `com.google.firebase:firebase-messaging` và plugin google-services vào ứng dụng, rồi chuyển token và dữ liệu thông báo cho SDK trong `FirebaseMessagingService` của bạn: ```kotlin title="AppMessagingService.kt" class AppMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { if (!HatangChat.isConfigured) return CoroutineScope(Dispatchers.IO).launch { runCatching { HatangChat.setPushToken(token) } } } override fun onMessageReceived(message: RemoteMessage) { if (!HatangChat.isHatangNotification(message.data)) return // không phải của Hatang: tự xử lý // Tạo thông báo, bấm vào thì mở đúng hội thoại val tap = PendingIntent.getActivity( this, 0, HatangChat.openIntent(this, HatangChat.conversationIdOf(message.data)), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) val notification = NotificationCompat.Builder(this, "hatang_messages") .setSmallIcon(R.drawable.ic_notification) .setContentTitle(message.notification?.title) .setContentText(message.notification?.body) .setAutoCancel(true) .setContentIntent(tap) .build() NotificationManagerCompat.from(this).notify(message.messageId.hashCode(), notification) } } ``` Đăng ký token hiện tại một lần khi khởi động, sau `configure`: ```kotlin FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> lifecycleScope.launch { HatangChat.setPushToken(token) } } ``` Khi ứng dụng ở nền, Android tự hiện thông báo, và bấm vào sẽ mở launcher activity với dữ liệu FCM nằm trong intent extras. Chuyển chúng cho SDK: ```kotlin title="MainActivity.kt" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent.extras?.let { extras -> val data = extras.keySet().associateWith { extras.getString(it).orEmpty() } if (HatangChat.isConfigured && savedInstanceState == null) HatangChat.handleNotification(this, data) } // setContent { … } } ``` - `isHatangNotification(data)` kiểm tra `hatang: "1"` (kể cả khi nằm trong chuỗi JSON `data` lồng bên trong), nên thông báo của bạn và của Hạ Tầng chung sống được. `conversationIdOf(data)` trả về hội thoại mà thông báo trỏ tới. - `handleNotification(context, data)` mở đúng hội thoại đó và trả `false` với payload không phải của Hạ Tầng. Nếu engine chưa chạy (khởi động lạnh), hàm sẽ khởi chạy nó. - Gọi `setPushToken` trước khi SDK sẵn sàng vẫn an toàn: việc đăng ký được hoãn lại và lặp lại khi khách hoặc phiên bản ứng dụng đổi. - Khi ứng dụng đang ở tiền cảnh và có kết nối, hoặc khách đang xem hội thoại, gateway **không** gửi đẩy: tin đến thẳng qua socket. - Từ Android 13 (API 33), cần xin quyền `POST_NOTIFICATIONS`. Hạ Tầng gửi trả lời vào kênh thông báo `hatang_messages`. Tạo kênh có id này trong ứng dụng để đặt tên và mức độ quan trọng trong cài đặt của máy; nếu không, Android hiện thông báo ở kênh dự phòng của Firebase. ## Deep link {#deep-links} Email "chúng tôi đã trả lời" gửi cho khách có link dạng `https:///c/`. Nếu ứng dụng nhận App Link cho tên miền đó (hoặc một scheme riêng với `?hatang_restore=`), hãy khôi phục phiên từ token rồi mở chat: ```kotlin override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val token = intent.data?.let { HatangChat.linkTokenFromUri(it) } ?: return // không phải link của Hatang lifecycleScope.launch { runCatching { HatangChat.restoreSession(token) } HatangChat.open(this@MainActivity) } } ``` `linkTokenFromUri` nhận cả `/c/` và `?hatang_restore=`, trả `null` với link khác. `restoreSession` lưu token phiên mới, bootstrap lại và phát `HatangSessionChanged(reason = RESTORED)`. Token khôi phục chỉ dùng một lần và có hạn, đừng lưu lại. ## Tuỳ biến giao diện {#theming} Mặc định màn hình chat lấy màu thương hiệu và chế độ sáng/tối từ cài đặt site trong dashboard (`HatangChatThemeMode.SITE`), và tự chỉnh màu để chữ luôn dễ đọc. Ghi đè trong `HatangConfig`, hoặc cho từng màn hình qua tham số của composable: ```kotlin HatangConfig( siteId = "site_…", appKey = "hak_…", themeMode = HatangChatThemeMode.SYSTEM, // SITE | SYSTEM | LIGHT | DARK theme = HatangChatTheme.light(accent = Color(0xFF0284C7), fontFamily = FontFamily(Font(R.font.be_vietnam_pro))), darkTheme = HatangChatTheme.dark(accent = Color(0xFF38BDF8)), ) ``` | `themeMode` | Ý nghĩa | |---|---| | `SITE` | Theo cài đặt giao diện của site (sáng, tối hoặc tự động). Mặc định. | | `SYSTEM` | Theo chế độ tối của máy. | | `LIGHT`, `DARK` | Luôn sáng hoặc luôn tối. | Mọi nút đều có nhãn cho TalkBack, vùng chạm 48 dp, giao diện theo cỡ chữ hệ thống và giảm chuyển động khi máy yêu cầu. ### Theo cài đặt widget trong dashboard {#dashboard-settings} Một số phần của `HatangChatScreen` lấy thẳng từ cài đặt widget của site (**Hỗ trợ → Kênh → chọn website**). Đổi trong dashboard là ứng dụng nhận ở lần mở chat sau, không cần sửa code: - **Header**: dòng mô tả dưới tên site (theo ngôn ngữ đang dùng), và kiểu hiển thị: ảnh nhân viên đang trực tuyến hoặc logo doanh nghiệp (không có logo thì hiện chữ cái đầu tên site). - **Lời chào**: bật/tắt, có hiện trên điện thoại không (ứng dụng luôn tính là điện thoại). Nếu người gửi là nhân viên trực tuyến, tên nhân viên hiện dưới lời chào khi đội đang online. Lời nhắc (teaser) trên nút chat chỉ có ở widget web. - **Nút liên hệ**: Zalo, Messenger, gọi điện, email hiện thành hàng nút cuộn ngang dưới header và mở bằng ứng dụng tương ứng. Đổi chữ "Gọi điện" và "Email" qua chuỗi `callUs` và `emailUs`. Với gateway cũ chưa gửi các cài đặt này, màn hình giữ như trước: luôn hiện lời chào, header hiện nhân viên, không có nút liên hệ. `HatangConversationView` không có top bar nên chỉ theo cài đặt lời chào. ## Ngôn ngữ {#languages} SDK có sẵn tiếng Việt và tiếng Anh. Thứ tự chọn ngôn ngữ: `locale` trong `HatangConfig`, lựa chọn khách đã lưu, ngôn ngữ mặc định của site, rồi ngôn ngữ máy. Đổi lúc chạy: ```kotlin lifecycleScope.launch { HatangChat.setLocale(HatangLocale.EN) } ``` `HatangChatStrings` là một data class. Sao chép bộ có sẵn và sửa phần cần thiết: ```kotlin HatangConfig( siteId = "site_…", appKey = "hak_…", strings = mapOf( HatangLocale.VI to HatangChatStrings.VI.copy(placeholder = "Bạn cần hỗ trợ gì?", team = "Mộc Hương"), HatangLocale.EN to HatangChatStrings.EN.copy(placeholder = "How can we help?"), ), ) ``` Chuỗi không sửa giữ nguyên bản gốc. ## Khắc phục sự cố {#troubleshooting} | Triệu chứng | Nguyên nhân và cách xử lý | |---|---| | `HatangException(invalid_app_key 401)` khi khởi động | `appKey` sai hoặc thuộc site khác. Kiểm tra tab **Ứng dụng** của đúng website; khoá bắt đầu bằng `hak_`. | | `HatangException(app_key_revoked 403)` | Khoá đã bị thu hồi trong dashboard. Phát hành bản cập nhật với khoá mới. `isAppKeyError` nhận ra cả hai lỗi khoá. | | `state.connection` là `OUTDATED`, sự kiện `HatangProtocolOutdated` | Gateway đóng socket với mã 4004: phiên bản SDK quá cũ so với giao thức. SDK dừng kết nối lại; nâng cấp `dev.hatang:chat` và phát hành. | | `rate_limit_exceeded` | 5 tin / 10 giây và 60 tin / phút mỗi khách; 30 lần bootstrap / phút mỗi bản cài. `isTransient` và `isRateLimit` cho biết nên thử lại sau. | | Máy ảo gọi được API local nhưng socket không kết nối | Gateway tạo địa chỉ WebSocket theo `CHAT_URL` của nó (`localhost`), nên `10.0.2.2` không đủ. Chạy `adb reverse tcp:3002 tcp:3002` rồi giữ `baseUrl = "http://localhost:3002"`. Điện thoại thật qua Wi-Fi: đặt `CHAT_URL` của gateway thành địa chỉ LAN của máy dev và dùng chính địa chỉ đó làm `baseUrl`. | | Gọi `http://` thất bại ở bản debug | Android chặn traffic không mã hoá theo mặc định. Cho phép host dev trong network security config chỉ dành cho bản debug; production luôn dùng `https://chat.hatang.dev`. | | Không nhận thông báo đẩy | Chưa gọi `setPushToken`, ứng dụng đang ở tiền cảnh hoặc đang mở hội thoại, chưa được cấp quyền thông báo trên Android 13+, hoặc tab **Ứng dụng** của website chưa có tệp Firebase. Bấm **Gửi thử** ở đó để kiểm tra thiết bị gần nhất và xem mục "Lỗi gần nhất". | | Thông báo nằm ở kênh "Miscellaneous" | Ứng dụng chưa tạo kênh `hatang_messages`. Xem [Thông báo đẩy](#push-notifications). | | Bấm thông báo không mở chat | Launcher activity chưa chuyển intent extras cho `handleNotification`, hoặc lúc đó `configure` chưa chạy. | Truyền `logger = logcatLogger()` trong `HatangConfig` để xem khung socket và lời gọi HTTP trong Logcat. ## Changelog {#changelog} **0.4.1** (03/11/2026): trang Maven Central: mô tả rõ hơn, URL dự án là hướng dẫn này, nhà phát triển Sota Labs, thêm file LICENSE. Không đổi code. **0.4.0** (31/10/2026): mua trong ứng dụng. `HatangChat.purchaseToken(userId)` cho mã truyền vào `setObfuscatedAccountId` để thông báo của Google Play về đúng khách; gọi `HatangChat.reportPurchase(...)` sau khi mua: Hạ Tầng xác minh với Google Play và hiện gói hoặc giao dịch trên hồ sơ khách. Thẻ sản phẩm có `basePlanId`, `offerId`, `billingPeriod`, `trialDays` và giá Google Play đặt cho sản phẩm. **0.3.0** (14/10/2026): màn hình chat theo các cài đặt widget mới của site: dòng mô tả theo ngôn ngữ dưới tên site, logo doanh nghiệp (hoặc chữ cái đầu tên site) thay cho nhân viên khi chọn kiểu "doanh nghiệp", ảnh nhân viên, công tắc bật/tắt lời chào và hiện trên điện thoại, tên nhân viên trực tuyến dưới lời chào, và hàng nút Zalo, Messenger, gọi điện, email dưới header. Gateway cũ không gửi các cài đặt này thì màn hình giữ như trước. Thêm `SiteConfig.header`, `SiteConfig.greetingOptions` và `SiteConfig.contactButtons` trong `chat-core`, cùng hai chuỗi `callUs` và `emailUs` (bắt buộc khi bạn tạo `HatangChatStrings` bằng constructor thay vì `copy`). **0.2.0** (13/10/2026): thẻ thanh toán chuyển sang "Đã thanh toán" ngay khi dòng thời gian có tin hoặc sự kiện `order_paid` của đơn hay khoản thanh toán, thẻ đã thanh toán hoặc hết hạn ẩn nội dung chuyển khoản; mã VietQR được vẽ trực tiếp từ payload EMVCo (`qr_payload`, hoặc lấy từ trang thanh toán công khai với thẻ cũ) vì gateway trả `qr_image_url` dạng SVG, có vòng xoay khi tải và nút thử lại khi lỗi (hết hạn sau 15 giây); thẻ sản phẩm, đơn hàng, thanh toán không còn lặp lại bản tóm tắt chữ phía trên thẻ, chỉ giữ câu dẫn. Thêm `PaidMarks`, `PaymentCard.statusPaid` / `isPaid(marks)`, `PaymentCard.qrPayload`, `PaymentQR`, `ProductCard.intro` và `cardBubbleText` trong `chat-core`, cùng phụ thuộc `com.google.zxing:core` (chỉ bộ mã hoá) trong `chat`. **0.1.0** (03/10/2026): bản đầu tiên: màn hình chat, hộp thư và nút chat Jetpack Compose (ứng dụng View mở bằng `HatangChat.open`), gửi ảnh và tệp, nhận diện có chữ ký, hỗ trợ thông báo đẩy FCM, tiếng Việt và tiếng Anh, giao diện sáng/tối. `chat-core` qua bộ kiểm thử tuân thủ C01–C12. Ghi chú phát hành có trong [nhật ký thay đổi](https://hatang.dev/vi/docs/changelog). Xem thêm: [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md), [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md), [Tương thích](https://hatang.dev/vi/docs/guides/sdks/compatibility.md) (phiên bản giao thức và gateway tối thiểu) và [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md). Source: https://hatang.dev/vi/docs/guides/sdks/android --- # Tương thích và chính sách phiên bản Trang này liệt kê mọi SDK của Hạ Tầng với phiên bản hiện tại, khoảng phiên bản còn được sửa lỗi, phiên bản giao thức chat mà SDK nói và nền tảng chạy được. Bảng sinh từ `sdks/compat.json` trong kho mã Hạ Tầng, nguồn duy nhất cho các con số này; cột "Hiện tại" đọc từ manifest của từng gói lúc build tài liệu. ## Ma trận hỗ trợ {#support-matrix} Mọi SDK dưới đây nói giao thức chat phiên bản **1**. Gateway phát hành theo tháng (YYYY.MM); "Gateway tối thiểu" là bản gateway cũ nhất mà các phiên bản SDK được liệt kê đã được kiểm thử. | SDK | Gói | Trạng thái | Hiện tại | Hỗ trợ | Giao thức | Gateway tối thiểu | Nền tảng | |---|---|---|---|---|---|---|---| | widget.js | [chat.hatang.dev/widget.js](https://chat.hatang.dev/widget.js) | Ổn định | — | always current (served by the gateway) | 1 | 2026.10 | Browsers with ES2020 (Chrome 80+, Safari 14+, Firefox 78+, Edge 80+) | | @hatang/chat-react | [npm: @hatang/chat-react](https://www.npmjs.com/package/@hatang/chat-react) | Ổn định | 0.1.2 | 0.1.x | 1 | 2026.10 | React 18+; Next.js 14+ (App Router and Pages) | | @hatang/sdk | [npm: @hatang/sdk](https://www.npmjs.com/package/@hatang/sdk) | Ổn định | 0.6.2 | 0.6.x | 1 | 2026.10 | Node 20+; Bun 1.1+; Edge runtimes with fetch (User-Agent reports node/unknown) | | hatang (Python) | [PyPI: hatang](https://pypi.org/project/hatang/) | Ổn định | 0.1.1 | 0.1.x | 1 | 2026.10 | Python 3.9+ (sync and asyncio, httpx) | | hatang_chat (Flutter) | [pub.dev: hatang_chat](https://pub.dev/packages/hatang_chat) | Ổn định | 0.6.1 | 0.6.x | 1 | 2026.10 | Flutter 3.32+; Dart 3.8+; iOS 15+; Android 6.0 (API 23)+; macOS 11+; Web; Windows 10+; Linux (secure storage falls back to preferences) | | @hatang/chat-react-native | [npm: @hatang/chat-react-native](https://www.npmjs.com/package/@hatang/chat-react-native) | Ổn định | 0.4.2 | 0.4.x | 1 | 2026.10 | React Native 0.76+; Expo SDK 52+ | | iOS (Swift) | [GitHub: sota-labs/hatang-chat-ios](https://github.com/sota-labs/hatang-chat-ios) | Ổn định | 0.4.1 | 0.4.x | 1 | 2026.10 | iOS 17+; Swift 6 / Xcode 16+; SwiftUI and UIKit (HatangChatViewController) | | Android (Kotlin) | [Maven Central: dev.hatang:chat](https://central.sonatype.com/artifact/dev.hatang/chat) | Ổn định | 0.4.1 | 0.4.x | 1 | 2026.10 | Android 8+ (API 26); Kotlin 1.9+ / 2.x; Jetpack Compose (BOM 2024.12+); View-based apps open the chat through HatangChat.open | | WordPress plugin | [WordPress.org: hatang-chat](https://wordpress.org/plugins/hatang-chat/) | Ổn định | 0.2.3 | 0.2.x | 1 | 2026.10 | WordPress 6.0+; PHP 7.4+ | ## Đánh số phiên bản {#versioning} - **Mỗi gói có phiên bản riêng.** Mọi SDK theo semantic versioning và có changelog riêng. Bản phát hành được gắn tag `-v`: `sdk-v*`, `chat-react-v*`, `chat-react-native-v*`, `flutter-v*`, `ios-v*`, `android-v*` và `wp-plugin-v*`. - **Trước 1.0, bản minor có thể đổi API.** Hiện mọi SDK đều ở 0.x. Một bản minor mới (ví dụ 0.4 lên 0.5) có thể có thay đổi không tương thích, và changelog ghi rõ thay đổi gì, chuyển đổi thế nào. Cột "Hỗ trợ" chuyển sang bản minor mới khi bản đó ra mắt. - **Kiểm thử tuân thủ trước khi phát hành.** Mọi SDK chat chạy cùng bộ kịch bản (C01–C12: bootstrap bằng khoá ứng dụng, xác thực, gửi tin và dedupe, resume và lịch sử, nhận diện và gộp phiên, đang gõ và đã đọc, tải tệp, trạng thái và đánh giá, kết nối lại, giao thức cũ, thẻ, thiết bị và trạng thái ứng dụng) với gateway thật. Muốn phát hành phải chạy qua hết; bộ kịch bản cũng chạy hằng đêm. - **Bảo mật.** Báo lỗ hổng tới security@hatang.dev. Không SDK client nào chứa secret: identity secret chỉ xuất hiện trong đoạn mã HMAC phía server. ## Phiên bản giao thức {#protocol-versions} | Giao thức | Ra mắt | Gateway | Ghi chú | |---|---|---|---| | 1 | 2026-10 | 2026.10+ | Giao thức ổn định đầu tiên: auth → ready, send → ack, resume theo `seq`, nhận diện và gộp phiên, đang gõ, biên nhận đã đọc, tải tệp, thẻ, đăng ký thiết bị | Client nên bỏ qua những gì nó không biết: loại sự kiện hội thoại lạ thì bỏ qua, thẻ có `metadata.kind` lạ thì hiện như tin thường. Khi gateway không còn phục vụ giao thức của một client, nó đóng socket với mã 4004 và mọi SDK báo lỗi "cần cập nhật ứng dụng" (kịch bản tuân thủ C10). Chi tiết các khung ở [Giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md). ## Nền tảng {#platforms} | SDK | Tối thiểu | |---|---| | `@hatang/sdk` | Node 20, Bun 1.1, hoặc edge runtime có `fetch` | | `@hatang/chat-react` | React 18, Next.js 14 | | `hatang_chat` (Flutter) | Flutter 3.32 / Dart 3.8; ứng dụng chạy iOS 15 và Android 6.0 (API 23) | | `@hatang/chat-react-native` | React Native 0.76, Expo SDK 52 | | iOS (Swift) | iOS 17, Swift 6 / Xcode 16 | | Android (Kotlin) | Android 8.0 (API 26), Kotlin 1.9, Compose BOM 2024.12 | | Plugin WordPress | WordPress 6.0, PHP 7.4 | ## Bước tiếp theo - [Tổng quan SDK](https://hatang.dev/vi/docs/guides/sdks/overview.md): mỗi SDK làm gì. - [Giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md): khung, mã đóng và giới hạn. - [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md): phần cài đặt chung cho mọi SDK di động. Source: https://hatang.dev/vi/docs/guides/sdks/compatibility --- # SDK Flutter `hatang_chat` > **Info: Phiên bản** > > `hatang_chat` **0.6.1** · giao thức v1 · Flutter 3.32+ / Dart 3.8+ · iOS 15+ · Android 6.0 (API 23)+ · web (cả WebAssembly) · [pub.dev](https://pub.dev/packages/hatang_chat) · [Changelog](https://pub.dev/packages/hatang_chat/changelog) · [Tham chiếu API (dartdoc)](https://hatang.dev/docs/reference/flutter/index.html) > **Note** > > Bản 0.5 có màn hình chat và nút chat nổi, nhận diện, sự kiện, thông báo đẩy, deep link, gửi ảnh và tệp, thẻ sản phẩm/đơn hàng/thanh toán kèm hook in-app purchase, hộp thư các hội thoại cũ, và màn hình chat theo cài đặt widget của site. API có thể còn thay đổi trước bản 1.0. `hatang_chat` là màn hình chat native cho ứng dụng Flutter: nói thẳng [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) với gateway Hạ Tầng (không web view), có màn hình chat, nút chat nổi, hộp thư các hội thoại cũ, thông báo đẩy qua Firebase của bạn và deep link. Khách nhắn từ ứng dụng vào cùng hộp thư với khách trên website, Zalo, Messenger, Instagram và TikTok Shop.

Màn chat với thẻ sản phẩm và đơn hàng Màn chat ở chế độ tối Danh sách hội thoại của khách Nút chat có số tin chưa đọc trong ứng dụng

## Cài đặt {#installation} ### Dùng thử không cần tài khoản Ứng dụng mẫu đi kèm gói nói chuyện với hộp thư demo công khai của Hạ Tầng: trợ lý AI trả lời các câu hỏi về Hạ Tầng, tin nhắn tự xoá sau 24 giờ. ```bash dart pub unpack hatang_chat cd hatang_chat-*/example flutter run ``` Với demo, ứng dụng mẫu nhận **thông báo đẩy thật** trên Android và iOS simulator mà không cần cấu hình: hỏi một câu, chuyển ứng dụng vào nền, câu trả lời của trợ lý đến dưới dạng thông báo. Tab **Push** hiện token, cho dán token và giả lập bấm thông báo. Truyền `--dart-define=HATANG_SITE_ID=… --dart-define=HATANG_APP_KEY=…` để trỏ ứng dụng mẫu vào site hỗ trợ của bạn; thêm `--dart-define=FIREBASE_API_KEY=… FIREBASE_APP_ID=… FIREBASE_PROJECT_ID=… FIREBASE_SENDER_ID=…` (lấy từ tệp cấu hình Firebase của ứng dụng) để thử đẩy với dự án Firebase của bạn. ### Lấy khoá ứng dụng trong dashboard SDK cần hai giá trị: **Site ID** và một **khoá ứng dụng** (`hak_…`). Khoá thay cho kiểm tra Origin của trình duyệt: ứng dụng gửi khoá khi khởi tạo, gateway biết phiên đến từ ứng dụng nào. 1. Trong dashboard, mở **Hỗ trợ → Kênh → Thêm kênh → Ứng dụng di động**. 2. Bấm **Tạo khoá ứng dụng**: đặt tên (ví dụ "Mộc Hương iOS"), chọn nền tảng Flutter, bundle id tuỳ chọn. 3. Khoá chỉ hiện một lần; đặt vào `HatangChat.configure(appKey:)`. Khoá công khai như site id (nằm trong bản build) nhưng thu hồi được. Để đổi khoá an toàn: tạo khoá mới, phát hành bản cập nhật dùng khoá đó, rồi thu hồi khoá cũ. Phiên của khách đang dùng khoá bị thu hồi bị ngắt trong vòng một phút. ### Thêm gói ```bash flutter pub add hatang_chat ``` ```yaml title="pubspec.yaml" dependencies: hatang_chat: ^0.6.1 ``` ### Quyền nền tảng {#platform-permissions} Ô nhập có nút đính kèm: camera (điện thoại), thư viện ảnh và tệp (tối đa 10 MB). Ảnh HEIC của iPhone được chuyển sang JPEG và ảnh lớn thu về 2048 px trước khi gửi; tin nhắn hiện tiến trình tải lên và bấm để thử lại nếu lỗi. Ảnh nhận được mở toàn màn hình, phóng to bằng hai ngón hoặc chạm đúp. Mục **Tệp** chỉ hiện khi bạn truyền bộ chọn tệp cho `configure`: `pickFile: hatangFilePicker` (import `package:hatang_chat/file_picker.dart`, dùng `file_picker` 13), hoặc một hàm `Future Function()` của riêng bạn nếu app đã ghim phiên bản `file_picker` khác. Gửi tệp từ giao diện riêng: `controller.attach(UploadSource(bytes: …, name: 'bao-gia.pdf', mimeType: 'application/pdf'))`. Đính kèm ảnh và camera cần khai báo lý do trên iOS; Android không cần quyền thêm với bộ chọn hệ thống. ```xml title="ios/Runner/Info.plist" NSPhotoLibraryUsageDescription Gửi ảnh cho bộ phận hỗ trợ NSCameraUsageDescription Chụp ảnh gửi cho bộ phận hỗ trợ ``` Gói chạy trên iOS, Android, macOS, Windows, Linux và web; token phiên lưu trong keychain / keystore (trên Linux rơi về shared preferences). ## Khởi tạo {#initialization} Gọi `HatangChat.configure` một lần trong `main()` trước `runApp`. SDK bootstrap khách, lưu token và mở socket ngay (`autoConnect: true`). ```dart title="lib/main.dart" import 'package:flutter/material.dart'; import 'package:hatang_chat/hatang_chat.dart'; final navigatorKey = GlobalKey(); Future main() async { WidgetsFlutterBinding.ensureInitialized(); await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', // baseUrl: Uri.parse('https://chat.hatang.dev'), // mặc định locale: HatangLocale.vi, navigatorKey: navigatorKey, // để SDK mở chat từ thông báo đẩy ); runApp(MaterialApp(navigatorKey: navigatorKey, home: const HomePage())); } ``` | Tham số | Ý nghĩa | |---|---| | `siteId`, `appKey` | Bắt buộc; lấy ở tab **Ứng dụng** của website | | `baseUrl` | Gateway. Mặc định `https://chat.hatang.dev`; chỉ đổi khi chạy gateway local (xem [Khắc phục sự cố](#troubleshooting)) | | `locale` | `HatangLocale.vi` / `.en`. Bỏ trống thì theo lựa chọn khách đã lưu, rồi site, rồi máy | | `themeMode`, `theme`, `darkTheme` | Xem [Tuỳ biến giao diện](#theming) | | `strings` | Ghi đè chuỗi theo ngôn ngữ, xem [Ngôn ngữ](#languages) | | `navigatorKey` | Cho `handleNotification` và tin đội nhắn trước mở màn hình khi bạn không truyền `context` | | `autoConnect` | `false` để tự gọi `HatangChat.instance.connect()` sau (ví dụ sau màn hình đồng ý) | | `openOnProactiveMessage` | Mở màn hình chat khi đội nhắn trước (mặc định `true`) | | `commerce` | Cách nút **Mua** và **Thanh toán** trên thẻ hoạt động, xem [Thẻ bán hàng và in-app purchase](#commerce) | | `pickFile` | Bật mục **Tệp** khi đính kèm, xem [Quyền nền tảng](#platform-permissions) | | `logger`, `appInfo` | Ghi log, và ghi đè tên/phiên bản ứng dụng gửi lên gateway | `HatangChat.instance.session` cho biết `visitorId`, `conversationId`, `locale`, `isIdentified`, `email`, `name` hiện tại. ## Nhận diện khách {#identify-visitors} Khi người dùng đăng nhập, gọi `identify` với id trong hệ thống của bạn. `hash` là HMAC-SHA256 của `userId` với identity secret của site, **tính ở backend của bạn** rồi trả về ứng dụng; đừng bao giờ đưa secret vào ứng dụng. Danh tính có chữ ký được gộp với các phiên trước của cùng người (kể cả trên web); không có chữ ký chỉ được lưu như thông tin tự khai. Chi tiết ở [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ```dart // Backend trả về {userId, hash} sau khi đăng nhập await HatangChat.instance.identify(HatangUser( userId: me.id, email: me.email, name: me.name, phone: me.phone, hash: me.hatangHash, data: {'plan': 'pro', 'orders': 12}, )); ``` Một dòng ở backend để tính `hash`: ```js title="Node" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` Khi gộp danh tính, SDK nhận token mới, nạp lại hội thoại và phát `HatangSessionChanged(reason: identityMerged)`; bạn không phải làm gì thêm. Khi đăng xuất, gọi `HatangChat.instance.reset()` để phiên tiếp theo trên cùng máy là một khách mới. ## Mở chat và nhúng {#open-and-embed} ### Mở toàn màn hình ```dart ElevatedButton( onPressed: () => HatangChat.open(context), child: const Text('Chat với chúng tôi'), ) ``` `open` đẩy một `MaterialPageRoute(fullscreenDialog: true)` (chuyển cảnh Cupertino trên iOS). Truyền `conversationId:` để mở một hội thoại cũ, `HatangChat.close()` để đóng. Ứng dụng dùng go_router có thể khai báo route riêng render `HatangChatScreen`: ```dart GoRoute(path: '/support', builder: (_, __) => const HatangChatScreen(showClose: false)), ``` ### Hộp thư hội thoại `HatangChat.openInbox(context)` mở danh sách các hội thoại của khách (người trả lời, tin cuối, giờ, số chưa đọc, đã giải quyết); kéo xuống để làm mới, hoặc đặt `HatangChatInbox()` vào một tab của bạn. `HatangChat.open(context, conversationId: id)` mở đúng hội thoại đó; cuộn lên để tải tin cũ hơn. Khách nhắn vào hội thoại đã giải quyết thì hội thoại mở lại; hội thoại đã đóng chỉ xem được, kèm nút "Tin nhắn mới". ### Nút chat nổi `HatangChatLauncher` là bong bóng chat với badge chưa đọc, tự ẩn khi site cấu hình ẩn lúc offline. Biểu tượng, ảnh và chữ trên nút lấy theo mục **Nút mở chat** trong cài đặt site (giống widget web); có chữ thì nút thành dạng dài như "Hỗ trợ". Truyền `child` để dùng biểu tượng của riêng bạn. Đặt trong `Stack` của màn hình chính: ```dart Stack(children: [ const HomeBody(), HatangChatLauncher(alignment: Alignment.bottomRight, margin: const EdgeInsets.all(16)), ]) ``` ### Nhúng vào màn hình của bạn `HatangConversationView` là phần thân hội thoại (dòng tin, ô nhập, đính kèm) không có app bar, để đặt dưới tab bar hay trong một tab "Hỗ trợ" của bạn. Nó cần một `HatangChatController`: ```dart class SupportTab extends StatefulWidget { const SupportTab({super.key}); @override State createState() => _SupportTabState(); } class _SupportTabState extends State { final controller = HatangChatController(); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Hỗ trợ')), body: HatangConversationView(controller: controller, bottomPadding: 56), ); } } ``` Chỉ gắn view khi nó đang hiển thị (không để trong `IndexedStack` giữ các tab ẩn), hoặc gọi `controller.setVisible(false)` khi tab bị ẩn: chat đang hiển thị sẽ đánh dấu tin đã đọc. `HatangChat.instance.setBottomPadding(56)` nâng nút chat nổi và màn hình chat lên trên tab bar hay bottom sheet của bạn; các màn hình đang mở cập nhật ngay. Controller có `state`, `send(text)`, `retry(clientId)`, `pickAndSendImage()`, `pickAndSendFile()`, `loadEarlier()`, `rate(score, comment: …)`, `setVisible(bool)` (gọi khi tab hiện/ẩn để số chưa đọc và biên nhận đọc đúng). `HatangChatInbox` liệt kê các hội thoại cũ (đã giải quyết chỉ đọc) và nhận `onOpenConversation`. `HatangChat.instance.showMessage(text)` chèn một bong bóng bot cục bộ (ví dụ lời chào theo ngữ cảnh), không gửi lên server. ### Thẻ bán hàng và in-app purchase {#commerce} Nhân viên gửi thẻ sản phẩm, đơn hàng, yêu cầu thanh toán, nút trả lời nhanh và đánh giá sao từ hộp thư; SDK hiển thị chúng dưới tin nhắn. Mặc định nút **Mua** điền "Mình muốn mua …" vào ô nhập, còn thẻ thanh toán hiện mã VietQR, nội dung chuyển khoản và nút **Thanh toán** mở trang checkout. Cách này dành cho hàng hoá và dịch vụ ngoài đời thực: App Store (Guideline 3.1.3(e)) và Google Play không cho bán chúng qua in-app purchase, và in-app purchase cũng không thu được một tổng đơn tuỳ ý. App Store và Google Play bắt buộc bán **nội dung số và tính năng dùng trong ứng dụng** qua in-app purchase (App Store Review Guideline 3.1.1, chính sách thanh toán của Google Play). Với các sản phẩm đó, chuyển việc mua cho tích hợp cửa hàng của bạn và ẩn thanh toán bên ngoài: ```dart await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', commerce: HatangCommerce( showExternalCheckout: !Platform.isIOS, // iOS: không QR, không nội dung CK, không checkout web onBuyProduct: (context, product) async { // Mã App Store / Google Play điền cho từng sản phẩm trong dashboard final storeId = Platform.isIOS ? product.appStoreProductId : product.googlePlayProductId; if (storeId == null) return false; // không phải sản phẩm in-app: giữ hành vi mặc định await myIapService.buy(storeId); // ví dụ package:in_app_purchase return true; }, ), ); ``` Mã sản phẩm của cửa hàng điền trong dashboard: **Sản phẩm** → mở sản phẩm → **Giá trên App Store và Google Play** (**Mã sản phẩm App Store** và **Mã sản phẩm Google Play**); thẻ sản phẩm gửi kèm hai mã này. Hook trả về `true` khi bạn đã xử lý. Khi tắt `showExternalCheckout` mà không có `onPay`, thẻ thanh toán chỉ hiện số tiền và câu báo nhân viên sẽ hướng dẫn khách hoàn tất thanh toán. SDK không phụ thuộc thư viện cửa hàng nào; `HatangConversationView(commerce: …)` ghi đè cấu hình chung cho một màn hình nhúng. > Toàn bộ quy trình, gồm kết nối cửa hàng, báo giao dịch gia hạn và khôi phục, nhập khách cũ và thử nghiệm, có trong [Mua trong ứng dụng và gói thuê bao](https://hatang.dev/vi/docs/guides/support/in-app-purchases.md). **Gói thuê bao.** Trong ứng dụng, thẻ hiện giá cửa hàng bạn đặt cho nền tảng đó (App Store trên iOS, Google Play trên Android); thẻ gói thuê bao có thêm `billingPeriod`, `trialDays`, và với Google Play là `basePlanId`, `offerId`. Truyền `HatangChat.instance.purchaseToken(userId)` làm `applicationUserName` trong `PurchaseParam` của `in_app_purchase` (thành appAccountToken của StoreKit và obfuscated account id của Play), và sau khi mua thành công gọi `HatangChat.instance.reportPurchase(platform: Platform.isIOS ? 'ios' : 'android', storeProductId: …, transactionId: …)` kèm bằng chứng của cửa hàng: `signedTransaction: purchase.verificationData.serverVerificationData` trên iOS (bật StoreKit 2 trong `in_app_purchase_storekit`), hoặc `purchaseToken: purchase.verificationData.serverVerificationData, productType: 'subs'` (`'inapp'` cho sản phẩm mua một lần) trên Android. Hạ Tầng kiểm tra với Apple hoặc Google trước khi ghi vào hồ sơ khách, và xác nhận (acknowledge) giao dịch Google Play. Sau đó kết nối ứng dụng ở **Kênh → ứng dụng của bạn → Thuê bao từ App Store và Google Play**. ## Sự kiện và dữ liệu {#events-and-data} ```dart final sub = HatangChat.instance.events.listen((e) { switch (e) { case HatangReady(:final visitorId): debugPrint('ready $visitorId'); case HatangMessageReceived(:final message): showInAppBanner(message.content); case HatangConversion(:final kind, :final data): analytics.log(kind.name, data); case HatangProtocolOutdated(): promptUpdate(); case _: break; } }); ``` | Sự kiện | Khi nào | |---|---| | `HatangReady` | Bootstrap và xác thực xong | | `HatangOpened`, `HatangClosed` | Màn hình chat hiện, ẩn | | `HatangMessageReceived`, `HatangMessageSent` | Tin từ đội/bot; tin của khách được lưu (kể cả từ máy khác) | | `HatangConversationStatus` | `open`, `pending`, `resolved`, `closed` | | `HatangConversion` | `chatStarted`, `lead`, `purchase` (kèm `value`, `currency`, `transaction_id`) | | `HatangSessionChanged` | `identityMerged`, `reset`, `restored`, `tokenRejected` | | `HatangProtocolOutdated` | Gateway không còn phục vụ phiên bản này; yêu cầu cập nhật ứng dụng | Các stream khác: `unreadCount` (và `unreadCountListenable` cho badge), `availability` (đội online, trong giờ làm việc, giờ mở lại), `connectionState` (`disconnected`, `connecting`, `connected`, `outdated`). ```dart ValueListenableBuilder( valueListenable: HatangChat.instance.unreadCountListenable, builder: (_, n, __) => Badge(label: Text('$n'), isLabelVisible: n > 0, child: const Icon(Icons.chat_bubble_outline)), ) ``` Gửi dữ liệu lên hộp thư: ```dart await HatangChat.instance.setData({'cart_total': 450000, 'last_order': 'SO-1001'}); // hiển thị cho nhân viên, ≤ 8 KB await HatangChat.instance.pushEvent('viewed_product', {'sku': 'TS-100'}); // dòng thời gian của khách HatangChat.instance.setScreen('ProductPage', params: {'sku': 'TS-100'}); // nhân viên thấy khách đang ở đâu ``` SDK tự gửi `app.state` khi ứng dụng vào nền / trở lại, nên khi ở nền khách được coi là offline và trả lời đi qua thông báo đẩy. ## Thông báo đẩy {#push-notifications} Hạ Tầng gửi đẩy qua **dự án Firebase của bạn** (FCM HTTP v1): tải tệp tài khoản dịch vụ lên tab **Ứng dụng** của website (xem [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md)). SDK không phụ thuộc Firebase; ứng dụng dùng `firebase_messaging` và chuyển token cho SDK. ```bash flutter pub add firebase_core firebase_messaging ``` ```dart title="lib/push.dart" import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:hatang_chat/hatang_chat.dart'; Future wirePush() async { final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); // Đăng ký thiết bị; SDK giữ lại và đăng ký sau nếu chưa có phiên final token = await messaging.getToken(); if (token != null) await HatangChat.instance.setPushToken(token, provider: PushProvider.fcm); messaging.onTokenRefresh.listen((t) => HatangChat.instance.setPushToken(t, provider: PushProvider.fcm)); // Ứng dụng ở nền, người dùng bấm thông báo FirebaseMessaging.onMessageOpenedApp.listen((m) { if (HatangChat.isHatangNotification(m.data)) HatangChat.instance.handleNotification(m.data); }); // Khởi động lạnh từ thông báo: SDK giữ yêu cầu đến khi sẵn sàng rồi mới mở hội thoại final initial = await messaging.getInitialMessage(); if (initial != null && HatangChat.isHatangNotification(initial.data)) { await HatangChat.instance.handleNotification(initial.data); } } ``` Gọi `wirePush()` sau `HatangChat.configure` và `Firebase.initializeApp`. `isHatangNotification` kiểm tra `data["hatang"] == "1"` (chấp nhận cả `data` lồng), nên thông báo của bạn và của Hạ Tầng chung sống được. `handleNotification` mở đúng hội thoại trong `data["conversation_id"]`; truyền `context:` hoặc để SDK dùng `navigatorKey`. Hàm trả về ngay khi đã mở (hoặc đã giữ yêu cầu lúc khởi động lạnh), không đợi khách đóng chat, nên `await` trong mã khởi động không làm treo ứng dụng. Khi ứng dụng đang ở tiền cảnh và kết nối, gateway **không** gửi đẩy (tin đến thẳng qua socket), nên `FirebaseMessaging.onMessage` hiếm khi nhận dữ liệu Hạ Tầng. Gọi `clearPushToken()` khi đăng xuất; `reset()` tự đăng ký lại token cho khách mới. **Android 8+**: Hạ Tầng gửi trả lời vào kênh thông báo `hatang_messages`. Tạo kênh có id này (ví dụ bằng `flutter_local_notifications`) để đặt tên và mức ưu tiên hiển thị trong cài đặt của máy; nếu không, Android hiện thông báo ở kênh dự phòng của Firebase. **iOS**: ngoài ứng dụng iOS trong Firebase, cần App ID khớp chính xác bundle ID và APNs key đã tải lên Firebase (xem [Thông báo đẩy → Ứng dụng iOS](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md#ios-apps)), rồi trong Xcode: 1. Runner → **Signing & Capabilities** → chọn Team sở hữu App ID. 2. **+ Capability → Push Notifications** (thêm `aps-environment` vào entitlements). 3. **+ Capability → Background Modes** → tick **Remote notifications**. Ứng dụng mẫu (`example/ios`) đã cấu hình sẵn cả ba để tham khảo. ## Deep link {#deep-links} Email "chúng tôi đã trả lời" gửi cho khách có link `https:///c/`. Nếu ứng dụng của bạn nhận universal link / app link cho tên miền đó (hoặc một scheme riêng với `?hatang_restore=`), khôi phục phiên từ token rồi mở chat: ```dart // app_links, go_router hay bất kỳ nguồn Uri nào Future onUri(Uri uri, BuildContext context) async { final token = HatangChat.linkTokenFromUri(uri); if (token == null) return; // không phải link của Hatang await HatangChat.instance.restoreSession(token); if (context.mounted) await HatangChat.open(context); } ``` `linkTokenFromUri` nhận cả `/c/` và `?hatang_restore=`. `restoreSession` lưu token phiên mới, bootstrap lại và phát `HatangSessionChanged(reason: restored)`; token khôi phục dùng một lần và có hạn, nên đừng lưu nó. ## Tuỳ biến giao diện {#theming} Mặc định SDK lấy màu thương hiệu và chế độ sáng/tối từ cấu hình site trong dashboard (`themeMode: HatangChatThemeMode.site`). Ghi đè bằng `theme` / `darkTheme`: ```dart await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', themeMode: HatangChatThemeMode.system, // site | light | dark | system theme: HatangChatTheme.light(accent: const Color(0xFF0284C7), fontFamily: 'Be Vietnam Pro'), darkTheme: HatangChatTheme.dark(accent: const Color(0xFF38BDF8)), ); ``` `HatangChatTheme` là một `ThemeExtension`: đặt vào `ThemeData.extensions` của ứng dụng để `HatangChatScreen` và `HatangConversationView` theo theme của bạn khi bạn không truyền qua `configure`; `copyWith` và `lerp` có sẵn. Màn hình tự bọc `Theme`, `Material` và `Localizations.override` nên chạy trong `CupertinoApp` không cần thêm delegate. Chữ và biểu tượng giữ tương phản tối thiểu 4.5:1 với màu bạn chọn (SDK tự làm đậm/nhạt màu khi cần), theo cỡ chữ hệ thống tới 200% và giảm chuyển động khi máy yêu cầu. ### Theo cài đặt widget trong dashboard {#widget-settings} Một số phần của `HatangChatScreen` lấy thẳng từ cài đặt widget của site (**Hỗ trợ → Kênh → chọn website**). Đổi trong dashboard là app nhận ở lần mở chat sau, không cần sửa code: - **Header**: dòng mô tả dưới tên site (theo ngôn ngữ đang dùng) và kiểu hiển thị: ảnh nhân viên đang trực tuyến, hoặc logo doanh nghiệp (không có logo thì hiện chữ cái đầu tên site). - **Lời chào**: bật/tắt, có hiện trên điện thoại hay không (SDK coi mọi nền tảng là điện thoại, kể cả tablet, desktop và web). Nếu người gửi là nhân viên trực tuyến, tên nhân viên hiện dưới lời chào khi đội đang online. Lời nhắc (teaser) trên nút chat chỉ có ở widget web. - **Nút liên hệ**: Zalo, Messenger, gọi điện, email hiện thành hàng nút cuộn ngang dưới header, mở bằng app tương ứng của máy (qua `url_launcher`). Chữ "Gọi điện" / "Email" ghi đè được qua chuỗi `callUs`, `emailUs`. Với gateway cũ chưa gửi các cài đặt này, màn hình giữ như trước: luôn hiện lời chào, header hiện nhân viên, không có nút liên hệ. `HatangConversationView` (không có app bar) chỉ theo cài đặt lời chào. ## Ngôn ngữ {#languages} SDK có sẵn tiếng Việt và tiếng Anh; thứ tự chọn: `locale` trong `configure`, lựa chọn khách đã lưu, ngôn ngữ mặc định của site, rồi ngôn ngữ máy. Đổi lúc chạy: ```dart await HatangChat.instance.setLocale(HatangLocale.en); ``` Ghi đè từng chuỗi theo ngôn ngữ (chuỗi không ghi đè giữ bản gốc): ```dart await HatangChat.configure( siteId: 'site_…', appKey: 'hak_…', strings: { HatangLocale.vi: HatangChatStrings.vi.copyWith(placeholder: 'Bạn cần hỗ trợ gì?', team: 'Mộc Hương'), HatangLocale.en: HatangChatStrings.en.copyWith(placeholder: 'How can we help?'), }, ); ``` Các khoá hay dùng: `placeholder`, `team`, `callUs`, `emailUs` và `poweredBy` (dòng "Chat bởi hatang.dev" dưới ô nhập). Trong widget của bạn, `HatangChatLocalizations.of(context).strings` cho chuỗi đang dùng; thêm `HatangChatLocalizations.delegate` vào `localizationsDelegates` của `MaterialApp` nếu muốn tra cứu ngoài màn hình chat. ## Khắc phục sự cố {#troubleshooting} | Triệu chứng | Nguyên nhân và cách xử lý | |---|---| | `HatangException(invalid_app_key 401)` khi khởi tạo | `appKey` sai hoặc thuộc site khác. Kiểm tra tab **Ứng dụng** của đúng website; khoá bắt đầu bằng `hak_`. | | `HatangException(app_key_revoked 403)` | Khoá đã bị thu hồi trong dashboard. Phát hành bản cập nhật với khoá mới; hiện thông báo "cần cập nhật ứng dụng" cho người dùng bản cũ (`HatangException.isAppKeyError`). | | `connectionState` là `outdated`, sự kiện `HatangProtocolOutdated` | Gateway đóng socket với mã 4004: SDK quá cũ so với giao thức. SDK dừng kết nối lại; nâng cấp `hatang_chat` và phát hành. | | Máy ảo Android gọi được API local nhưng socket không kết nối | Gateway trả địa chỉ WebSocket theo `CHAT_URL` của nó (`localhost`), nên `10.0.2.2` không đủ. Chạy `adb reverse tcp:3002 tcp:3002` rồi giữ `baseUrl: Uri.parse('http://localhost:3002')`. Điện thoại thật qua Wi-Fi: đặt `CHAT_URL` của gateway thành IP LAN của máy dev và dùng chính địa chỉ đó làm `baseUrl`. | | iOS không gọi được `http://` khi dev | App Transport Security chặn HTTP thường. Thêm ngoại lệ `NSAppTransportSecurity` / `NSAllowsLocalNetworking` trong `Info.plist` chỉ cho bản debug; production luôn dùng `https://chat.hatang.dev`. | | Flutter web: lỗi CORS hoặc 401 dù khoá đúng | Trình duyệt gửi Origin và header `x-hatang-app-key`; origin của trang phải được site cho phép (tên miền của website, hoặc **Origin được phép thêm** trong cài đặt) thì preflight mới qua. Trên web nên cân nhắc dùng [widget web](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) thay vì SDK Flutter. | | Không nhận thông báo đẩy | Xem thứ tự quyết định ở [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md): ứng dụng ở tiền cảnh hoặc khách đang xem hội thoại thì không đẩy; tab **Ứng dụng** của website phải có tệp Firebase; bấm **Gửi thử** để kiểm tra thiết bị gần nhất. | | iOS: lỗi gần nhất là APNs `TopicDisallowed` | App ID trên Apple Developer không khớp chính xác bundle ID (kể cả dấu chấm thừa ở cuối) hoặc APNs key thuộc team khác. Tạo App ID đúng bundle ID, bật Push Notifications; không cần build lại ứng dụng. | | iOS: `firebase_messaging/apns-token-not-set` | Thiếu capability Push Notifications, hoặc (trên simulator) dịch vụ đẩy của simulator bị kẹt: khởi động lại simulator. Simulator nhận đẩy thật từ iOS 16 trên máy Mac chip Apple. | | Android: thông báo nằm ở kênh "Miscellaneous" | Ứng dụng chưa tạo kênh `hatang_messages`; xem [Thông báo đẩy](#push-notifications). | | `dart pub publish` từ chối vì "Potential leak of Google API Key" | Cấu hình Firebase phía ứng dụng không phải bí mật; khai báo tệp chứa nó trong `false_secrets` của `pubspec.yaml`. | | `rate_limit_exceeded` | 5 tin / 10 giây và 60 tin / phút mỗi khách; 30 bootstrap / phút mỗi cài đặt. `HatangException.isTransient` cho biết nên thử lại sau. | Truyền `logger: HatangLogger.console` cho `configure` để xem khung socket và HTTP trong console khi debug. ## Changelog {#changelog} **0.6.1**: trang pub.dev: mô tả rõ hơn, homepage là trang sản phẩm, thêm topic `in-app-chat` và `zalo`, README có phần About Ha Tang. Không đổi code. **0.6.0**: mua trong ứng dụng. `HatangChat.purchaseToken(userId)` (và hàm `purchaseToken(siteId, userId)`) cho mã tài khoản ứng dụng dùng với StoreKit / Google Play; gọi `HatangChat.reportPurchase(...)` sau khi mua, kèm `signedTransaction` (iOS) hoặc `purchaseToken` + `productType` (Android): Hạ Tầng xác minh với cửa hàng và hiện gói hoặc giao dịch trên hồ sơ khách. `ProductCardItem` có `basePlanId`, `offerId`, `billingPeriod`, `trialDays`. **0.5.0**: màn hình chat theo thêm nhiều cài đặt widget của site (gateway cũ không gửi thì giữ hành vi cũ). Header: dòng mô tả dưới tên site, và logo doanh nghiệp (hoặc chữ cái đầu tên site) thay cho nhân viên khi kiểu hiển thị là "doanh nghiệp"; ảnh đại diện nhân viên hiện ảnh thật. Lời chào: ẩn khi tắt hoặc tắt trên điện thoại; khi người gửi là "nhân viên trực tuyến", tên nhân viên online đầu tiên hiện dưới lời chào. Nút liên hệ: hàng nút Zalo, Messenger, gọi điện, email cuộn ngang dưới header, mở bằng `url_launcher`. Thêm `SiteConfig.header`, `SiteConfig.greetingOptions`, `SiteConfig.contactButtons`, `greetingRowFor` và chuỗi `callUs` / `emailUs`. **0.4.0**: thẻ thanh toán chuyển sang "Đã thanh toán" ngay khi dòng thời gian có tin hoặc sự kiện `order_paid` của đơn hoặc khoản thanh toán; mã VietQR vẽ trực tiếp từ payload EMVCo (`qr_payload`, hoặc lấy từ trang thanh toán công khai với thẻ cũ) vì gateway trả `qr_image_url` dạng SVG, lỗi thì có nút thử lại; thẻ sản phẩm, đơn hàng, thanh toán không còn lặp lại đoạn tóm tắt văn bản trong bong bóng phía trên, chỉ giữ lời dẫn (`metadata.intro`); thẻ đã thanh toán hoặc hết hạn ẩn nội dung chuyển khoản. Thêm `PaidMarks`, `PaymentCard.statusPaid` / `isPaid(marks)`, `MessageRow.paid`, `PaymentCard.qrPayload`, `PaymentQr`, `HatangChatController.resolvePaymentQr`, `ProductCard.intro`, `cardBubbleText`; phụ thuộc `qr` (Dart thuần) để vẽ mã. **0.3.2**: dòng "Chat bởi hatang.dev" dưới ô nhập như widget web (chữ thường, không phải liên kết; đổi được qua chuỗi `poweredBy`). **0.3.1**: `HatangChatLauncher` lấy biểu tượng, ảnh và chữ trên nút theo mục **Nút mở chat** trong cài đặt site (có chữ thì thành nút dài như "Hỗ trợ"); `child` vẫn ghi đè; `SiteConfig.launcher`. **0.3.0** (thay đổi không tương thích): mục **Tệp** trong nút đính kèm chỉ hiện khi truyền `pickFile` cho `configure` (`pickFile: hatangFilePicker` từ `package:hatang_chat/file_picker.dart`, hoặc hàm của bạn), nên ứng dụng đã ghim bản `file_picker` khác vẫn cài được SDK; camera và thư viện ảnh không đổi. Nới khoảng phiên bản `flutter_secure_storage` (10–11), `image_picker` (từ 1.1.2), `flutter_image_compress` (từ 2.3.0). `reset()` khi chat chưa từng kết nối (`autoConnect: false`) nay quên đúng khách cũ. **0.2.4**: bỏ dòng "Tải tin nhắn cũ hơn" thừa trong hội thoại do nhân viên mở bằng lời chào (SDK dùng `timeline_has_more` của gateway); ứng dụng mẫu nhận đẩy với demo trên Android và iOS không cần tham số. **0.2.3**: `handleNotification(data, context: …)` trả về ngay thay vì đợi khách đóng chat (trước đây làm treo mã khởi động có `await`); ứng dụng mẫu có tab Push và Firebase Messaging tuỳ chọn; tài liệu các bước iOS, kênh Android `hatang_messages`, `adb reverse` cho máy ảo Android. **0.2.2**: ảnh chụp màn hình trên pub.dev và README; ứng dụng mẫu chạy với hộp thư demo công khai khi không truyền tham số. **0.2.1**: tương thích WebAssembly (`flutter build web --wasm`) và sửa lỗi Flutter web không kết nối được; chữ trên màu thương hiệu chọn đen hoặc trắng theo độ tương phản WCAG, màu thương hiệu dùng làm chữ được chỉnh đủ tương phản 4,5:1; nút trong thẻ dùng font của ứng dụng; banner, ô nhập và thẻ đơn hàng đạt chuẩn trợ năng ở cỡ chữ 200%. **0.2.0**: thẻ sản phẩm, đơn hàng, thanh toán (VietQR), trả lời nhanh và đánh giá; `HatangCommerce` cho in-app purchase cùng mã sản phẩm App Store / Google Play; gửi ảnh và tệp (camera, thư viện ảnh, tệp; tiến trình tải lên; HEIC chuyển sang JPEG; ảnh lớn thu về 2048 px; xem ảnh toàn màn hình); `HatangChatInbox`, `HatangChat.openInbox` và mở hội thoại theo id; `setBottomPadding` áp dụng ngay cho màn hình đang mở; User-Agent có phiên bản hệ điều hành. **0.1.0**: `configure` với site id và khoá ứng dụng; `HatangChatScreen` và `HatangChat.open`; session API (`identify`, `setData`, `pushEvent`, `setLocale`, `setScreen`, `reset`, `restoreSession`, `linkTokenFromUri`); stream `events`, `unreadCount`, `availability`, `connectionState`; `app.state` khi vào nền / trở lại; hook thông báo đẩy; form offline và hỏi email; tiếng Việt và tiếng Anh; theme sáng/tối theo màu site. Toàn bộ lịch sử ở [changelog trên pub.dev](https://pub.dev/packages/hatang_chat/changelog). Xem thêm: [Tương thích](https://hatang.dev/vi/docs/guides/sdks/compatibility.md) cho phiên bản giao thức và gateway tối thiểu, [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md), [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md) và [SDK React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md). Source: https://hatang.dev/vi/docs/guides/sdks/flutter --- # SDK iOS `HatangChat` `HatangChat` đưa màn hình chat hỗ trợ native vào ứng dụng iOS của bạn. SDK nói thẳng [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) với gateway Hạ Tầng (không dùng web view) và chỉ dùng framework của hệ thống: URLSession, SwiftUI và Keychain. Khách nhắn từ ứng dụng vào cùng hộp thư với khách trên website, Zalo, Messenger, Instagram và TikTok Shop. > **Info: Phiên bản** > > `HatangChat` **0.4.1** · giao thức v1 · iOS 17+ · Swift 6 / Xcode 16+ · [Swift Package](https://github.com/sota-labs/hatang-chat-ios) · không phụ thuộc thư viện ngoài · [Changelog](https://github.com/sota-labs/hatang-chat-ios/blob/main/CHANGELOG.md) · [Tham chiếu API (DocC)](https://hatang.dev/docs/reference/ios/documentation) > **Note** > > Bản 0.3 có màn hình chat và hộp thư SwiftUI (kèm view controller UIKit), nút chat nổi, nhận diện có chữ ký, gửi ảnh và tệp, thẻ sản phẩm/đơn hàng/thanh toán, thông báo đẩy và deep link khôi phục phiên. API có thể còn thay đổi trước bản 1.0. ## Cài đặt {#installation} ### Lấy khoá ứng dụng trong dashboard SDK cần hai giá trị: **Site ID** và một **khoá ứng dụng** (`hak_…`). Khoá ứng dụng thay cho việc kiểm tra Origin của trình duyệt: ứng dụng gửi khoá khi khởi động, nhờ đó gateway biết phiên đến từ ứng dụng nào. 1. Trong dashboard, mở **Hỗ trợ → Kênh → Thêm kênh → Ứng dụng di động**. 2. Bấm **Tạo khoá ứng dụng**, đặt tên (ví dụ "Mộc Hương iOS") và chọn nền tảng iOS. Bundle ID là tuỳ chọn, chỉ để ghi nhớ. 3. Khoá chỉ hiện một lần. Truyền nó vào `HatangChat.configure(appKey:)`. Khoá ứng dụng công khai giống site ID (nằm trong bản build), nhưng thu hồi được. Muốn đổi khoá an toàn: tạo khoá mới, phát hành bản cập nhật dùng khoá đó, rồi thu hồi khoá cũ. Phiên đang dùng khoá bị thu hồi sẽ bị ngắt trong vòng một phút. ### Thêm package Trong Xcode, chọn **File → Add Package Dependencies…**, dán `https://github.com/sota-labs/hatang-chat-ios` và thêm sản phẩm **HatangChat** vào target ứng dụng. Hoặc khai báo trong `Package.swift`: ```swift title="Package.swift" dependencies: [ .package(url: "https://github.com/sota-labs/hatang-chat-ios", from: "0.4.1"), ], targets: [ .target(name: "MyApp", dependencies: [ .product(name: "HatangChat", package: "hatang-chat-ios"), ]), ] ``` Package có hai sản phẩm: | Sản phẩm | Gồm | |---|---| | `HatangChat` | Facade `HatangChat` và giao diện SwiftUI / UIKit. Dùng sản phẩm này. | | `HatangChatCore` | Chỉ Foundation: kiểu dữ liệu giao thức, client REST, WebSocket và actor `ChatEngine`. Dành cho ứng dụng tự dựng giao diện. | ### Đính kèm và quyền Nút đính kèm trong ô nhập cho chọn ảnh từ thư viện (`PhotosPicker` của SwiftUI) và tệp (bộ chọn tệp của hệ thống). Ảnh HEIC được chuyển sang JPEG và thu về 2048 px trước khi tải lên; tệp tối đa 10 MB. SDK không mở camera, và các bộ chọn hệ thống nó dùng không cần khai báo thêm trong `Info.plist`. ## Khởi tạo {#initialization} Gọi `HatangChat.configure` một lần khi ứng dụng khởi động. Với `autoConnect` (mặc định), SDK bootstrap khách, lưu token vào Keychain và mở socket ngay ở nền. ```swift title="MyApp.swift" import HatangChat import SwiftUI @main struct MyApp: App { init() { HatangChat.configure( siteId: "site_…", appKey: "hak_…", locale: .vi, logger: .console // khi đang tích hợp; mặc định là .silent ) } var body: some Scene { WindowGroup { ContentView() } } } ``` | Tham số | Mặc định | Ý nghĩa | |---|---|---| | `siteId`, `appKey` | bắt buộc | Lấy ở tab **Ứng dụng** của website. Giá trị rỗng làm ứng dụng dừng vì precondition. | | `baseURL` | `https://chat.hatang.dev` | Gateway. Chỉ đổi khi chạy gateway local (xem [Khắc phục sự cố](#troubleshooting)). | | `locale` | `nil` | `.vi` hoặc `.en`. Bỏ trống thì theo lựa chọn khách đã lưu, rồi ngôn ngữ mặc định của site, rồi ngôn ngữ máy. | | `themeMode`, `theme`, `darkTheme` | `.site`, `nil`, `nil` | Xem [Tuỳ biến giao diện](#theming). | | `strings` | `nil` | Ghi đè câu chữ theo ngôn ngữ; xem [Ngôn ngữ](#languages). | | `autoConnect` | `true` | `false` để tự gọi `await HatangChat.connect()` sau (ví dụ sau màn hình đồng ý). | | `openOnProactiveMessage` | `true` | Mở chat khi đội nhắn trước và chọn mở trên máy khách (ngay nếu ứng dụng đang hoạt động, nếu không thì khi ứng dụng trở lại). | | `logger` | `.silent` | `.console` ghi khung socket và lời gọi HTTP. | | `appInfo` | `HatangAppInfo.current()` | Ghi đè thông tin ứng dụng và thiết bị gửi lên gateway. | | `commerce` | `.standard` | Cách nút **Mua** và **Thanh toán** trên thẻ hoạt động; xem [Thẻ bán hàng và in-app purchase](#commerce). | Gọi `configure` lần nữa sẽ thay engine cũ. Các thành viên khác của facade: | Thành viên | Ý nghĩa | |---|---| | `HatangChat.isConfigured` | `configure` đã chạy. | | `await HatangChat.connect()` / `disconnect()` | Bootstrap và kết nối, hoặc đóng socket nhưng vẫn giữ cấu hình. | | `HatangChat.dispose()` | Giải phóng engine và mọi tài nguyên; gọi lại `configure` để dùng tiếp. | | `HatangChat.model` | `HatangChatModel` (`@Observable`) mà các view có sẵn dùng: `state`, `unread`, `connection`, `availability`, `locale`, `online`… | | `HatangChat.session` | Id khách, id hội thoại và ngôn ngữ hiện tại. | | `HatangChat.engine` | Actor `ChatEngine` bên dưới, cho trường hợp nâng cao. | SDK tự theo vòng đời ứng dụng: khi ứng dụng vào nền, SDK báo cho gateway (`app.state`) để trả lời mới đi qua thông báo đẩy thay vì socket. Keychain còn lại sau khi gỡ ứng dụng, nên ở lần mở đầu tiên sau khi cài lại, SDK xoá các mục Keychain của nó và ứng dụng bắt đầu như một khách mới, giống trên Android. ## Nhận diện khách {#identify-visitors} Khi người dùng đăng nhập, gọi `identify` với id trong hệ thống của bạn. `hash` là HMAC-SHA256 (dạng hex) của `userId` với **identity secret** của site. Hãy tính nó **ở backend** rồi trả về ứng dụng; tuyệt đối không đặt secret trong ứng dụng. Danh tính có chữ ký được gộp với các phiên trước của cùng người (kể cả trên web) và hiện nhãn đã xác minh trong hộp thư; không có chữ ký thì thông tin chỉ được lưu như tự khai. Xem [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ```swift // Backend trả về { userId, hash } sau khi đăng nhập do { try await HatangChat.identify(HatangUser( userId: me.id, email: me.email, name: me.name, phone: me.phone, hash: me.hatangHash, data: ["plan": "pro", "orders": 12] )) } catch let error as HatangError { print(error.code, error.message) } ``` Tính `hash` ở backend chỉ cần một dòng: ```js title="Node.js" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` Khi danh tính đã thuộc về một khách khác (ví dụ người dùng từng chat trên máy khác), gateway gộp phiên: SDK nhận token mới, nạp lại hội thoại và phát `.sessionChanged(reason: .identityMerged)`. Bạn không phải làm gì thêm. ### Đăng xuất ```swift await HatangChat.clearPushToken() // tuỳ chọn: ngừng gửi đẩy tới máy này await HatangChat.reset() ``` `reset()` thu hồi token cũ và bắt đầu một khách mới, để người tiếp theo trên cùng máy không thấy hội thoại của người trước. Lỗi được ghi log chứ không ném ra. ## Mở chat và nhúng {#open-and-embed} ### Mở màn hình chat ```swift HatangChat.present() // hội thoại đang mở, trên view controller trên cùng HatangChat.present(conversationId: "cnv_…") // một hội thoại cụ thể HatangChat.presentInbox() // danh sách hội thoại của khách HatangChat.dismiss() ``` `present` và `presentInbox` nhận tham số `from:` (view controller) tuỳ chọn; bỏ trống thì SDK hiện trên view controller trên cùng của key window. Khi chat đang hiển thị, `present(conversationId:)` chuyển sang hội thoại đó thay vì chồng thêm một màn hình. `HatangChat.isPresented` cho biết màn hình chat do SDK mở có đang hiện không. ### Nút chat nổi `HatangChatLauncher` là nút chat có badge số tin chưa đọc. Nút dùng màu thương hiệu của site và biểu tượng, ảnh, nhãn đã cấu hình ở mục **Nút mở chat** trong cài đặt site (có nhãn thì nút thành dạng dài như "Hỗ trợ"), và tự ẩn khi đội vắng mặt nếu site yêu cầu. Trước `configure`, nút không hiện gì. ```swift ContentView() .overlay(alignment: .bottomTrailing) { HatangChatLauncher().padding() } ``` | Tham số | Mặc định | Ý nghĩa | |---|---|---| | `model` | `HatangChat.model` | Ghi đè model. | | `showBadge` | `true` | Hiện số tin chưa đọc. | | `systemImage` | `nil` | Một SF Symbol thay cho biểu tượng và nhãn của site. | | `action` | `HatangChat.present()` | Việc cần làm khi bấm. | ### Nhúng vào điều hướng của bạn `HatangChatView` là màn hình hội thoại dạng view SwiftUI, `HatangChatInbox` là danh sách hội thoại. Đặt chúng vào một tab hoặc push vào `NavigationStack` của bạn: ```swift TabView { HomeView().tabItem { Label("Trang chủ", systemImage: "house") } HatangChatView(title: "Hỗ trợ") .tabItem { Label("Hỗ trợ", systemImage: "bubble.left.and.bubble.right") } } // Push vào stack của bạn: đừng bọc thêm một NavigationStack nữa NavigationLink("Chat với chúng tôi") { HatangChatView(embedInNavigationStack: false) } ``` | View | Tham số | |---|---| | `HatangChatView` | `conversationId` (mặc định: hội thoại đang mở), `model`, `title` (mặc định: tên site), `embedInNavigationStack` (mặc định `true`), `onClose` (hiện nút đóng) | | `HatangChatInbox` | `model`, `onOpenConversation` (mặc định: push `HatangChatView`), `onClose` | Ứng dụng UIKit dùng `HatangChatViewController(conversationId:model:)` cho hội thoại và `HatangChatViewController.inbox(model:)` cho danh sách. Cả hai hiện toàn màn hình kèm nút đóng và có `onDismiss`. ```swift let chat = HatangChatViewController() chat.onDismiss = { print("đã đóng chat") } present(chat, animated: true) ``` `HatangChat.showMessage("Chị cần tư vấn size không ạ?", senderName: "Mộc Hương")` thêm một bong bóng trợ lý chỉ hiện cục bộ, không gửi lên gateway, ví dụ lời chào theo từng màn hình. ### Thẻ bán hàng và in-app purchase {#commerce} Nhân viên gửi thẻ sản phẩm, thẻ đơn hàng, yêu cầu thanh toán, nút trả lời nhanh và lời mời đánh giá từ hộp thư; SDK vẽ chúng dưới tin nhắn. Mặc định nút **Mua** trên thẻ sản phẩm điền "Mình muốn mua …" vào ô nhập, còn thẻ thanh toán hiện mã VietQR (SDK tự vẽ từ dữ liệu thanh toán), nội dung chuyển khoản và nút **Thanh toán** mở trang checkout web. - **Hàng hoá vật lý và dịch vụ ngoài đời thực** (trà, quần áo, giao hàng, đặt lịch…): giữ mặc định. App Store yêu cầu các giao dịch này thanh toán ngoài in-app purchase (App Store Review Guideline 3.1.3(e)), và in-app purchase cũng không thu được một tổng đơn tuỳ ý. - **Nội dung số và tính năng dùng trong ứng dụng** (gói hội viên, xu, khoá học trong app…): bắt buộc bán qua in-app purchase (Guideline 3.1.1). Chuyển việc mua cho StoreKit và tắt thanh toán bên ngoài: ```swift HatangChat.configure( siteId: "site_…", appKey: "hak_…", commerce: HatangCommerce( onBuyProduct: { product in // Mã sản phẩm App Store điền cho sản phẩm này trong dashboard guard let id = product.appStoreProductId else { return false } // không phải sản phẩm in-app: giữ mặc định Task { try await Store.shared.buy(id) } // mã StoreKit 2 của bạn return true }, showExternalCheckout: false // không QR, không nội dung chuyển khoản, không checkout web ) ) ``` | Trường của `HatangCommerce` | Ý nghĩa | |---|---| | `onBuyProduct` | Nút **Mua** trên thẻ sản phẩm. Trả `true` khi bạn đã xử lý; `false` quay về việc điền sẵn ô nhập. | | `onPay` | Nút **Thanh toán** trên thẻ thanh toán. Trả `true` khi bạn đã xử lý; `false` quay về checkout web. | | `showExternalCheckout` | Mặc định `true`. `false` ẩn mã VietQR, nội dung chuyển khoản và checkout web. | Mã sản phẩm của cửa hàng điền trong dashboard: **Sản phẩm** → mở sản phẩm → **Giá trên App Store và Google Play**. Thẻ sản phẩm khi đó mang `appStoreProductId` và `googlePlayProductId`. Khi `showExternalCheckout: false` mà không có `onPay`, thẻ thanh toán chỉ hiện số tiền và lời nhắn rằng đội sẽ hướng dẫn cách thanh toán. > Toàn bộ quy trình, gồm kết nối cửa hàng, báo giao dịch gia hạn và khôi phục, nhập khách cũ và thử nghiệm, có trong [Mua trong ứng dụng và gói thuê bao](https://hatang.dev/vi/docs/guides/support/in-app-purchases.md). **Gói thuê bao.** Khách trong ứng dụng iOS thấy giá App Store bạn đặt cho sản phẩm; thẻ gói thuê bao có thêm `billingPeriod` (`P1M`, `P1Y`…) và `trialDays`. Để thông báo từ App Store tới đúng khách trong Hạ Tầng, mua bằng mã tài khoản của khách rồi báo cho đội: ```swift let token = HatangChat.purchaseToken(userId: currentUser.id) // id bạn dùng khi identify let result = try await product.purchase(options: token.map { [.appAccountToken($0)] } ?? []) if case .success(let v) = result, case .verified(let tx) = v { await HatangChat.reportPurchase(storeProductId: tx.productID, transactionId: String(tx.originalID), signedTransaction: v.jwsRepresentation) await tx.finish() } ``` Với `signedTransaction`, Hạ Tầng kiểm tra giao dịch với Apple trước khi ghi vào hồ sơ khách (giao dịch giả bị từ chối), nên gói hiện ngay thay vì chờ thông báo của Apple. Sản phẩm dùng dần và mở khoá vĩnh viễn cũng được ghi. Giao dịch mua trước khi khách đăng nhập sẽ chuyển sang khách khi bạn gọi `identify`. Sau đó kết nối ứng dụng ở **Kênh → ứng dụng của bạn → Thuê bao từ App Store và Google Play**: gói, gia hạn, huỷ và hoàn tiền của khách hiện trên hồ sơ, và trợ lý AI cũng biết. Thẻ thanh toán chuyển sang "Đã thanh toán" ngay khi dòng thời gian có tin hoặc sự kiện `order_paid` của đơn hay khoản thanh toán đó, và khi ấy ẩn mã QR, nội dung chuyển khoản và nút **Thanh toán**. ## Sự kiện và dữ liệu {#events-and-data} `HatangChat.events` là một `AsyncStream` của engine hiện tại. Đăng ký sau `configure`: ```swift Task { for await event in HatangChat.events { switch event { case .ready(let visitorId, _): print("ready", visitorId) case .messageReceived(let message): showInAppBanner(message.content) case .conversion(let conversion): Analytics.log(conversion.kind, conversion.data) case .protocolOutdated: promptUpdate() default: break } } } ``` | Sự kiện | Khi nào | |---|---| | `.ready(visitorId:conversationId:)` | Bootstrap và xác thực xong. | | `.opened`, `.closed` | Màn hình chat hiện hoặc đóng. | | `.messageReceived(_)`, `.messageSent(_)` | Tin từ đội hoặc bot; tin của khách đã được lưu (kể cả gửi từ máy khác). | | `.conversationStatus(conversationId:status:)` | Hội thoại chuyển sang `open`, `pending`, `resolved` hoặc `closed`. | | `.conversion(_)` | `HatangConversion` với `kind` là `.chatStarted`, `.lead` hoặc `.purchase` (kèm `value`, `currency`, `transaction_id` trong `data`). | | `.sessionChanged(visitorId:reason:)` | `.identityMerged`, `.reset`, `.restored` hoặc `.tokenRejected`. | | `.protocolOutdated(reason:)` | Gateway không còn phục vụ phiên bản SDK này; hãy nhắc người dùng cập nhật ứng dụng. | Để hiện badge, đọc `HatangChat.unreadCount` hoặc theo dõi `HatangChat.model.unread` trong SwiftUI: ```swift struct RootView: View { let model = HatangChat.model var body: some View { TabView { HomeView().tabItem { Label("Trang chủ", systemImage: "house") } HatangChatView() .tabItem { Label("Hỗ trợ", systemImage: "bubble.left") } .badge(model.unread) } } } ``` Gửi ngữ cảnh lên hộp thư: ```swift try await HatangChat.setData(["cart_total": 450000, "last_order": "SO-1001"]) // hiện cho nhân viên, tối đa 8 KB HatangChat.pushEvent("viewed_product", data: ["sku": "TS-100"]) // dòng thời gian của khách HatangChat.setScreen("ProductPage", params: ["sku": "TS-100"]) // khách đang ở đâu trong ứng dụng ``` ## Thông báo đẩy {#push-notifications} Hạ Tầng gửi thông báo đẩy qua **dự án Firebase của chính bạn** (FCM HTTP v1), kể cả trên iOS. Tải tài khoản dịch vụ Firebase lên tab **Ứng dụng** của website, mục **Thông báo đẩy (Firebase)**, và cấu hình APNs trong Firebase; xem [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md) và mục [Ứng dụng iOS](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md#ios-apps). SDK không phụ thuộc Firebase: hãy thêm Firebase Messaging vào ứng dụng rồi chuyển FCM token cho SDK. > **Caution** > > `HatangChat.setPushToken(_ deviceToken: Data)` đăng ký token thiết bị APNs thô. Gateway có lưu token này nhưng hiện chỉ gửi qua FCM, nên máy chỉ đăng ký bằng token APNs sẽ không nhận thông báo. Hãy dùng `setPushToken(fcmToken:)`. Gửi thẳng qua APNs không cần Firebase đang trong kế hoạch. ```swift title="AppDelegate.swift" import FirebaseCore import FirebaseMessaging import HatangChat import UIKit final class AppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { FirebaseApp.configure() Messaging.messaging().delegate = self UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } application.registerForRemoteNotifications() return true } // Đăng ký thiết bị với Hatang (gọi trước khi SDK sẵn sàng vẫn an toàn; SDK tự đăng ký lại khi khách đổi) func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { guard let fcmToken else { return } Task { await HatangChat.setPushToken(fcmToken: fcmToken) } } // Người dùng bấm vào thông báo func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { let info = response.notification.request.content.userInfo if HatangChat.isHatangNotification(info) { HatangChat.handleNotification(info) } } } ``` Trong ứng dụng SwiftUI, gắn delegate bằng `@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate`. - `isHatangNotification(_:)` kiểm tra `hatang: "1"` trong `userInfo`, nên thông báo của bạn và của Hạ Tầng chung sống được. `conversationId(of:)` trả về hội thoại mà thông báo trỏ tới. - `handleNotification(_:)` mở đúng hội thoại đó và trả `false` với payload không phải của Hạ Tầng. Gọi được từ bất kỳ luồng nào. Khi khởi động lạnh, SDK khởi chạy engine và mở chat khi ứng dụng đã hoạt động. - Khi ứng dụng đang ở tiền cảnh và có kết nối, hoặc khách đang xem hội thoại, gateway **không** gửi đẩy: tin đến thẳng qua socket. - Gọi `clearPushToken()` khi đăng xuất nếu máy này không nên nhận thông báo nữa. Trong Xcode, chọn target → **Signing & Capabilities**, thêm **Push Notifications** và **Background Modes → Remote notifications**. ## Deep link {#deep-links} Email "chúng tôi đã trả lời" gửi cho khách có link dạng `https:///c/`. Nếu ứng dụng nhận universal link cho tên miền đó (hoặc một scheme riêng với `?hatang_restore=`), hãy khôi phục phiên từ token rồi mở chat: ```swift ContentView() .onOpenURL { url in guard let token = HatangChat.linkToken(from: url) else { return } // không phải link của Hatang Task { try? await HatangChat.restoreSession(token) HatangChat.present() } } ``` `linkToken(from:)` nhận cả `/c/` và `?hatang_restore=`, trả `nil` với URL khác. `restoreSession(_:)` lưu token phiên mới, bootstrap lại và phát `.sessionChanged(reason: .restored)`. Token khôi phục chỉ dùng một lần và có hạn, đừng lưu lại. ## Tuỳ biến giao diện {#theming} Mặc định màn hình chat lấy màu thương hiệu và chế độ sáng/tối từ cài đặt site trong dashboard (`themeMode: .site`), và tự tăng độ tương phản ở chế độ tối. Ghi đè trong `configure`: ```swift HatangChat.configure( siteId: "site_…", appKey: "hak_…", themeMode: .system, // .site | .system | .light | .dark theme: .light(accent: Color(red: 0.01, green: 0.52, blue: 0.78)), darkTheme: .dark(accent: Color(red: 0.22, green: 0.74, blue: 0.97)) ) ``` | `themeMode` | Ý nghĩa | |---|---| | `.site` | Theo cài đặt giao diện của site (sáng, tối hoặc tự động). Mặc định. | | `.system` | Theo giao diện của máy. | | `.light`, `.dark` | Luôn sáng hoặc luôn tối. | Mọi nút đều có nhãn cho VoiceOver và tin mới được đọc lên; giao diện theo cỡ chữ Dynamic Type và tôn trọng Giảm chuyển động. ### Theo cài đặt widget trong dashboard {#dashboard-settings} Một số phần của màn hình chat lấy thẳng từ cài đặt widget của site (**Hỗ trợ → Kênh → chọn website**). Đổi trong dashboard là ứng dụng nhận ở lần mở chat sau, không cần sửa code: - **Header**: dòng mô tả dưới tên site (theo ngôn ngữ đang dùng), và kiểu hiển thị: ảnh nhân viên đang trực tuyến hoặc logo doanh nghiệp (không có logo thì hiện chữ cái đầu tên site). - **Lời chào**: bật/tắt, có hiện trên điện thoại không (ứng dụng luôn tính là điện thoại). Nếu người gửi là nhân viên trực tuyến, tên nhân viên hiện dưới lời chào khi đội đang online. Lời nhắc (teaser) trên nút chat chỉ có ở widget web. - **Nút liên hệ**: Zalo, Messenger, gọi điện, email hiện thành hàng nút dưới header và mở bằng ứng dụng tương ứng trên máy. Đổi chữ "Gọi điện" và "Email" qua chuỗi `call` và `emailUs`. Với gateway cũ chưa gửi các cài đặt này, màn hình giữ như trước: luôn hiện lời chào, header hiện nhân viên, không có nút liên hệ. ## Ngôn ngữ {#languages} SDK có sẵn tiếng Việt và tiếng Anh. Thứ tự chọn ngôn ngữ: `locale` trong `configure`, lựa chọn khách đã lưu, ngôn ngữ mặc định của site, rồi ngôn ngữ máy. Đổi lúc chạy: ```swift await HatangChat.setLocale(.en) ``` `HatangChatStrings` là một struct, mỗi chuỗi là một thuộc tính. Bắt đầu từ bộ có sẵn và sửa phần cần thiết: ```swift var vi = HatangChatStrings.vi vi.placeholder = "Bạn cần hỗ trợ gì?" vi.team = "Mộc Hương" var en = HatangChatStrings.en en.placeholder = "How can we help?" HatangChat.configure(siteId: "site_…", appKey: "hak_…", strings: [.vi: vi, .en: en]) ``` Chuỗi không sửa giữ nguyên bản gốc. ## Khắc phục sự cố {#troubleshooting} | Triệu chứng | Nguyên nhân và cách xử lý | |---|---| | `HatangError(invalid_app_key 401)` khi khởi động | `appKey` sai hoặc thuộc site khác. Kiểm tra tab **Ứng dụng** của đúng website; khoá bắt đầu bằng `hak_`. | | `HatangError(app_key_revoked 403)` | Khoá đã bị thu hồi trong dashboard. Phát hành bản cập nhật với khoá mới. `error.isAppKeyError` nhận ra cả hai lỗi khoá. | | `model.connection` là `.outdated`, sự kiện `.protocolOutdated` | Gateway đóng socket với mã 4004: phiên bản SDK quá cũ so với giao thức. SDK dừng kết nối lại; nâng cấp `HatangChat` và phát hành. | | `rate_limit_exceeded` | 5 tin / 10 giây và 60 tin / phút mỗi khách; 30 lần bootstrap / phút mỗi bản cài. `error.isTransient` và `error.isRateLimit` cho biết nên thử lại sau. | | Gọi `http://` thất bại khi dev | App Transport Security chặn HTTP thường. Thêm ngoại lệ `NSAppTransportSecurity` (ví dụ `NSAllowsLocalNetworking`) chỉ cho bản debug; production luôn dùng `https://chat.hatang.dev`. | | Simulator chạy được với gateway local nhưng điện thoại thật thì không | `localhost` trên điện thoại là chính điện thoại. Đặt `CHAT_URL` của gateway thành địa chỉ LAN của máy Mac và dùng địa chỉ đó làm `baseURL`. | | Không nhận thông báo đẩy | Ứng dụng đăng ký token APNs thay vì FCM (xem [Thông báo đẩy](#push-notifications)), ứng dụng đang ở tiền cảnh hoặc đang mở hội thoại, hoặc tab **Ứng dụng** của website chưa có tệp Firebase. Bấm **Gửi thử** ở đó để kiểm tra thiết bị gần nhất và xem mục "Lỗi gần nhất". | | Lỗi gần nhất là APNs `TopicDisallowed` | App ID trên Apple Developer không khớp chính xác bundle ID, hoặc APNs key thuộc team khác. Xem [Thông báo đẩy → Ứng dụng iOS](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md#ios-apps). | | Bấm thông báo không mở chat | Chưa gọi `handleNotification(_:)` trong `userNotificationCenter(_:didReceive:)`, hoặc chưa đặt `UNUserNotificationCenter.current().delegate`. | Truyền `logger: .console` vào `configure` để xem khung socket và lời gọi HTTP trong console của Xcode. ## Changelog {#changelog} **0.4.1** (03/11/2026): gói có file LICENSE (MIT) và đã được gửi lên Swift Package Index; README mở đầu bằng giới thiệu SDK và có phần About Ha Tang. Không đổi code. **0.4.0** (31/10/2026): mua trong ứng dụng. `HatangChat.purchaseToken(userId:)` cho `.appAccountToken` của StoreKit để App Store Server Notifications về đúng khách; gọi `HatangChat.reportPurchase(...)` sau khi mua, kèm `signedTransaction` của StoreKit 2: Hạ Tầng xác minh và hiện gói hoặc giao dịch trên hồ sơ khách. Thẻ sản phẩm có `basePlanId`, `offerId`, `billingPeriod`, `trialDays` và giá App Store đặt cho sản phẩm. **0.3.0** (14/10/2026): màn hình chat theo thêm nhiều cài đặt widget của site mà không cần sửa code: dòng mô tả trong header, logo doanh nghiệp thay cho ảnh nhân viên (hoặc chữ cái đầu tên site), ảnh nhân viên trong header, nút liên hệ (Zalo, Messenger, gọi điện, email) mở bằng ứng dụng tương ứng, và công tắc bật/tắt lời chào, hiện trên điện thoại. Lời chào ký tên nhân viên trực tuyến hiện tên người đó khi đội đang online. Thêm `SiteConfig.header`, `SiteConfig.greetingOptions`, `SiteConfig.contactButtons`, `SiteConfig.showsGreetingInApp` và `SiteConfig.greetingSigner(online:)` trong `HatangChatCore`, cùng hai chuỗi `call` và `emailUs` (hai tham số mới ở cuối `HatangChatStrings.init`). **0.2.0** (13/10/2026): thẻ thanh toán chuyển sang "Đã thanh toán" ngay khi dòng thời gian có tin hoặc sự kiện `order_paid` của đơn; mã VietQR được vẽ trực tiếp bằng CoreImage từ payload EMVCo (`qr_payload`, hoặc lấy từ trang thanh toán công khai với thẻ cũ) vì gateway trả `qr_image_url` dạng SVG, lỗi thì có nút thử lại; thẻ sản phẩm, đơn hàng, thanh toán không còn lặp lại bản tóm tắt chữ phía trên thẻ, chỉ giữ câu dẫn. Thêm `PaymentCard.qrPayload`, `ProductCard.intro`, `PaymentQR`, `cardBubbleText(_:card:)` và `PaidPayments` trong `HatangChatCore`. **0.1.0** (03/10/2026): bản đầu tiên: màn hình chat, hộp thư và nút chat SwiftUI (kèm view controller UIKit), gửi ảnh và tệp, nhận diện có chữ ký, hỗ trợ thông báo đẩy, tiếng Việt và tiếng Anh, giao diện sáng/tối. `HatangChatCore` qua bộ kiểm thử tuân thủ C01–C12. Toàn bộ lịch sử ở [CHANGELOG](https://github.com/sota-labs/hatang-chat-ios/blob/main/CHANGELOG.md). Xem thêm: [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md), [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md), [Tương thích](https://hatang.dev/vi/docs/guides/sdks/compatibility.md) (phiên bản giao thức và gateway tối thiểu) và [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md). Source: https://hatang.dev/vi/docs/guides/sdks/ios --- # MCP server (`@hatang/mcp`) `@hatang/mcp` là một server [Model Context Protocol](https://modelcontextprotocol.io) cho Hạ Tầng. Thêm nó vào coding agent của bạn, agent sẽ tìm trong tài liệu này và đọc bất kỳ trang nào dưới dạng Markdown trong lúc viết phần tích hợp, đồng thời gọi API giúp bạn: gửi email thử, kiểm tra bản ghi DNS của tên miền, tạo thanh toán VietQR và giả lập chuyển khoản ở môi trường development, đọc hội thoại trong hộp thư hay thêm kiến thức cho trợ lý AI. > **Info: Phiên bản** > > `@hatang/mcp` **0.1.1** · Node 20+ · stdio · [npm](https://www.npmjs.com/package/@hatang/mcp) · nhật ký thay đổi: `CHANGELOG.md` trong gói ## Cài đặt {#install} Các tool đọc tài liệu không cần gì thêm. Với các tool gọi API, tạo key trong dashboard ở **Cài đặt → API Keys**, chỉ cấp các scope agent cần (xem [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md)), và để Thanh toán ở `development` trong lúc phát triển. **Claude Code** ```bash claude mcp add hatang --env HATANG_API_KEY=ht_... --env HATANG_ENVIRONMENT=development -- npx -y @hatang/mcp ``` **Cursor** (`.cursor/mcp.json`), **Windsurf** (`~/.codeium/windsurf/mcp_config.json`), **Claude Desktop** và các client cấu hình bằng JSON ```json { "mcpServers": { "hatang": { "command": "npx", "args": ["-y", "@hatang/mcp"], "env": { "HATANG_API_KEY": "ht_...", "HATANG_ENVIRONMENT": "development" } } } } ``` **VS Code** (`.vscode/mcp.json`), hỏi key khi chạy thay vì lưu trong file ```json { "servers": { "hatang": { "command": "npx", "args": ["-y", "@hatang/mcp"], "env": { "HATANG_API_KEY": "${input:hatang-key}", "HATANG_ENVIRONMENT": "development" } } }, "inputs": [{ "id": "hatang-key", "type": "promptString", "description": "Ha Tang API key", "password": true }] } ``` | Biến hoặc cờ | | |---|---| | `HATANG_API_KEY` | API key của bạn. Không có key thì chỉ `search_docs` và `read_doc` chạy. | | `HATANG_ENVIRONMENT` | Môi trường Thanh toán: `development` (không có tiền thật, dùng được `simulate_transfer`) hoặc `production`. Xem [Môi trường](https://hatang.dev/vi/docs/guides/payments/environments.md). | | `HATANG_BASE_URL` | Mặc định `https://api.hatang.dev`. | | `--read-only` | Chỉ đăng ký các tool đọc. Không gửi, tạo hay sửa gì. | ## Các tool | Tool | Làm gì | Thay đổi dữ liệu | |---|---|---| | `search_docs` | Tìm trong tài liệu này (tiếng Anh hoặc tiếng Việt), trả về tiêu đề, tóm tắt và URL Markdown | Không | | `read_doc` | Đọc một trang tài liệu dạng Markdown | Không | | `send_email` | Gửi email (`POST /emails`) bằng html/text hoặc template đã xuất bản, có thể kèm idempotency key | Có | | `get_email`, `list_emails` | Email đã gửi và trạng thái | Không | | `list_templates` | Template email | Không | | `list_domains`, `get_domain` | Tên miền gửi và các bản ghi DNS cần thêm | Không | | `create_domain`, `verify_domain` | Thêm tên miền, kiểm tra bản ghi DNS | Có | | `create_payment` | Tạo thanh toán chuyển khoản VietQR | Có | | `get_payment`, `list_payments` | Thanh toán và trạng thái | Không | | `simulate_transfer` | Chỉ ở development: giả lập khoản chuyển khoản của người trả đã về, để IPN và webhook chạy | Có | | `list_einvoices`, `get_einvoice` | Hoá đơn điện tử | Không | | `list_conversations`, `get_conversation` | Hộp thư hỗ trợ, mọi kênh, kèm các tin nhắn mới nhất | Không | | `send_message` | Trả lời trong hội thoại, hoặc thêm ghi chú nội bộ | Có | | `list_customers` | Khách hàng, tìm theo tên, email hoặc số điện thoại | Không | | `knowledge_search` | Tìm trong kiến thức mà trợ lý AI dùng để trả lời | Không | | `knowledge_upsert` | Tạo hoặc thay một tài liệu kiến thức | Có | Các tool thay đổi dữ liệu có annotation MCP (`readOnlyHint: false`), nên client sẽ hỏi bạn trước khi chạy. Kết quả là JSON của API; lỗi trả về dạng `name (status): message`, giống [định dạng lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md). ## Gợi ý câu lệnh cho agent - "Thêm email xác nhận đơn hàng cho app này bằng Hạ Tầng. Đọc tài liệu trước." - "Tạo thanh toán VietQR 150.000đ, giả lập chuyển khoản, rồi cho tôi xem IPN mà endpoint của tôi cần xác minh." - "mail.example.com đã xác minh chưa? Thiếu bản ghi DNS nào?" - "Tóm tắt các hội thoại chưa trả lời hôm nay." - "Biến docs/faq.md thành kiến thức cho trợ lý AI." ## An toàn - Agent làm được những gì scope của key cho phép. Dùng một key riêng cho việc này, chỉ cấp scope cần thiết, và thu hồi khi xong. - Giữ `HATANG_ENVIRONMENT=development` trừ khi bạn thật sự muốn agent đụng tới thanh toán thật. - `--read-only` là lựa chọn mặc định tốt cho agent chỉ cần tra cứu. - Server không bao giờ in key ra; nó chạy trên máy bạn và chỉ gọi `api.hatang.dev` và `hatang.dev/docs`. ## Không dùng MCP Agent không chạy được MCP server vẫn đọc được tài liệu: [`/vi/docs/llms.txt`](https://hatang.dev/docs/llms.txt) liệt kê mọi trang, [`/vi/docs/llms-full.txt`](https://hatang.dev/docs/llms-full.txt) là toàn bộ tài liệu trong một file, và mỗi trang có bản Markdown ở chính địa chỉ của trang thêm `.md`. Về file quy tắc và câu lệnh mẫu, xem [Dùng Hạ Tầng với AI coding agent](https://hatang.dev/vi/docs/guides/getting-started/ai-agents.md). Source: https://hatang.dev/vi/docs/guides/sdks/mcp --- # SDK Node.js (`@hatang/sdk`) `@hatang/sdk` là client phía server cho API Hạ Tầng. Một client dùng cho cả email (gửi, nhận, tên miền, danh sách nhận, broadcast), hộp thư hỗ trợ (`chat`), thanh toán và hoá đơn điện tử, API key và webhook; kèm các hàm xác minh chữ ký webhook và IPN. Mọi hàm trả về `{ data, error }` và không throw khi API báo lỗi. > **Info: Phiên bản** > > `@hatang/sdk` **0.6.2** · Node 20+ · Bun 1.1+ · edge runtime có `fetch` · [npm](https://www.npmjs.com/package/@hatang/sdk) · [Tham chiếu API (TypeDoc)](https://hatang.dev/docs/reference/js/index.html) · changelog: tệp `CHANGELOG.md` trong gói ## Trước khi bắt đầu - Một API key (`ht_...`) có các scope mà code của bạn cần. Tạo trong dashboard; scope và môi trường xem ở [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md). - Node.js 20 trở lên, Bun 1.1 trở lên, hoặc runtime có `fetch` và Web Crypto (Deno, Cloudflare Workers, Vercel Edge). - Chỉ giữ key ở server. Đừng bao giờ đưa key vào bundle trình duyệt hay ứng dụng di động. ## Cài đặt ```bash npm install @hatang/sdk # hoặc pnpm add @hatang/sdk # hoặc bun add @hatang/sdk ``` Gói chỉ có bản ESM (`"type": "module"`). Gói có một phụ thuộc là `@react-email/render`, chỉ dùng khi bạn gửi email bằng React element; `react` là peer dependency tuỳ chọn. ## Tạo client {#client} ```ts title="lib/hatang.ts" import { Hatang } from "@hatang/sdk"; export const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", }); ``` > **Note: Bản cũ mặc định gọi localhost** > > Từ 0.4.0, client gọi `https://api.hatang.dev` trừ khi bạn truyền `baseUrl` hoặc đặt `HATANG_BASE_URL` (dùng một trong hai khi trỏ tới API local hay staging). Các bản đến 0.3.x mặc định gọi `http://localhost:3001`; với các bản đó, hãy đặt `baseUrl` ở production hoặc nâng cấp. | Tuỳ chọn | Mặc định | Tác dụng | |---|---|---| | tham số đầu (API key) | `HATANG_API_KEY`, rồi `EMAIL_API_KEY` | Gửi trong header `Authorization: Bearer ht_...`. Constructor throw `Missing API key` khi không tìm thấy key. | | `baseUrl` | `HATANG_BASE_URL`, rồi `EMAIL_API_URL`, rồi `https://api.hatang.dev` (trước 0.4.0 là `http://localhost:3001`) | Origin của API; dấu `/` cuối bị bỏ. | | `environment` | `HATANG_ENVIRONMENT` | Chỉ cho thanh toán: `"development"` hoặc `"production"`, gửi trong header `Hatang-Environment`. Không đặt thì key `ht_test_` dùng development, key khác dùng production. | | `fetch` | `fetch` toàn cục | Hàm `fetch` riêng (test, proxy, đo đạc). | | `userAgent` | `hatang-sdk-node/ (node/)` | Header `User-Agent`. | Client không giữ kết nối nào, nên tạo một lần cho mỗi tiến trình rồi dùng lại ở mọi nơi. `hatang.baseUrl` cho biết client đang gọi tới đâu. ## Kết quả và lỗi {#errors} Mọi hàm trả về một union: ```ts type Result = { data: T; error: null } | { data: null; error: { statusCode: number; name: string; message: string } }; ``` Kiểm tra `error` trước; sau đó TypeScript biết `data` có giá trị. ```ts const { data, error } = await hatang.emails.get("em_123..."); if (error) { console.error(error.statusCode, error.name, error.message); return; } console.log(data.status); ``` | `error.name` | `statusCode` | Nguyên nhân thường gặp | |---|---|---| | `missing_api_key` | 401 | Header `Authorization` không tới được API | | `invalid_api_key` | 403 | Key sai, đã thu hồi hoặc thuộc môi trường khác | | `restricted_api_key` | 401 | Key thiếu scope cho lệnh gọi này | | `validation_error`, `missing_required_field` | 422 | Thiếu trường hoặc sai dạng | | `not_found` | 404 | Id không tồn tại trong doanh nghiệp này | | `invalid_idempotent_request`, `concurrent_idempotent_requests` | 400, 409 | Dùng lại `Idempotency-Key` với nội dung khác, hoặc lệnh trước còn đang chạy | | `rate_limit_exceeded`, `daily_quota_exceeded` | 429 | Gọi quá nhiều; đợi rồi thử lại | | `application_error` | 0 | Không tới được API (DNS, TLS, timeout). `message` bắt đầu bằng `Unable to reach the API` | Danh sách đầy đủ ở [Mã lỗi](https://hatang.dev/vi/docs/reference/errors.md). Lỗi mạng được trả về dưới dạng `application_error` với `statusCode: 0`, không throw. SDK không tự thử lại, nên bạn tự chọn cách thử lại (queue, cron, vòng lặp backoff). Giới hạn tần suất theo sản phẩm ở [Giới hạn](https://hatang.dev/vi/docs/reference/limits.md). Chỉ ba trường hợp throw: constructor không có API key, gửi `react` khi chưa cài `react`, và các hàm `construct*` khi chữ ký không khớp (throw `HatangSignatureError`). ## Email {#emails} ```ts const { data, error } = await hatang.emails.send( { from: "Mộc Hương ", to: ["khach@example.com"], subject: "Đơn SO-1001 đã được xác nhận", html: "

Cảm ơn bạn đã đặt hàng.

", text: "Cảm ơn bạn đã đặt hàng.", tags: [{ name: "type", value: "order" }], }, { idempotencyKey: "order-SO-1001-confirmation" }, ); // data → { id: "..." } ``` - `from` phải dùng tên miền đã xác minh ([Tên miền](#domains)). - `to`, `cc`, `bcc` và `reply_to` nhận một chuỗi hoặc một mảng. - `headers`, `tags`, `scheduled_at` (ISO 8601) và `attachments` là tuỳ chọn. `content` của tệp đính kèm có thể là chuỗi base64, `Buffer` hoặc `Uint8Array`; SDK tự mã hoá nội dung nhị phân sang base64. - `idempotencyKey` được gửi thành header `Idempotency-Key`, nên gọi lại sẽ chỉ gửi một email. Gửi component React Email thay cho HTML. SDK render nó thành `html`, và thành `text` nếu bạn không truyền: ```tsx import { WelcomeEmail } from "./emails/welcome"; await hatang.emails.send({ from: "Mộc Hương ", to: "khach@example.com", subject: "Chào mừng", react: }); ``` Các lệnh email khác: ```ts await hatang.emails.get("em_123..."); await hatang.emails.list({ limit: 50 }); await hatang.emails.update({ id: "em_123...", scheduledAt: "2026-11-01T09:00:00+07:00" }); // đổi giờ gửi await hatang.emails.cancel("em_123..."); // huỷ email hẹn giờ ``` Thư nhận được trên tên miền đã bật nhận email (xem [Nhận email](https://hatang.dev/vi/docs/guides/email/receiving.md)): ```ts const { data: inbox } = await hatang.emails.receiving.list({ limit: 20 }); const { data: mail } = await hatang.emails.receiving.get(inbox.data[0].id); const { data: files } = await hatang.emails.receiving.attachments.list(mail.id); // files.data[0].download_url dùng được không cần API key trong một giờ ``` ## Gửi hàng loạt {#batch} Gửi từ 1 đến 100 email trong một request. Mỗi phần tử có cùng dạng với `emails.send`. ```ts const { data } = await hatang.batch.send([ { from: "Mộc Hương ", to: "a@example.com", subject: "Chào A", html: "

Chào A

" }, { from: "Mộc Hương ", to: "b@example.com", subject: "Chào B", html: "

Chào B

" }, ]); // data.data → [{ id }, { id }] ``` ## Tên miền {#domains} ```ts const { data: domain } = await hatang.domains.create({ name: "shop.vn" }); // domain.records → các bản ghi DNS cần thêm ở nhà cung cấp DNS await hatang.domains.verify(domain.id); await hatang.domains.update({ id: domain.id, open_tracking: true, click_tracking: true, tls: "enforced" }); await hatang.domains.update({ id: domain.id, capabilities: { receiving: "enabled" } }); const { data: all } = await hatang.domains.list(); await hatang.domains.remove(domain.id); ``` Bản ghi DNS và cách xác minh xem ở [Tổng quan Email](https://hatang.dev/vi/docs/guides/email/overview.md). ## Danh sách nhận và liên hệ {#contacts} Từ 0.4.0, liên hệ thuộc về doanh nghiệp, không gắn với một danh sách nhận. Gọi liên hệ theo `id` hoặc `email`, và nhóm liên hệ bằng segment và topic. ```ts await hatang.contacts.create({ email: "lan@example.com", first_name: "Lan", properties: { plan: "pro" } }); await hatang.contacts.update({ email: "lan@example.com", unsubscribed: true }); const { data: contact } = await hatang.contacts.get("lan@example.com"); const { data: list } = await hatang.contacts.list({ segment_id: "aud_123..." }); await hatang.contacts.segments.add("lan@example.com", "aud_123..."); await hatang.contacts.topics.update("lan@example.com", [{ id: "topic_123...", subscription: "opt_out" }]); await hatang.contacts.remove(contact.id); ``` Truyền `audience_id` vẫn được và dùng các route audience cũ: ```ts const { data: audience } = await hatang.audiences.create({ name: "Bản tin" }); await hatang.contacts.create({ audience_id: audience.id, email: "lan@example.com" }); ``` Ngoài ra có `audiences.get`, `audiences.list` và `audiences.remove`. Liên hệ được tìm theo `id` hoặc `email`. Xem [Danh sách nhận](https://hatang.dev/vi/docs/guides/email/audiences.md). ## Broadcast {#broadcasts} ```ts const { data: draft } = await hatang.broadcasts.create({ audience_id: "aud_123...", from: "Mộc Hương ", subject: "Trà mùa thu đã về", html: "

Chào {{{contact.first_name}}}, trà mùa thu đã về.

", name: "Ra mắt mùa thu", }); await hatang.broadcasts.send(draft.id); // gửi ngay await hatang.broadcasts.send(draft.id, { scheduledAt: "2026-11-01T08:00:00+07:00" }); // hoặc hẹn giờ await hatang.broadcasts.cancel(draft.id); ``` `get`, `list`, `update` và `remove` làm đúng như tên gọi. ## Template {#templates} ```ts const { data: tpl } = await hatang.templates.create({ name: "Xác nhận đơn", alias: "order-confirmed", subject: "Đơn {{{ORDER}}} đã được xác nhận", html: "

Cảm ơn bạn! Đơn {{{ORDER}}} đã được xác nhận.

", variables: [{ key: "ORDER", type: "string" }], }); await hatang.templates.publish(tpl.id); await hatang.emails.send({ from: "Mộc Hương ", to: "khach@example.com", template: { id: "order-confirmed", variables: { ORDER: "SO-1001" } }, }); ``` `get`, `list`, `update`, `remove` và `duplicate` nhận id hoặc alias. Khi gửi bằng template, `from` và `subject` có thể lấy từ template. Xem [Template](https://hatang.dev/vi/docs/guides/email/templates.md). ## API key {#api-keys} ```ts const { data: key } = await hatang.apiKeys.create({ name: "Worker thanh toán", scopes: ["payments:write"] }); // key.token chỉ hiện một lần: lưu ngay const { data: keys } = await hatang.apiKeys.list(); await hatang.apiKeys.remove(key.id); ``` ## Webhook {#webhooks} Tạo endpoint và giữ lại signing secret: ```ts const { data: hook } = await hatang.webhooks.create({ endpoint: "https://shop.vn/api/hatang/webhooks", events: ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"], }); // hook.signing_secret → "whsec_..." ``` Sau đó quản lý bằng `get`, `list`, `update` (`endpoint`, `events`, `status`, `description`), `remove`, `rotateSecret` và `deliveries`. Xác minh từng lần gửi trước khi tin nó. Truyền raw body đúng như nhận được, trước khi parse JSON: ```ts title="app/api/hatang/webhooks/route.ts (Next.js)" import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk"; export async function POST(request: Request) { const rawBody = await request.text(); try { const event = await constructWebhookEvent(rawBody, request.headers, process.env.HATANG_WEBHOOK_SECRET!); if (event.type === "chat.message.created") await notifySales(event.data); return new Response(null, { status: 204 }); } catch (err) { if (err instanceof HatangSignatureError) return new Response("bad signature", { status: 400 }); throw err; } } ``` | Hàm | Trả về | Ghi chú | |---|---|---| | `verifyWebhook(rawBody, headers, secret, { toleranceSec? })` | `Promise` | Kiểm tra `svix-id` / `svix-timestamp` / `svix-signature` (hoặc các header `webhook-*` tương đương). Từ chối lần gửi cũ hơn `toleranceSec`, mặc định 300 giây. | | `constructWebhookEvent(rawBody, headers, secret, { toleranceSec? })` | `Promise` | Xác minh rồi trả về `{ type, created_at, data }`. Throw `HatangSignatureError` khi chữ ký sai hoặc timestamp quá cũ. | `headers` có thể là đối tượng `Headers` của Fetch hoặc object thường như `req.headers` của Express. Với Express, đọc body bằng `express.raw({ type: "application/json" })` để body chưa bị parse. Tên sự kiện và payload ở [Webhook](https://hatang.dev/vi/docs/reference/webhooks.md); cách cài đặt và thử lại ở [hướng dẫn Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## Hộp thư hỗ trợ (`chat`) {#chat} Namespace `chat` bọc API hộp thư hỗ trợ: hội thoại và tin nhắn từ widget website, ứng dụng di động và mọi kênh đã kết nối, cùng khách hàng, sản phẩm, đơn hàng, phiếu hỗ trợ và báo cáo. Dùng nó để nối hộp thư với ERP, CRM hay bot riêng của bạn. Key cần `chat:read` để đọc và `chat:write` để trả lời và thay đổi dữ liệu. Sản phẩm và đơn hàng nhận thêm `products:read` / `products:write`; khách hàng và ZNS nhận thêm `crm:read` / `crm:write`. `chat:reply` và `chat:manage` là quyền của nhân viên trong dashboard, không phải scope của API key. ### Hội thoại và tin nhắn ```ts // Hội thoại đang mở mà khách nhắn sau cùng const { data: open } = await hatang.chat.conversations.list({ status: "open", unanswered: true }); // Trả lời như nhân viên. Idempotency key giúp gọi lại an toàn. await hatang.chat.conversations.send(open.data[0].id, { content: "Dạ shop còn hàng ạ" }, { idempotencyKey: "reply-42" }); // Ghi chú nội bộ, khách không thấy await hatang.chat.conversations.send(open.data[0].id, { content: "Khách VIP", visibility: "private" }); // Giao, gắn thẻ, giải quyết await hatang.chat.conversations.update(open.data[0].id, { assignee_id: "usr_...", tags: ["vip"], status: "resolved" }); // Dòng thời gian (tin nhắn và sự kiện) sau một số thứ tự, để đồng bộ dần const { data: page } = await hatang.chat.conversations.messages("cnv_...", { after_seq: 120, limit: 200 }); for (const item of page.data) if (item.kind === "message") console.log(item.sender_type, item.content); ``` `list` lọc theo `status` (một hoặc nhiều trong `open`, `pending`, `resolved`, `closed`), `assignee_id`, `site_id`, `visitor_id`, `q`, `unanswered` và `flag`, phân trang bằng `limit` và `after`. `seq` tăng một cho mỗi mục trong một hội thoại; đó cũng là `seq` mà widget và SDK di động dùng để `resume` (xem [Giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md)). `send` trả về `{ id, seq, conversation_id, duplicate }`; `duplicate: true` nghĩa là idempotency key trùng một lần gọi trước. ### Khách hàng ```ts const { data: customers } = await hatang.chat.customers.list({ stage: "qualified", channel: "instagram" }); await hatang.chat.customers.update("cus_...", { stage: "customer", labels: ["wholesale"], fields: { ma_kh: "KH0012" } }); await hatang.chat.customers.merge("cus_a", ["cus_b"]); // gộp hồ sơ trùng vào cus_a const { data: csv } = await hatang.chat.customers.exportCsv({ label: "wholesale" }); // chuỗi CSV ``` Khách được `identify` có chữ ký từ widget hoặc SDK di động xuất hiện ở đây với `external_id` là user id trong hệ thống của bạn ([Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md)). `customers.fields` quản lý trường tuỳ chỉnh; `chat.visitors` đọc và sửa một khách truy cập. ### Đơn hàng và link thanh toán ```ts const { data: order } = await hatang.chat.orders.create({ conversation_id: "cnv_...", items: [{ name: "Trà sen 100g", price: 120_000, quantity: 2 }], shipping: 30_000, send_card: true, // đăng thẻ đơn hàng vào hội thoại }); const { data: link } = await hatang.chat.orders.paymentLink(order.id, { expires_in: 3600 }); // link.checkout_url, link.qr_image_url (SVG), link.qr_payload (chuỗi EMVCo) // Khách thanh toán xong, đơn tự chuyển sang đã thanh toán. const { data: paid } = await hatang.chat.orders.list({ status: "confirmed", payment_status: "paid" }); ``` Số tiền là số nguyên, đơn vị VND. Đơn đồng bộ từ TikTok Shop nằm cùng danh sách với `source: "tiktokshop"`, `external_id` và `external_status` của TikTok. ### Sản phẩm, phiếu hỗ trợ, khách đang truy cập, báo cáo ```ts await hatang.chat.products.import({ url: "https://docs.google.com/spreadsheets/d/.../pub?output=csv" }); await hatang.chat.conversations.sendProducts("cnv_...", ["prod_a", "prod_b"], "Chị tham khảo hai mẫu này ạ"); await hatang.chat.tickets.create({ title: "Đổi size", priority: "high", conversation_id: "cnv_..." }); const { data: overdue } = await hatang.chat.tickets.list({ open: true, overdue: true }); const { data: live } = await hatang.chat.live.list(); await hatang.chat.live.greet(live.data[0].id, "Chào chị, em giúp gì được ạ?"); const { data: report } = await hatang.chat.reports.get({ from: "2026-09-01", to: "2026-09-30" }); ``` Namespace còn có `sites` (site widget), `cannedResponses` (câu trả lời mẫu), `zns` (Zalo ZNS qua Official Account đã kết nối) và `reports.ratings`. Ý nghĩa từng endpoint xem ở [REST API hỗ trợ](https://hatang.dev/vi/docs/guides/support/rest-api.md). ### Kiến thức, gói thuê bao và liên kết hồ sơ ```ts // Nội dung trợ lý AI dùng để trả lời, theo id của bạn; tài liệu không đổi thì không lập chỉ mục lại. await hatang.knowledge.sync("help", articles.map((a) => ({ external_id: `help:${a.slug}`, title: a.title, content: a.body, url: a.url }))); const { data: hits } = await hatang.knowledge.search("đổi trả trong bao lâu", { locale: "vi" }); // Gói backend của bạn biết (web, App Store, Google Play), hiện trên hồ sơ khách và cho trợ lý. await hatang.chat.subscriptions.upsert("web", "sub_123", { external_user_id: "u_42", sku: "PRO-1M", status: "active", current_period_end: "2026-11-30T00:00:00Z" }); // Giao dịch mua một lần (gói xu, mở khoá, khoá học), kể cả bán trên web; hoàn tiền thì gửi status "refunded" với cùng mã. await hatang.chat.purchases.upsert("web", "order_981", { external_user_id: "u_42", store_product_id: "credits.300", sku: "XU-300", kind: "consumable", amount: 29000, currency: "VND" }); const { data: token } = await hatang.chat.subscriptions.purchaseToken("site_...", "u_42"); // appAccountToken của StoreKit / obfuscatedAccountId của Play // Liên kết nhân viên mở cho người đang chat. await hatang.chat.profileLinks.set([{ label: "Admin", url: "https://example.com/admin/users/{{user_id}}" }]); ``` Xem [Knowledge API](https://hatang.dev/vi/docs/guides/support/knowledge-api.md) để biết về tài liệu và bộ sưu tập. ## Thanh toán và hoá đơn điện tử {#payments} ```ts const hatang = new Hatang(process.env.HATANG_API_KEY, { baseUrl: "https://api.hatang.dev", environment: "development" }); // bỏ environment ở production // Một khoản thanh toán cho mỗi đơn: mã đơn là idempotency key, gọi lại trả về đúng khoản cũ const { data: payment } = await hatang.payments.createOrder({ order_id: "SO-1001", amount: 150_000, ipn_url: "https://shop.vn/api/hatang/ipn", return_url: "https://shop.vn/orders/SO-1001", }); // payment.checkout_url, payment.qr.image_url, payment.code // Trước khi giao hàng: hỏi Hatang xem đơn này đã thật sự được thanh toán chưa const { data: check } = await hatang.payments.verify(payment.id, { order_id: "SO-1001", amount: 150_000 }); if (check?.verified) await fulfil("SO-1001"); ``` Xác minh từng lần gọi IPN tới `ipn_url` của bạn: ```ts import { constructIpnEvent } from "@hatang/sdk"; const ipn = await constructIpnEvent(rawBody, request.headers, process.env.HATANG_SECRET_KEY!); // throw HatangSignatureError if (ipn.data.status === "paid") await markOrderPaid(ipn.data.metadata.order_id); ``` | Hàm | Trả về | Ghi chú | |---|---|---| | `verifyIpn(rawBody, signature, secretKey)` | `Promise` | HMAC-SHA256 (hex) của raw body với secret key, so với header `Hatang-Signature`. | | `constructIpnEvent(rawBody, headers, secretKey)` | `Promise` | Đọc `Hatang-Signature` từ `headers`, xác minh, trả về `{ id, event_id, type, environment, created_at, data }`. Dedupe theo `id`: giá trị này giữ nguyên qua mọi lần thử lại. | `payments` còn có `create`, `get`, `list`, `cancel`, `markPaid`, `ipnDeliveries`, `qr`, `simulateTransfer` (chỉ development), `transactions`, `bankConnections` và `statements`. `einvoices` tạo, phát hành, thay thế và đồng bộ hoá đơn điện tử qua tài khoản nhà cung cấp của bạn. Hướng dẫn đầy đủ ở [Thanh toán → SDK JavaScript](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md). ## Chữ ký nhận diện cho widget và SDK chat {#identity-hash} Server của bạn là nơi duy nhất giữ identity secret của site. SDK không có hàm riêng cho việc này vì chỉ cần một dòng `crypto`: ```ts import { createHmac } from "node:crypto"; const hash = createHmac("sha256", process.env.HATANG_IDENTITY_SECRET!).update(userId).digest("hex"); // Trả { userId, hash } về cho widget hoặc ứng dụng di động ``` Xem [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ## Kiểu TypeScript {#types} Gói viết bằng TypeScript và xuất mọi kiểu request, response, nên bạn hiếm khi phải tự khai báo. ```ts import type { CreateEmailOptions, Email, Payment, WebhookEvent, ChatConversation, ChatMessage, Result } from "@hatang/sdk"; import { VERSION } from "@hatang/sdk"; // "0.6.2" ``` Kiểu của chat dùng đúng tên trường snake_case của API. `constructWebhookEvent()` nhận tham số kiểu cho `data`, ví dụ `constructWebhookEvent(…)`. ## Runtime {#runtimes} | Runtime | Trạng thái | |---|---| | Node.js 20+ | Hỗ trợ | | Bun 1.1+ | Hỗ trợ | | Deno, Cloudflare Workers, Vercel Edge | Client chạy được ở nơi có `fetch`; nhớ đặt `baseUrl`. `User-Agent` khi đó ghi `node/unknown`. | `verifyWebhook`, `constructWebhookEvent`, `verifyIpn` và `constructIpnEvent` chỉ dùng Web Crypto nên chạy được ở mọi runtime trên. Gửi tệp đính kèm dạng `Buffer` cần `Buffer` của Node; ở edge runtime hãy truyền chuỗi base64. ## Mới trong 0.6.0 {#whats-new} | Thay đổi | Chi tiết | |---|---| | `chat.purchases` | `upsert(platform, transactionId, payload)`, `list`, `forCustomer`: giao dịch mua một lần từ backend, kể cả bán trên web và hoàn tiền | | `sku` | trong `chat.subscriptions.upsert` và `chat.purchases.upsert`: tìm sản phẩm trong danh mục theo SKU, cho các lượt bán trên website của bạn | | CommonJS | `require("@hatang/sdk")` chạy được trong backend CommonJS (NestJS, Express cũ, ts-jest), kèm khai báo kiểu riêng | ## Mới trong 0.5.0 {#new-in-0-5} | Namespace | Lệnh | |---|---| | `knowledge` | `upsert`, `batch`, `sync`, `get`, `list`, `remove`, `search` | | `chat.subscriptions` | `upsert`, `list`, `forCustomer`, `purchaseToken` | | `chat.profileLinks` | `list`, `set` | | `chat.products` | sản phẩm phần mềm: `kind`, `billing_period`, `trial_days` và giá theo nền tảng `prices` | ## Mới trong 0.4.0 {#new-in-0-4} 0.4.0 thêm các lệnh sau (chi tiết trong [changelog](https://www.npmjs.com/package/@hatang/sdk?activeTab=versions)): | Namespace | Lệnh | |---|---| | `templates` | `create`, `get`, `list`, `update`, `remove`, `publish`, `duplicate`, và `emails.send({ template })` | | `emails` | `metrics()` | | `logs`, `usage` | `logs.list`, `logs.get`, `usage.get` | | `contacts` | liên hệ dùng chung không cần `audience_id`, `contacts.segments.*`, `contacts.topics.*` | | `segments`, `topics`, `contactProperties` | đủ CRUD | | `events`, `automations` | `events.create/get/list/update/remove/send`, `automations.create/get/list/update/remove/stop/duplicate`, `automations.runs.list/get` | 0.4.0 cũng bỏ namespace `blog`, vì API blog không còn là API công khai, và đặt `https://api.hatang.dev` làm `baseUrl` mặc định. ## Bước tiếp theo - [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md): API key, scope và môi trường. - [Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md): cài endpoint và xử lý thử lại. - [Thanh toán → SDK JavaScript](https://hatang.dev/vi/docs/guides/payments/sdk-javascript.md): toàn bộ luồng thanh toán. - [Tham chiếu API](https://hatang.dev/vi/docs/reference/api.md): mọi endpoint mà SDK bọc lại. Source: https://hatang.dev/vi/docs/guides/sdks/node --- # SDK Hạ Tầng Các SDK của Hạ Tầng đưa chat hỗ trợ vào website và ứng dụng của bạn, và nối server của bạn với API Hạ Tầng. Mọi client chat nói cùng một [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) với gateway `chat.hatang.dev`, nên khách nhắn từ website, ứng dụng di động, Zalo, Messenger, Instagram hay TikTok Shop đều về cùng một hộp thư. ## Các SDK {#sdks} | Nền tảng | Gói | Trạng thái | Hướng dẫn | |---|---|---|---| | Website (thẻ script) | `https://chat.hatang.dev/widget.js` | Ổn định | [Widget web và JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) | | React / Next.js | [`@hatang/chat-react`](https://www.npmjs.com/package/@hatang/chat-react) | Ổn định | [React và Next.js](https://hatang.dev/vi/docs/guides/sdks/react.md) | | WordPress | [Hạ Tầng Chat](https://wordpress.org/plugins/hatang-chat/) | Ổn định | [Plugin WordPress](https://hatang.dev/vi/docs/guides/integrations/wordpress.md) | | Flutter (iOS, Android, web, desktop) | [`hatang_chat`](https://pub.dev/packages/hatang_chat) | Ổn định | [Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md) | | React Native / Expo | [`@hatang/chat-react-native`](https://www.npmjs.com/package/@hatang/chat-react-native) | Ổn định | [React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md) | | iOS (Swift) | [`HatangChat`](https://github.com/sota-labs/hatang-chat-ios) | Ổn định | [iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md) | | Android (Kotlin) | [`dev.hatang:chat`](https://central.sonatype.com/artifact/dev.hatang/chat) | Ổn định | [Android](https://hatang.dev/vi/docs/guides/sdks/android.md) | | Node.js (server) | [`@hatang/sdk`](https://www.npmjs.com/package/@hatang/sdk) | Ổn định | [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md) | | Python (server) | [`hatang`](https://pypi.org/project/hatang/) | Ổn định | [SDK Python](https://hatang.dev/vi/docs/guides/sdks/python.md) | | AI coding agent (MCP) | [`@hatang/mcp`](https://www.npmjs.com/package/@hatang/mcp) | Mới | [MCP server](https://hatang.dev/vi/docs/guides/sdks/mcp.md) | ## Các SDK phối hợp thế nào {#how-they-fit} **Trên web, widget là phần lõi.** `@hatang/chat-react` nạp widget trong ứng dụng React, còn plugin WordPress nạp widget trong website WordPress. Cả hai dùng chung [JS API `$hatang`](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md), và widget theo đúng cài đặt bạn chỉnh trong dashboard. **Trong ứng dụng di động, SDK là native.** SDK Flutter, React Native, iOS và Android không nhúng web view. Mỗi SDK tự nói giao thức với gateway và có sẵn màn hình chat, danh sách hội thoại, nút chat nổi, hàm hỗ trợ thông báo đẩy và deep link. Chúng theo chung một hợp đồng: cùng khung mục trong tài liệu này, cùng các sự kiện, và cùng bộ kịch bản tuân thủ (C01–C12) mà mọi SDK phải chạy qua với gateway thật trước khi phát hành. Ứng dụng tự giới thiệu bằng khoá ứng dụng (`hak_...`) thay cho Origin của trình duyệt. **Ở server, dùng `@hatang/sdk` (Node.js) hoặc `hatang` (Python).** Hai SDK gọi REST API bằng API key: gửi email, đọc và trả lời hội thoại như nhân viên, đưa gói và giao dịch mua lên hồ sơ khách, đồng bộ khách hàng, đơn hàng và kiến thức của trợ lý, tạo thanh toán và xác minh webhook. Với PHP, Go hay ngôn ngữ khác, gọi thẳng [REST API](https://hatang.dev/vi/docs/reference/api.md). ## Chủ đề dùng chung cho mọi SDK chat {#shared} - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md): `identify` có và không có chữ ký, identity secret, cách hộp thư gộp phiên. - [Thông báo đẩy](https://hatang.dev/vi/docs/guides/sdks/push-notifications.md): tải tài khoản dịch vụ Firebase, khi nào Hạ Tầng gửi đẩy, dạng payload. - [Giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md): các khung WebSocket, `seq` và `resume`, mã đóng, JSON Schema tải về. - [Tương thích](https://hatang.dev/vi/docs/guides/sdks/compatibility.md): phiên bản SDK, giao thức và gateway tối thiểu, chính sách phiên bản. ## Ma trận hỗ trợ {#support-matrix} | SDK | Trạng thái | Hiện tại | Hỗ trợ | Giao thức | Nền tảng | |---|---|---|---|---|---| | widget.js | Ổn định | — | always current (served by the gateway) | 1 | Browsers with ES2020 (Chrome 80+, Safari 14+, Firefox 78+, Edge 80+) | | @hatang/chat-react | Ổn định | 0.1.2 | 0.1.x | 1 | React 18+; Next.js 14+ (App Router and Pages) | | @hatang/sdk | Ổn định | 0.6.2 | 0.6.x | 1 | Node 20+; Bun 1.1+; Edge runtimes with fetch (User-Agent reports node/unknown) | | hatang (Python) | Ổn định | 0.1.1 | 0.1.x | 1 | Python 3.9+ (sync and asyncio, httpx) | | hatang_chat (Flutter) | Ổn định | 0.6.1 | 0.6.x | 1 | Flutter 3.32+; Dart 3.8+; iOS 15+; Android 6.0 (API 23)+; macOS 11+; Web; Windows 10+; Linux (secure storage falls back to preferences) | | @hatang/chat-react-native | Ổn định | 0.4.2 | 0.4.x | 1 | React Native 0.76+; Expo SDK 52+ | | iOS (Swift) | Ổn định | 0.4.1 | 0.4.x | 1 | iOS 17+; Swift 6 / Xcode 16+; SwiftUI and UIKit (HatangChatViewController) | | Android (Kotlin) | Ổn định | 0.4.1 | 0.4.x | 1 | Android 8+ (API 26); Kotlin 1.9+ / 2.x; Jetpack Compose (BOM 2024.12+); View-based apps open the chat through HatangChat.open | | WordPress plugin | Ổn định | 0.2.3 | 0.2.x | 1 | WordPress 6.0+; PHP 7.4+ | Bảng đầy đủ (gói, gateway tối thiểu) ở trang [Tương thích](https://hatang.dev/vi/docs/guides/sdks/compatibility.md). ## Tham chiếu API {#api-references} - REST API Hạ Tầng (email, hộp thư, thanh toán): [Tham chiếu API](https://hatang.dev/vi/docs/reference/api.md). - Gateway chat (endpoint của widget và SDK, có OpenAPI): [Tham chiếu gateway chat](https://hatang.dev/vi/docs/reference/chat-gateway.md). Giao thức dạng máy đọc được ở [chat.hatang.dev/protocol.json](https://chat.hatang.dev/protocol.json). - `@hatang/sdk`: [tham chiếu TypeDoc](https://hatang.dev/docs/reference/js/index.html). `@hatang/chat-react`: README trên [npm](https://www.npmjs.com/package/@hatang/chat-react). - `hatang_chat` (Flutter): [tham chiếu dartdoc](https://hatang.dev/docs/reference/flutter/index.html). - `HatangChat` và `HatangChatCore` (iOS): [tham chiếu DocC](https://hatang.dev/docs/reference/ios/documentation). - `dev.hatang:chat` và `dev.hatang:chat-core` (Android): [tham chiếu Dokka](https://hatang.dev/docs/reference/android/index.html). Các tham chiếu SDK được sinh từ chú thích trong mã nguồn của từng SDK và chỉ có bản tiếng Anh. ## Bước tiếp theo - Thêm chat vào website: [Widget web và JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) hoặc [React và Next.js](https://hatang.dev/vi/docs/guides/sdks/react.md). - Thêm chat vào ứng dụng di động: [Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md), [React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md), [iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md) hoặc [Android](https://hatang.dev/vi/docs/guides/sdks/android.md). - Gọi API từ server: [SDK Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md). Source: https://hatang.dev/vi/docs/guides/sdks/overview --- # Thông báo đẩy cho khách trong ứng dụng Khi nhân viên hoặc bot trả lời mà khách không mở chat, Hạ Tầng gửi thông báo đẩy tới điện thoại của khách qua **dự án Firebase của bạn** (FCM HTTP v1). Hạ Tầng không có dự án Firebase dùng chung: thông báo mang tên và biểu tượng ứng dụng của bạn, hạn mức tính vào dự án của bạn. Trang này mô tả phần cài đặt chung cho các SDK native ([Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md#push-notifications), [React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md#push-notifications), [iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md#push-notifications) và [Android](https://hatang.dev/vi/docs/guides/sdks/android.md#push-notifications)). Khách trên website nhận email "chúng tôi đã trả lời" thay cho thông báo đẩy. ## Trước khi bắt đầu - Ứng dụng di động dùng một trong các SDK native, có khoá ứng dụng (`hak_...`). - Một dự án Firebase đã thêm ứng dụng Android và/hoặc iOS của bạn. - Với iOS: tài khoản Apple Developer sở hữu bundle ID của ứng dụng. ## 1. Tải tài khoản dịch vụ Firebase {#service-account} 1. Firebase console → **Project settings → Service accounts → Generate new private key**; một tệp JSON được tải về. 2. Dashboard Hạ Tầng → **Hỗ trợ → Kênh** → chọn website → tab **Ứng dụng** → **Thông báo đẩy (Firebase)** → chọn tệp → **Lưu thông tin Firebase**. 3. Thẻ này hiện dự án, tài khoản dịch vụ, lần dùng cuối và lỗi gần nhất (nếu Google từ chối khoá). Tệp được mã hoá khi lưu. Khi chưa có tệp, khách trong ứng dụng chỉ nhận email khi có trả lời, và chỉ khi họ đã để lại email. **Gỡ thông tin** xoá tệp. ### Ứng dụng iOS {#ios-apps} Firebase chuyển thông báo tới iPhone qua APNs, nên dự án Firebase cần thêm: 1. Một **App ID** trên Apple Developer (Certificates, Identifiers & Profiles → Identifiers) có bundle ID **trùng chính xác** với ứng dụng và bật **Push Notifications**. Chỉ cần App ID, không cần tạo ứng dụng trên App Store Connect. 2. Một **APNs key** (Keys → +, tick Apple Push Notifications service). Chọn môi trường **Sandbox & Production** để dùng được cho bản debug, TestFlight và App Store. Tải tệp `.p8`, ghi lại Key ID và Team ID. 3. Firebase → Project settings → **Cloud Messaging → Apple app configuration** → tải key lên cùng Key ID và Team ID. Khi App ID không khớp bundle ID (chỉ thừa một dấu chấm ở cuối cũng đủ) hoặc key thuộc team khác, FCM trả lỗi APNs `TopicDisallowed` cho mọi lần gửi. Hạ Tầng hiện lỗi này ở mục lỗi gần nhất của thẻ Firebase. Sửa App ID không cần build lại ứng dụng. > **Note: APNs trực tiếp** > > Hạ Tầng chỉ gửi qua Firebase. SDK iOS đăng ký được token thiết bị APNs thô, và gateway nhận thiết bị có `provider: "apns"`, nhưng hiện chưa gửi gì tới chúng. Cho đến khi có gửi thẳng qua APNs, ứng dụng iOS nên dùng Firebase Messaging và đăng ký token FCM. ### Giới hạn API key Firebase của ứng dụng {#api-key} API key trong cấu hình Firebase của ứng dụng (`google-services.json`, `GoogleService-Info.plist`) không phải bí mật: nó nằm trong mọi bản build. Giới hạn nó trong Google Cloud console → APIs & Services → Credentials → key → **API restrictions**, chỉ cho các API ứng dụng dùng để lấy token, như Firebase Installations API và FCM Registration API. Hạ Tầng không dùng key này; Hạ Tầng gửi bằng tài khoản dịch vụ. ## 2. Đăng ký thiết bị từ SDK {#register} Ứng dụng lấy token FCM từ Firebase rồi giao cho SDK: | SDK | Lệnh | |---|---| | Flutter | `HatangChat.instance.setPushToken(token, provider: PushProvider.fcm)` | | React Native | `Hatang.setPushToken(token, "fcm")` | | iOS | `await HatangChat.setPushToken(fcmToken: token)` | | Android | `HatangChat.setPushToken(token)` (hàm suspend) | SDK gọi `PUT /widget/v1/sites/{siteId}/devices` với token, provider, nền tảng và, nếu có, install id, ngôn ngữ, phiên bản ứng dụng, phiên bản hệ điều hành và model máy. Một token là một điện thoại. Khi danh tính của khách được [gộp](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md), thiết bị đi theo người. `clearPushToken()` gỡ token (`DELETE devices`); gọi nó khi đăng xuất, trước `reset()`. Tab **Ứng dụng** của website liệt kê **Thiết bị đã đăng ký** cùng lần đẩy cuối. ## 3. Khi nào Hạ Tầng gửi đẩy {#when} Với mỗi tin **công khai** từ nhân viên hoặc bot (không phải tin của khách, không phải ghi chú nội bộ), Hạ Tầng kiểm tra theo thứ tự: | Điều kiện | Nếu không đạt | |---|---| | Hội thoại không bị đánh dấu spam, khách vẫn tồn tại | Bỏ qua | | Khách có ít nhất một thiết bị đã đăng ký | Không đẩy. Email "chúng tôi đã trả lời" theo quy tắc riêng (khách đang offline và đã để lại email) | | Khách **không** đang kết nối với ứng dụng ở tiền cảnh | Bỏ qua: tin đến qua socket | | Khách **không** đang xem đúng hội thoại này (`app.state` kèm `conversation_id`) | Bỏ qua | | Site đã có thông tin Firebase | Bỏ qua, ghi log một lần mỗi giờ cho mỗi site | | Dưới 20 lần đẩy trong một phút cho khách này | Bỏ qua | Sau đó Hạ Tầng gửi tới từng thiết bị. Token mà FCM báo không còn đăng ký thì bị xoá, lỗi tạm thời của FCM thì thử lại, lỗi thông tin đăng nhập (Google từ chối khoá) hiện ở mục lỗi gần nhất của thẻ Firebase. Khách đã có điện thoại đăng ký thì **không** nhận thêm email cho mỗi trả lời; email bản ghi khi hội thoại được giải quyết vẫn được gửi. SDK gửi `app.state` với `foreground: false` ngay khi ứng dụng vào nền, nên "đang kết nối" nghĩa là ứng dụng thật sự ở trên màn hình. Một client không gửi `app.state` sẽ trông như online tới 60 giây sau khi vào nền và bỏ lỡ thông báo trong khoảng đó. ## 4. Payload {#payload} ```json { "notification": { "title": "Hà (Mộc Hương)", "body": "Dạ shop còn hàng ạ" }, "data": { "hatang": "1", "site_id": "site_…", "conversation_id": "cnv_…", "message_id": "msg_…", "visitor_id": "vis_…", "kind": "message" }, "android": { "priority": "high", "collapse_key": "cnv_…", "notification": { "tag": "cnv_…", "channel_id": "hatang_messages" } }, "apns": { "headers": { "apns-priority": "10", "apns-collapse-id": "cnv_…" }, "payload": { "aps": { "sound": "default", "thread-id": "cnv_…", "badge": 3, "mutable-content": 1 } } } } ``` | Trường | Ý nghĩa | |---|---| | `notification.title` | Tên người gửi; tên site khi tin không có tên người gửi | | `notification.body` | 140 ký tự đầu của tin (thẻ được rút thành chữ). Tin chỉ có tệp: "🖼 Ảnh" hoặc "📎 Tệp đính kèm", theo ngôn ngữ của khách | | `data.*` | Chỉ có chuỗi. `hatang: "1"` để `isHatangNotification` nhận ra; `kind` là `message`, hoặc `test` với thông báo thử từ dashboard | | `collapse_key`, `tag`, `apns-collapse-id`, `thread-id` | Id hội thoại: nhiều trả lời liên tiếp thay thế nhau và được nhóm theo hội thoại | | `aps.badge` (iOS) | Tổng số tin chưa đọc của khách trong các hội thoại đang mở và đang chờ | | `android.notification.channel_id` | `hatang_messages`. Trên Android 8+, tạo kênh có id này trong ứng dụng để đặt tên và mức quan trọng; không có thì Android dùng kênh dự phòng của Firebase | Khi khách bấm thông báo, truyền `data` cho hàm `handleNotification` của SDK để mở đúng hội thoại. Trang của từng SDK chỉ cách nối sự kiện bấm khi khởi động lạnh và khi ứng dụng đang ở nền. ## 5. Gửi thử {#test} Nút **Gửi thử** ở tab **Ứng dụng** của website gửi một thông báo cố định ("Thông báo thử từ Hạ Tầng: mọi thứ đã sẵn sàng.") tới thiết bị kết nối gần nhất của site, với `data` là `{ site_id, kind: "test", hatang: "1" }` và không cần hội thoại. Dùng nó để kiểm tra tệp Firebase, kênh Android và quyền thông báo trước khi phát hành. Chưa có thiết bị nào đăng ký thì dashboard báo "Chưa có thiết bị nào đăng ký nhận thông báo." ## Bước tiếp theo - [Giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md): khung `app.state` và endpoint `PUT` / `DELETE devices`. - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md): thiết bị đi theo người khi gộp danh tính. - Nối sự kiện bấm trong SDK của bạn: [Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md#push-notifications), [React Native](https://hatang.dev/vi/docs/guides/sdks/react-native.md#push-notifications), [iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md#push-notifications), [Android](https://hatang.dev/vi/docs/guides/sdks/android.md#push-notifications). Source: https://hatang.dev/vi/docs/guides/sdks/push-notifications --- # SDK Python (`hatang`) `hatang` là client phía server cho API Hạ Tầng bằng Python. Nó lo những việc backend hay làm nhất: đưa gói và giao dịch mua lên hồ sơ khách, đồng bộ kiến thức cho trợ lý AI, trả lời hội thoại, gửi email, tạo thanh toán VietQR, và xác minh webhook, IPN. Có client đồng bộ (`Hatang`) và client asyncio (`AsyncHatang`) với cùng các hàm. > **Info: Phiên bản** > > `hatang` **0.1.1** · Python 3.9+ · [PyPI](https://pypi.org/project/hatang/) · changelog: `CHANGELOG.md` trong package ## Trước khi bắt đầu - Một API key (`ht_...`), tạo trong dashboard (xem [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md)), có scope cho những gì bạn gọi: `crm:write` cho gói và giao dịch mua, `chat:write` cho hội thoại và kiến thức, `email:send` cho email. - Với identity hash cho chat: identity secret của site (**Hỗ trợ → Kênh → site → Widget**). ## Cài đặt ```bash pip install hatang # hoặc uv add hatang # hoặc poetry add hatang ``` ## Tạo client {#client} ```python from hatang import Hatang hatang = Hatang() # đọc HATANG_API_KEY (và HATANG_BASE_URL, HATANG_ENVIRONMENT) hatang = Hatang("ht_...", base_url="http://localhost:3001", timeout=10) ``` Đóng client khi xong (`hatang.close()`, hoặc `with Hatang() as hatang:`), hoặc giữ một client suốt vòng đời tiến trình. Truyền `httpx.Client` của riêng bạn qua `http_client` khi cần proxy, retry hoặc test. asyncio (FastAPI, aiohttp, Starlette): ```python from hatang import AsyncHatang hatang = AsyncHatang() async def on_paid(order): await hatang.chat.purchases.upsert("web", order.id, {...}) ``` ## Kết quả và lỗi {#errors} Mọi hàm trả về JSON của API dưới dạng `dict`. Gọi không thành công thì ném `HatangError`: ```python from hatang import HatangError try: hatang.chat.purchases.upsert("web", order.id, body) except HatangError as e: e.status_code # mã HTTP; 0 khi không tới được Hạ Tầng (mạng, timeout) e.name # validation_error, not_found, rate_limit_exceeded, ... e.retryable # True khi không tới được, 429 hoặc 5xx: nên thử lại sau ``` Đây là cách quen thuộc của Python; SDK Node trả về `{ data, error }`. Tên trường giữ nguyên snake_case của API ở cả hai. ## Gói và giao dịch mua {#purchases} Đưa những gì khách đang trả tiền lên hồ sơ khách, để nhân viên và trợ lý AI trả lời "gói của tôi khi nào gia hạn?" bằng dữ liệu thật. Dùng đúng user id bạn truyền vào `identify` trong chat. ```python # Gói bán trên website (hoặc gói bạn đã tự xác minh với App Store / Google Play). hatang.chat.subscriptions.upsert("web", subscription.id, { "status": "active", "sku": "PRO-1M", # SKU của sản phẩm trong danh mục "external_user_id": str(user.id), "started_at": "2026-10-01T00:00:00Z", "current_period_end": "2026-11-01T00:00:00Z", "auto_renew": False, "amount": 99000, "currency": "VND", }) # Mua một lần: gói xu, mở khoá, khoá học. Hoàn tiền thì gửi status="refunded" với cùng mã. hatang.chat.purchases.upsert("web", order.id, { "store_product_id": "credits:300", # mã riêng của bạn trên web; mã sản phẩm của cửa hàng trong app "sku": "XU-300", "kind": "consumable", "external_user_id": str(user.id), "amount": 29000, "currency": "VND", "purchased_at": "2026-10-30T10:00:00Z", }) hatang.chat.purchases.list(external_user_id=str(user.id)) ``` Giao dịch trong cửa hàng dùng platform `ios` (originalTransactionId của Apple cho gói, transactionId cho mua một lần) hoặc `android` (purchase token, hoặc order id của Play). Gửi lại cùng một bản ghi chỉ cập nhật nó, nên backend có thể gửi lại mọi thứ nó biết sau mỗi thay đổi. Xem [Mua trong ứng dụng và gói thuê bao](https://hatang.dev/vi/docs/guides/support/in-app-purchases.md#backend-only). ## Kiến thức {#knowledge} ```python hatang.knowledge.sync("help", [ {"external_id": f"help:{a.slug}", "title": a.title, "content": a.body, "url": a.url, "locale": "vi"} for a in articles ]) # bộ sưu tập chỉ còn đúng các tài liệu này; tài liệu không đổi thì không lập chỉ mục lại hatang.knowledge.search("đổi trả trong bao lâu", locale="vi") ``` Xem [Knowledge API](https://hatang.dev/vi/docs/guides/support/knowledge-api.md). ## Hội thoại và khách hàng {#chat} ```python convs = hatang.chat.conversations.list(status="open", unanswered=True) hatang.chat.conversations.send(conv_id, {"content": "Đơn của bạn đã được gửi."}, idempotency_key=f"shipped:{order.id}") hatang.chat.conversations.send(conv_id, {"content": "Khách VIP", "visibility": "private"}) # ghi chú nội bộ hatang.chat.customers.update(customer_id, {"stage": "won", "fields": {"plan": "pro"}}) csv_text = hatang.chat.customers.export_csv({"stage": "won"}) ``` Sản phẩm và đơn hàng: `hatang.chat.products` (`list`, `search`, `get`, `create`, `update`, `remove`) và `hatang.chat.orders` (`list`, `get`, `create`, `update`, `payment_link`). ## Email {#emails} `from` là từ khoá của Python, nên viết body dưới dạng dict: ```python hatang.emails.send({ "from": "Shop ", "to": "an@example.com", "subject": "Đơn hàng DH12", "html": "

Cảm ơn bạn!

", "attachments": [{"filename": "invoice.pdf", "content": pdf_bytes}], # bytes được mã hoá base64 giúp bạn }, idempotency_key=f"order-mail:{order.id}") hatang.batch.send([...]) # tối đa 100 ``` ## Thanh toán và IPN {#payments} ```python payment = hatang.payments.create({"amount": 199000, "description": "DH12", "ipn_url": "https://shop.vn/hatang/ipn"}) from hatang import construct_ipn_event def ipn(request): # Django; truyền body thô trước khi parse event = construct_ipn_event(request.body, request.headers, settings.HATANG_SECRET_KEY) if event["data"]["status"] == "paid": mark_paid(event["data"]["metadata"]["order_id"]) ``` ## Webhook {#webhooks} ```python from hatang import HatangSignatureError, construct_webhook_event @app.post("/hatang/webhook") # FastAPI async def webhook(request: Request): try: event = construct_webhook_event(await request.body(), request.headers, WEBHOOK_SECRET) # whsec_... except HatangSignatureError: raise HTTPException(400) if event["type"] == "chat.message.created": ... ``` Lần gửi cũ hơn 5 phút bị từ chối (đổi bằng `tolerance_sec`). `verify_webhook` và `verify_ipn` trả về bool thay vì ném lỗi. ## Identity hash và purchase token {#identity-hash} ```python from hatang import identity_hash, purchase_token identity_hash(str(user.id), settings.HATANG_IDENTITY_SECRET) # trả {userId, hash} cho widget hoặc app purchase_token("site_...", str(user.id)) # StoreKit appAccountToken / Play obfuscatedAccountId ``` Cả hai cho kết quả giống hệt server Hạ Tầng và các SDK app. Xem [Xác minh danh tính](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ## Kiểu dữ liệu {#types} Body của request là các `TypedDict` trong `hatang.types` (`ChatSubscriptionOptions`, `ChatPurchaseOptions`, `KnowledgeDocumentInput`, `CreateEmailOptions`, `CreatePaymentOptions`…), nên mypy và editor kiểm tra được tên trường. Package có kèm `py.typed`. ## Tiếp theo - [Mua trong ứng dụng và gói thuê bao](https://hatang.dev/vi/docs/guides/support/in-app-purchases.md) - [Knowledge API](https://hatang.dev/vi/docs/guides/support/knowledge-api.md) - [REST API](https://hatang.dev/vi/docs/reference/api.md) cho những gì SDK chưa bọc Source: https://hatang.dev/vi/docs/guides/sdks/python --- # React và Next.js (`@hatang/chat-react`) `@hatang/chat-react` nạp widget chat Hạ Tầng trong ứng dụng React hoặc Next.js. Gói có một component không render gì, chỉ chèn script widget đúng một lần, và một hook trả về [JS API `$hatang`](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md) đã có kiểu. Widget vẫn là widget web, cùng giao diện và cùng cài đặt trong dashboard; gói này chỉ lo phần nạp và kiểu TypeScript. > **Info: Phiên bản** > > `@hatang/chat-react` **0.1.2** · React 18+ · Next.js 14+ (App Router và Pages Router) · [npm](https://www.npmjs.com/package/@hatang/chat-react) · changelog: tệp `CHANGELOG.md` trong gói ## Trước khi bắt đầu - Một kênh website trong Hạ Tầng. Chép **Site ID** (`site_...`) ở **Hỗ trợ → Kênh** → chọn website. - Tên miền ứng dụng của bạn chạy phải là tên miền của website (hoặc tên miền phụ), hoặc nằm trong **Origin được phép thêm** ở cài đặt website. Thêm `http://localhost:3000` vào đó khi phát triển trên máy. ## Cài đặt ```bash npm install @hatang/chat-react ``` ## Nạp widget {#load} Render `` một lần trong root layout. Module bắt đầu bằng `"use client"` nên dùng thẳng được trong App Router. ```tsx title="app/layout.tsx" import { HatangChat } from "@hatang/chat-react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Ở lần render đầu, component đặt `window.HATANG_SITE_ID`, đặt `window.HATANG_CONFIG` nếu bạn truyền `config`, rồi chèn `https://chat.hatang.dev/widget.js` với `async`. Script không bao giờ bị chèn hai lần, kể cả khi component render lại hay được render nhiều chỗ. Sau khi trang đầu tiên tải xong, website trong dashboard hiện trạng thái đã cài. ### Props {#props} | Prop | Kiểu | Mặc định | Tác dụng | |---|---|---|---| | `siteId` | `string` | bắt buộc | Site ID của website | | `host` | `string` | `https://chat.hatang.dev` | Nơi phục vụ `widget.js`. Chỉ đổi khi chạy gateway local | | `config` | `HatangConfig` | — | `{ position?: "left" \| "right"; locale?: "vi" \| "en"; hideLauncher?: boolean }`, ghi vào `window.HATANG_CONFIG` trước khi script tải | | `user` | `HatangIdentity` | — | `{ userId?, email?, name?, phone?, hash?, data? }`. Gọi `identify` khi widget sẵn sàng, và gọi lại mỗi khi `userId`, `email`, `name` hoặc `hash` đổi | `config` chỉ được đọc một lần, lúc script được chèn; đổi sau đó không tới được widget. Muốn đổi ngôn ngữ lúc chạy, gọi `setLocale`. Tuỳ chọn `offset` và `mobileOffset` của `HATANG_CONFIG` chưa có trong kiểu `HatangConfig`; nếu cần, tự đặt `window.HATANG_CONFIG` trước khi component render (xem [Widget web và JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md)). ## Gọi widget từ code {#api} `useHatang()` (hook) và `hatang()` (hàm thường, dùng ngoài component) trả về cùng một object. Lệnh gọi trước khi widget tải xong được xếp hàng, nên gọi lúc nào trong trình duyệt cũng được. Ở server, các lệnh không làm gì. ```tsx "use client"; import { useHatang } from "@hatang/chat-react"; export function SupportButton() { const chat = useHatang(); return ; } ``` | Hàm | Tác dụng | |---|---| | `open()`, `close()`, `toggle()` | Mở, đóng hoặc bật/tắt cửa sổ chat | | `hide()`, `show()` | Ẩn, hiện nút chat (ví dụ trên trang thanh toán) | | `identify(identity)` | Gắn người dùng đã đăng nhập; `hash` tính ở server ([Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md)) | | `setData(data)` | Thuộc tính tuỳ chỉnh hiển thị cho nhân viên | | `setLocale("vi" \| "en")` | Đổi ngôn ngữ widget | | `pushEvent(name, data?)` | Ghi một sự kiện vào dòng thời gian của khách | | `on(event, cb)`, `off(event, cb)` | Nghe `ready`, `open`, `close`, `message:received`, `message:sent`, `conversation:status` | | `reset()` | Quên khách hiện tại (khi đăng xuất). Widget tải lại trang để bắt đầu phiên mới | ```tsx "use client"; import { useEffect } from "react"; import { hatang } from "@hatang/chat-react"; export function ChatAnalytics() { useEffect(() => { const chat = hatang(); const onReply = (m?: unknown) => track("chat_reply", m); chat.on("message:received", onReply); return () => chat.off("message:received", onReply); }, []); return null; } ``` Widget còn nhận những lệnh mà API có kiểu này chưa liệt kê, như `lead`. Tự đẩy chúng vào hàng đợi: `window.$hatang.push(["lead", { email, name }])`. Danh sách lệnh và sự kiện đầy đủ ở [Widget web và JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md). ## Nhận diện người dùng đã đăng nhập {#identify} Tính `hash`, tức HMAC-SHA256 của user id với identity secret của site, ở server rồi truyền xuống cùng người dùng. Đừng bao giờ đưa secret vào bundle phía client. ```tsx title="app/layout.tsx (Server Component)" import { createHmac } from "node:crypto"; import { HatangChat } from "@hatang/chat-react"; import { currentUser } from "@/lib/auth"; export default async function RootLayout({ children }: { children: React.ReactNode }) { const user = await currentUser(); const identity = user ? { userId: user.id, email: user.email, name: user.name, hash: createHmac("sha256", process.env.HATANG_IDENTITY_SECRET!).update(user.id).digest("hex"), } : undefined; return ( {children} ); } ``` Với `hash` hợp lệ, hộp thư đánh dấu khách là đã xác minh và gộp hội thoại với các phiên trước của cùng người, kể cả phiên trong ứng dụng di động. Gọi `hatang().reset()` khi người dùng đăng xuất để người tiếp theo trên cùng trình duyệt không mang danh tính cũ. ## Không dùng `useEffect` {#snippet} Với Pages Router, framework khác hoặc template HTML thuần, `hatangSnippet(siteId, host?, config?)` trả về đúng đoạn script inline mà dashboard hiển thị, dưới dạng một chuỗi. Đặt nó vào thẻ ` ``` ## Các export {#exports} `HatangChat`, `HatangChatProps`, `hatang`, `useHatang`, `HatangApi`, `HatangIdentity`, `HatangEvent`, `HatangConfig`, `hatangSnippet`, `inlineSnippet` và `widgetUrl`. ## Changelog {#changelog} **0.1.1**: trang npm mô tả widget và Hạ Tầng, dẫn tới hướng dẫn này; README có thêm phần About Ha Tang. Không đổi code. **0.1.0**: bản đầu tiên. `` nạp widget một lần; `useHatang()` / `hatang()` với `open`, `close`, `toggle`, `hide`, `show`, `identify`, `setData`, `setLocale`, `pushEvent`, `on`, `off` và `reset`, được xếp hàng đến khi widget tải xong. ## Bước tiếp theo - [Widget web và JS API](https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api.md): mọi tuỳ chọn `HATANG_CONFIG`, lệnh `lead`, theo dõi chuyển đổi. - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md): danh tính có chữ ký và cách gộp phiên. - [Cài widget](https://hatang.dev/vi/docs/guides/support/web-widget/install.md): Shopify, Google Tag Manager, Webflow và HTML thuần. Source: https://hatang.dev/vi/docs/guides/sdks/react --- # SDK React Native `@hatang/chat-react-native` > **Info: Phiên bản** > > `@hatang/chat-react-native` **0.4.2** · giao thức v1 · React Native 0.76+ (New Architecture hoặc không) · Expo SDK 52+ · React 18+ · [npm](https://www.npmjs.com/package/@hatang/chat-react-native) · [Tương thích](https://hatang.dev/vi/docs/guides/sdks/compatibility.md) > **Note** > > Bản 0.3 có màn hình chat, hộp thư các hội thoại cũ, hook cho giao diện riêng, nhận diện có chữ ký, gửi ảnh và tệp, thẻ sản phẩm/đơn hàng/thanh toán kèm hook in-app purchase, thông báo đẩy qua Firebase của bạn, sự kiện chuyển đổi, deep link khôi phục phiên, và màn hình chat theo cài đặt widget của site. API có thể còn thay đổi trước bản 1.0. Chat hỗ trợ cho ứng dụng React Native và Expo: SDK nói thẳng [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) với gateway Hạ Tầng (không web view), giống các SDK [Flutter](https://hatang.dev/vi/docs/guides/sdks/flutter.md), [iOS](https://hatang.dev/vi/docs/guides/sdks/ios.md) và [Android](https://hatang.dev/vi/docs/guides/sdks/android.md). Giao diện dựng hoàn toàn từ component lõi của React Native, gói không có mã native riêng; các module Expo (lưu trữ an toàn, thông tin thiết bị, chọn ảnh/tệp) là tuỳ chọn và được nạp khi có cài. Khách nhắn từ ứng dụng vào cùng hộp thư với khách trên website, Zalo, Messenger, Instagram và TikTok Shop. ## Cài đặt {#installation} ### Yêu cầu | | Phiên bản | |---|---| | React Native | 0.76+ (New Architecture hoặc kiến trúc cũ) | | Expo | SDK 52+ (managed hoặc bare) | | React | 18+ | ### Lấy khoá ứng dụng trong dashboard SDK cần hai giá trị: **Site ID** và một **khoá ứng dụng** (`hak_…`). Khoá thay cho kiểm tra Origin của trình duyệt: ứng dụng gửi khoá khi khởi tạo, gateway biết phiên đến từ ứng dụng nào. 1. Trong dashboard, mở **Hỗ trợ → Kênh → Thêm kênh → Ứng dụng di động**. 2. Bấm **Tạo khoá ứng dụng**: đặt tên (ví dụ "Mộc Hương RN"), chọn nền tảng, bundle id tuỳ chọn. 3. Khoá chỉ hiện một lần; đặt vào `Hatang.configure({ appKey })`. Khoá công khai như site id (nằm trong bản build) nhưng thu hồi được. Để đổi khoá an toàn: tạo khoá mới, phát hành bản cập nhật dùng khoá đó, rồi thu hồi khoá cũ. Phiên của khách đang dùng khoá bị thu hồi bị ngắt trong vòng một phút. ### Thêm gói ```bash # Expo: gói chính và các module tuỳ chọn, đúng phiên bản với SDK Expo của bạn npx expo install @hatang/chat-react-native expo-secure-store expo-application expo-device expo-image-picker expo-document-picker react-native-safe-area-context # React Native thuần npm install @hatang/chat-react-native # hoặc pnpm add @hatang/chat-react-native ``` ### Module Expo tuỳ chọn Mọi gói `expo-*` là **peer dependency tuỳ chọn**. SDK nạp chúng khi chạy nếu có cài; thiếu gói nào thì tính năng tương ứng tắt, ứng dụng vẫn chạy: | Gói | Dùng cho | Khi không cài | |---|---|---| | `expo-secure-store` | Token khách trong Keychain / Keystore và vài tuỳ chọn nhỏ của phiên | Lưu trong bộ nhớ: khách bị quên mỗi lần mở lại ứng dụng, SDK ghi một cảnh báo. Truyền `storage` để thay (xem [Lưu trữ](#storage)). | | `expo-application` | Phiên bản ứng dụng (`1.4.0+12`) | Không gửi phiên bản ứng dụng | | `expo-device` | Model máy và phiên bản OS | Dùng `Platform.Version`, không có model | | `expo-image-picker` | "Chụp ảnh" và "Thư viện ảnh" trong ô nhập | Ẩn hai mục đó | | `expo-document-picker` | "Chọn tệp" trong ô nhập | Ẩn mục đó | | `react-native-safe-area-context` | Giữ màn hình chat tránh tai thỏ, thanh trạng thái và thanh home | iOS dùng `SafeAreaView` của React Native (kèm cảnh báo deprecated); Android không chừa lề. Hoặc truyền `insets`. | Nút đính kèm chỉ hiện khi có ít nhất một bộ chọn được cài. Ứng dụng React Native thuần vẫn dùng được các module này sau khi cài [`expo`](https://docs.expo.dev/bare/installing-expo-modules/) (Expo Modules). ### Quyền nền tảng Đính kèm ảnh cần khai báo lý do trên iOS. Với Expo, khai báo qua config plugin của `expo-image-picker`: ```json title="app.json" { "expo": { "plugins": [ ["expo-image-picker", { "photosPermission": "Gửi ảnh cho bộ phận hỗ trợ", "cameraPermission": "Chụp ảnh gửi cho bộ phận hỗ trợ" }] ] } } ``` Ứng dụng React Native thuần thêm `NSPhotoLibraryUsageDescription` và `NSCameraUsageDescription` vào `ios//Info.plist`. Quyền camera được hỏi ở lần dùng đầu. Tệp gửi tối đa 10 MB: ảnh (HEIC được bộ chọn chuyển sang JPEG), PDF, tệp văn phòng, zip, mp4 và m4a. Trên Android, giữ `softwareKeyboardLayoutMode: "resize"` (mặc định của Expo, tương đương `android:windowSoftInputMode="adjustResize"`): màn hình chat dựa vào việc cửa sổ co lại khi bàn phím mở. Trên iOS, `KeyboardAvoidingView` lo phần này. Expo Go chạy được SDK khi bạn chỉ dùng `expo-secure-store`, `expo-application`, `expo-device` và các bộ chọn; thông báo đẩy qua Firebase cần [development build](https://docs.expo.dev/develop/development-builds/introduction/). ## Khởi tạo {#initialization} Gọi `Hatang.configure` một lần lúc khởi động, ngoài mọi component (ví dụ đầu `App.tsx` hoặc `index.js`). SDK bootstrap khách, lưu token và mở socket ngay (`autoConnect: true`). ```ts title="hatang.ts" import { Hatang } from "@hatang/chat-react-native"; Hatang.configure({ siteId: "site_…", appKey: "hak_…", // baseUrl: "https://chat.hatang.dev", // mặc định // locale: "vi", // autoConnect: false, // tự gọi Hatang.connect() sau }); ``` | Tham số | Ý nghĩa | |---|---| | `siteId`, `appKey` | Bắt buộc; lấy ở tab **Ứng dụng** của website | | `baseUrl` | Gateway. Mặc định `https://chat.hatang.dev`; chỉ đổi khi chạy gateway local (xem [Khắc phục sự cố](#troubleshooting)) | | `locale` | `"vi"` / `"en"`. Bỏ trống thì theo lựa chọn đã lưu, rồi site, rồi máy | | `storage`, `secureStorage` | Kho lưu trữ riêng, xem [Lưu trữ](#storage) | | `autoConnect` | `false` để tự gọi `Hatang.connect()` sau (ví dụ sau màn hình đồng ý) | | `openOnProactiveMessage` | Mở chat khi đội nhắn trước (mặc định `true`) | | `commerce` | Cách nút **Mua** và **Thanh toán** trên thẻ hoạt động, xem [Thẻ bán hàng và in-app purchase](#commerce) | | `logger` | `{ debug?, info?, warn?, error? }` để ghi log khi debug | | `app` | Ghi đè thông tin ứng dụng gửi lên gateway (`version`, `os`, `osVersion`, `deviceModel`…) | Gọi `configure` lần nữa sẽ thay engine cũ (listener của `Hatang.on` vẫn giữ). Các thành viên khác của facade: `Hatang.connect()`, `Hatang.disconnect()`, `Hatang.dispose()`, `Hatang.isConfigured`, `Hatang.session` (`visitorId`, `conversationId`, `locale`, `isIdentified`, `email`, `name`), `Hatang.unreadCount`, `Hatang.availability`, `Hatang.connection`. `Hatang.engine` trả về `ChatEngine` đầy đủ cho trường hợp nâng cao. SDK tự theo `AppState`: khi ứng dụng vào nền, gateway được báo ngay (`app.state`) để trả lời mới đi qua thông báo đẩy, và socket đóng khoảng 10 giây sau; khi trở lại tiền cảnh, SDK kết nối lại và bắt kịp tin đã lỡ. ### Lưu trữ {#storage} Mặc định SDK dùng `expo-secure-store` cho cả token khách lẫn các tuỳ chọn nhỏ của phiên; khoá đọc được sau lần mở khoá máy đầu tiên, để thông báo đẩy đánh thức ứng dụng ở nền vẫn đọc được token. Không có `expo-secure-store` và không truyền `storage` thì SDK lưu trong bộ nhớ: khách bị coi là khách mới mỗi lần mở ứng dụng. `storage` nhận một đối tượng kiểu AsyncStorage (`getItem`, `setItem`, `removeItem`) hoặc dạng `{ get, set, remove }` của SDK (mọi hàm trả `Promise`): ```ts import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Keychain from "react-native-keychain"; import { Hatang, type HatangStorage } from "@hatang/chat-react-native"; // Một adapter riêng cho token, dựa trên react-native-keychain const keychainStore: HatangStorage = { get: async (key) => { const c = await Keychain.getGenericPassword({ service: key }); return c ? c.password : null; }, set: async (key, value) => { await Keychain.setGenericPassword("hatang", value, { service: key }); }, remove: async (key) => { await Keychain.resetGenericPassword({ service: key }); }, }; Hatang.configure({ siteId: "site_…", appKey: "hak_…", storage: AsyncStorage, secureStorage: keychainStore }); ``` Thứ tự chọn: token khách đi vào `secureStorage`, nếu không có thì `expo-secure-store`, nếu không có thì `storage`. Nghĩa là khi đã cài `expo-secure-store`, token luôn nằm ở đó kể cả khi bạn truyền `storage`. Đừng đặt token vào AsyncStorage nếu có lựa chọn khác: AsyncStorage không mã hoá. ## Nhận diện khách {#identify-visitors} Khi người dùng đăng nhập, gọi `identify` với id trong hệ thống của bạn. `hash` là HMAC-SHA256 (hex) của `userId` với identity secret của site, **tính ở backend của bạn** rồi trả về ứng dụng; đừng bao giờ đưa secret vào ứng dụng. Danh tính có chữ ký được gộp với các phiên trước của cùng người (kể cả trên web); không có chữ ký chỉ được lưu như thông tin tự khai. Chi tiết ở [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md). ```ts // Backend trả về { userId, hash } sau khi đăng nhập await Hatang.identify({ userId: me.id, email: me.email, name: me.name, phone: me.phone, hash: me.hatangHash, data: { plan: "pro", orders: 12 }, }); ``` Một dòng ở backend để tính `hash`: ```js title="Node" const hash = require("node:crypto").createHmac("sha256", process.env.HATANG_IDENTITY_SECRET).update(userId).digest("hex"); ``` ```php title="PHP" $hash = hash_hmac('sha256', $userId, getenv('HATANG_IDENTITY_SECRET')); ``` Khi danh tính đã thuộc về một khách khác (ví dụ người dùng từng chat trên máy khác), gateway gộp phiên: SDK nhận token mới, nạp lại hội thoại và phát `sessionChanged` với `reason: "identity_merged"`; bạn không phải làm gì thêm. ### Đăng xuất ```ts await Hatang.reset(); ``` `reset()` thu hồi token cũ, xoá trạng thái cục bộ, bắt đầu một khách mới và đăng ký lại token đẩy cho khách mới, để người dùng tiếp theo trên cùng máy không thấy hội thoại của người trước. Muốn dừng hẳn thông báo đẩy cho máy này thì gọi thêm `Hatang.clearPushToken()` trước `reset()`. ## Mở chat và nhúng {#open-and-embed} ### HatangProvider và modal mặc định Bọc ứng dụng trong `HatangProvider`. Khi bạn không truyền `onOpenRequest`, provider tự mở `HatangChatScreen` trong một modal toàn màn hình khi `Hatang.open()` được gọi, khi người dùng bấm thông báo đẩy, hoặc khi đội nhắn trước. ```tsx title="App.tsx" import { Button } from "react-native"; import { Hatang, HatangProvider, useHatangUnread } from "@hatang/chat-react-native"; export default function App() { return ( ); } function SupportButton() { const unread = useHatangUnread(); return
Cần tư vấn? Nhắn ngay ``` Muốn chỉ dùng nút của mình, ẩn nút chat bằng `window.HATANG_CONFIG = { hideLauncher: true }` và tự hiện số tin chưa đọc qua sự kiện `unread`. ### identify {#identify} ```js $hatang.push(["identify", { user_id: "u_42", email: "ha@example.com", name: "Nguyễn Thu Hà", phone: "0912345678", hash: "", data: { plan: "pro", orders: 12 } }]); ``` | Trường | Kiểu | Giới hạn | |---|---|---| | `user_id` | string | Tối đa 200 ký tự. ID người dùng trong hệ thống của bạn. | | `email` | string | Email hợp lệ, tối đa 320 ký tự. | | `name` | string | Tối đa 200 ký tự. | | `phone` | string | Tối đa 40 ký tự. | | `hash` | string | `HMAC-SHA256(identity secret, user_id)` dạng hex, tính ở server. | | `data` | object | Thuộc tính tuỳ chỉnh, tối đa 8 KB dạng JSON. | Không có `hash`, thông tin được lưu là thông tin khách tự khai và hộp thư gắn nhãn **Chưa xác minh**. Có `hash` hợp lệ, hộp thư gắn nhãn **Đã xác minh** và nối mọi phiên của người dùng đó. Đọc [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md) trước khi dùng `user_id`. > **Caution** > > Tên trường là `user_id`, viết kiểu snake case. Các khoá khác bị bỏ đi. Nếu `email` không hợp lệ hoặc một giá trị vượt giới hạn, cả lệnh `identify` bị từ chối, nên hãy kiểm tra dữ liệu trước. ### setData ```js $hatang.push(["setData", { plan: "pro", cart_value: 450000, last_order: "DH-1042" }]); ``` `setData` là `identify` chỉ có `data`. Nhân viên thấy các thuộc tính trong hồ sơ khách ở hộp thư. Khoá mới được thêm, khoá trùng bị ghi đè, khoá không gửi thì giữ nguyên. Mỗi lần gọi tối đa 8 KB JSON. ### pushEvent ```js $hatang.push(["pushEvent", "viewed_pricing", { plan: "pro" }]); ``` Trong lúc có hội thoại, sự kiện hiện trong hội thoại đó, để nhân viên biết khách đang làm gì khi chat. Tên tối đa 100 ký tự. Trước khi khách nhắn, các sự kiện và trang khách xem được giữ làm hành trình của khách: 20 bước gần nhất, trong 24 giờ. Khi khách mở hội thoại (tin nhắn đầu tiên, form offline, lead hay chiến dịch), hành trình được gắn vào hội thoại và hiện cho nhân viên dưới mục **Trước khi nhắn** trong hội thoại và thanh thông tin khách. Khách không bao giờ thấy. Cùng một trang lặp lại liền nhau chỉ tính một lần. Dữ liệu của mỗi sự kiện được cắt còn 1 KB JSON, và giá trị dưới các khoá trông như bí mật (chứa `password`, `token`, `secret`, `card`, `cvv` hoặc `otp`, không phân biệt hoa thường) được lưu thành `[redacted]`, kể cả trong URL trang. Đừng gửi dữ liệu cá nhân mà bạn không muốn nhân viên thấy. Widget cũng tự báo khi khách chuyển trang, kể cả khi ứng dụng một trang đổi URL (`history.pushState`, `replaceState` và nút quay lại), nên nhân viên thấy trang hiện tại trong **Hỗ trợ → Đang truy cập** và trong hộp thư. ## Sự kiện {#events} Đăng ký bằng `on`, huỷ bằng `off`: ```js function onReply(message) { console.log("Trả lời mới từ", message.sender_type, message.content); } $hatang.push(["on", "message:received", onReply]); // sau đó $hatang.push(["off", "message:received", onReply]); ``` | Sự kiện | Dữ liệu | Khi nào | |---|---|---| | `ready` | `{ visitor_id, conversation_id }` | Widget đã khởi động. `conversation_id` là `null` nếu khách chưa có hội thoại đang mở. | | `open` | không | Khung chat mở. | | `close` | không | Khung chat đóng. | | `message:received` | `{ id, sender_type, content }` | Nhân viên, trợ lý AI hoặc hệ thống nhắn trong hội thoại của khách. `sender_type` là `agent`, `bot` hoặc `system`. | | `message:sent` | `{ id, sender_type, content }` | Tin của khách đã được lưu, kể cả tin gửi từ tab khác. `sender_type` là `visitor`. | | `conversation:status` | `{ status, conversation_id }` | Hội thoại đổi trạng thái: `open`, `pending`, `resolved` hoặc `closed`. | | `unread` | `{ count }` | Số tin trả lời chưa đọc thay đổi. Về 0 khi khách mở khung chat. | | `conversion` | `{ name, ...data }` | Một chuyển đổi được ghi nhận: `name` là `chat_started`, `lead` hoặc `purchase`. Chỉ phát khi bật theo dõi chuyển đổi. | > **Tip** > > `ready` chỉ phát một lần, ngay sau khi các lệnh trong hàng đợi chạy xong. Đặt `on("ready", ...)` trước khi script widget tải, ví dụ trong thẻ thứ nhất của đoạn mã, nếu không sẽ lỡ sự kiện này. ### Ví dụ: huy hiệu chưa đọc của riêng bạn ```html ``` ## Lead từ form của bạn {#leads} Bất kỳ form nào trên website cũng tạo được lead. Widget nhận diện khách bằng thông tin trong form, mở hội thoại (hoặc dùng hội thoại đang mở của khách) với một tin tóm tắt form, phân phối theo quy tắc phân phối của bạn và tính một chuyển đổi `lead`. ```js document.querySelector("#quote-form").addEventListener("submit", function (e) { e.preventDefault(); const f = new FormData(e.target); $hatang.push(["lead", { name: f.get("name"), phone: f.get("phone"), email: f.get("email") || undefined, message: f.get("message"), source: "pricing-form", fields: { city: f.get("city"), budget: f.get("budget") } }]); }); ``` | Trường | Kiểu | Giới hạn | |---|---|---| | `name` | string | Tối đa 200 ký tự. | | `phone` | string | Tối đa 40 ký tự. Cần số điện thoại hoặc email. | | `email` | string | Email hợp lệ, tối đa 320 ký tự. | | `message` | string | Tối đa 2.000 ký tự. | | `source` | string | Tối đa 100 ký tự. Hiện trong hội thoại và dùng làm tên chiến dịch trong hệ thống phân tích. Mặc định `form`. | | `fields` | object | Chỉ giá trị chuỗi: khoá tối đa 60 ký tự, giá trị tối đa 500. Lưu vào khách truy cập và hồ sơ khách hàng. | Mỗi lead cũng phát sự kiện [webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md) `chat.campaign.lead`. ### Không cần widget Landing page tĩnh có thể gửi cùng JSON thẳng tới chat gateway. Request phải đến từ một trang trên trình duyệt thuộc origin được phép của site; request không có header `Origin` bị từ chối. ```js await fetch("https://chat.hatang.dev/widget/v1/sites/site_123.../leads", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Hà", phone: "0912345678", message: "Báo giá gói Pro", source: "landing-tet" }) }); ``` Gọi thành công trả về `201`: ```json { "ok": true, "conversation_id": "cnv_123...", "visitor_id": "vis_123...", "customer_id": "cus_123..." } ``` | Mã | Lỗi | Nguyên nhân | |---|---|---| | 403 | `origin_not_allowed` | Origin của trang không được phép với site. | | 403 | `site_disabled` | Site đang tắt. | | 422 | `validation_error` | Không có số điện thoại lẫn email, email sai, hoặc một trường vượt giới hạn. | | 429 | `rate_limit_exceeded` | Quá 10 lead mỗi phút từ một địa chỉ IP. | Thêm một ô ẩn tên `website` vào form và gửi kèm giá trị của nó: đó là honeypot. Người thật để trống; nếu bot điền vào, gateway trả `200 { "ok": true }` và không lưu gì. ## Theo dõi chuyển đổi {#conversion-tracking} Bật ở **Hỗ trợ → Kênh → website của bạn → Tự động hoá → Theo dõi chuyển đổi**. Khi đó widget báo ba loại chuyển đổi: | Chuyển đổi | Khi nào | Sự kiện gtag | Sự kiện dataLayer | Meta Pixel | |---|---|---|---|---| | `chat_started` | Khách gửi tin đầu tiên của một hội thoại. | `chat_started` | `hatang_chat_started` | `Contact` | | `lead` | Khách gửi form của chiến dịch, hoặc trang gọi `lead`. | `generate_lead` | `hatang_generate_lead` | `Lead` | | `purchase` | Một đơn hàng trong chat được thanh toán. | `purchase` | `hatang_purchase` | `Purchase` | - Nếu trang có `gtag`, widget gọi `gtag("event", ...)` kèm `source: "hatang_chat"`. Nếu bạn điền **Chuyển đổi Google Ads (send_to)**, widget gửi thêm sự kiện `conversion` tới đích đó. - Nếu không có `gtag` nhưng có `window.dataLayer`, widget đẩy sự kiện dataLayer cho Google Tag Manager. - Nếu trang có `fbq`, widget ghi sự kiện Meta Pixel. - Trong mọi trường hợp, widget phát sự kiện `conversion`. Điền **GA4 measurement id**, **GA4 API secret**, **Meta pixel id** và **Meta Conversions API token** để gửi thêm sự kiện phía server (GA4 Measurement Protocol, Meta Conversions API). Các sự kiện này không bị trình chặn quảng cáo chặn và tính cả đơn thanh toán từ Zalo, Messenger, Instagram. Khi khởi động, widget gửi các cookie `_ga`, `_fbp`, `_fbc` để hai phía nối vào cùng một phiên phân tích. ## Bước tiếp theo - [Nhận diện khách](https://hatang.dev/vi/docs/guides/support/web-widget/identity-verification.md): ký `identify` để hộp thư tin `user_id`. - [SDK React](https://hatang.dev/vi/docs/guides/sdks/react.md): `` và `useHatang()` bọc API này. - [Realtime](https://hatang.dev/vi/docs/guides/support/realtime.md) và [giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md): tự viết client riêng. Source: https://hatang.dev/vi/docs/guides/support/web-widget/javascript-api --- # Zalo ZNS ZNS (Zalo Notification Service) gửi một tin nhắn theo mẫu tới số điện thoại qua Zalo Official Account (OA) của bạn. Khách không cần nhắn cho bạn trước, nên ZNS rất hợp để xác nhận đơn hàng, nhắc thanh toán hay báo tình trạng giao hàng. Bạn gửi ZNS được từ trang đơn hàng, tới một danh sách khách hàng đã lọc, từ chu trình, hoặc qua API. Zalo tính phí theo từng tin. ## Trước khi bắt đầu {#before-you-start} 1. **Kết nối Zalo Official Account** ở **Hỗ trợ → Kênh**. Xem [Zalo OA](https://hatang.dev/vi/help/channels/zalo-oa). Dashboard chỉ cho chọn tài khoản đang ở trạng thái **Đang hoạt động**. 2. **Nâng OA lên gói trả phí của Zalo** có ZNS. Nếu chưa, Hạ Tầng không tải được mẫu tin của bạn. 3. **Tạo mẫu và chờ duyệt** trong trang quản trị Zalo OA: oa.zalo.me → ZNS → Mẫu. Hạ Tầng chỉ liệt kê mẫu đã được duyệt. 4. **Giữ module Khách hàng (CRM) đang bật.** ZNS thuộc module Khách hàng: các endpoint API nằm trong module này, và tin đang xếp hàng bị bỏ qua khi module tắt. Xem [Doanh nghiệp và module](https://hatang.dev/vi/help/getting-started/modules). ## Trang Zalo ZNS {#the-zalo-zns-page} Vào **Hỗ trợ → Kênh**. Thẻ **Zalo ZNS** có nút **Cài đặt ZNS** mở trang Zalo ZNS. Trang này cho OA đang chọn hiển thị: - **Hạn mức hôm nay**: số tin còn được gửi trong ngày trên tổng hạn mức của OA. - Tổng số tin đã gửi, thất bại và đang trong hàng đợi. - Các mẫu đã duyệt với tên, id, trạng thái, chất lượng và giá mỗi tin. - **Gửi gần đây** với thời gian, số điện thoại, mẫu, trạng thái (kèm lỗi của Zalo khi thất bại) và liên kết tới đơn hàng, khách hàng. Nếu doanh nghiệp có nhiều OA, chọn OA ở đầu trang. ## Gửi thông báo cho một đơn hàng {#send-a-notification-for-an-order} 1. Mở đơn hàng (**Đơn hàng**, rồi chọn đơn). 2. Trên thẻ **Zalo ZNS**, bấm **Gửi thông báo**. 3. Chọn OA và mẫu tin. Số điện thoại lấy từ địa chỉ giao hàng của đơn; phải là số di động Việt Nam. 4. Điền tham số của mẫu. Hạ Tầng tự điền sẵn các tham số có tên gợi ý tới tên khách, mã đơn, tổng tiền hoặc số điện thoại. 5. Bấm **Gửi ZNS**. Tin được gửi ngay. Thẻ hiện **Đã gửi (msg …)** với mã tin của Zalo, hoặc lỗi Zalo trả về. ## Gửi cho danh sách khách hàng {#send-to-a-list-of-customers} 1. Vào **Khách hàng** và lọc danh sách (tìm kiếm, giai đoạn, người phụ trách, nhãn, kênh). 2. Bấm **Gửi ZNS** (trang này cũng có trên thanh bên với tên **Gửi ZNS**). 3. Chọn OA, mẫu tin và **Số tin tối đa** (tối đa 5.000). 4. Gán giá trị cho từng **Tham số của mẫu**: | Giá trị | Ý nghĩa | |---|---| | `customer.name`, `customer.phone`, `customer.email` | Thông tin của khách | | `field:` | Một trường tuỳ chỉnh, ví dụ `field:member_tier` | | `text:` và văn bản cố định phía sau | Cùng một nội dung cho mọi người, ví dụ `text:20/11` | 5. Kiểm tra số lượng: trang cho biết bao nhiêu khách khớp bộ lọc và bao nhiêu khách có số điện thoại. Rồi bấm **Xếp hàng N tin ZNS**. Khách không có số điện thoại bị bỏ qua. Tin trong hàng đợi được gửi theo tốc độ của OA, kết quả hiện ở mục **Gửi gần đây** trên trang Zalo ZNS. Tin bị Zalo giới hạn tốc độ được thử lại, tối đa 3 lần. > **Caution** > > Zalo tính phí mỗi tin. Kiểm tra số người nhận và giá của mẫu trước khi xếp hàng một đợt gửi lớn. ## Gửi từ chu trình {#send-from-a-workflow} Thêm bước **Gửi ZNS** vào một [chu trình](https://hatang.dev/vi/docs/guides/support/automation.md#workflows), ví dụ với sự kiện **Đơn đã thanh toán**. Chọn OA, nhập **Mã mẫu ZNS** và tham số, mỗi dòng một cặp `tên=giá trị`. Giá trị dùng được `{{name}}`, `{{phone}}`, `{{email}}`, `{{order_code}}`, `{{order_total}}`, `{{rating}}` và `{{site}}`. Bước này dùng số điện thoại của khách và bị bỏ qua khi khách chưa có số. ## Gửi qua API {#send-with-the-api} | Endpoint | Dùng để | |---|---| | `GET /chat/zns/templates?account_id=cha_…` | Mẫu đã duyệt của một OA, kèm hạn mức hôm nay | | `GET /chat/zns/templates/{id}?account_id=cha_…` | Một mẫu kèm danh sách tham số | | `POST /chat/zns` | Gửi ngay một tin theo mẫu | | `POST /chat/zns/bulk` | Xếp hàng một mẫu cho mọi khách khớp bộ lọc | | `GET /chat/zns` | Tin doanh nghiệp đã gửi (lọc theo `customer_id`, `order_id`, `limit`) | Đọc cần scope `crm:read`, gửi cần `crm:write`. Key có `chat:read` / `chat:write` cũng được chấp nhận. `account_id` là id của OA đã kết nối: phần `cha_…` ở cuối địa chỉ trang của OA trong dashboard (`…/chat/channels/zalo/cha_…`). ### Gửi một tin {#send-one-message} Các trường trong body: | Trường | Bắt buộc | Ghi chú | |---|---|---| | `account_id` | Có | Tài khoản Zalo OA (`cha_…`) | | `template_id` | Có | Id của mẫu đã duyệt | | `phone` | Có | Số di động Việt Nam; `0912 345 678`, `84912345678` và `+84 912 345 678` đều được | | `template_data` | Có | Tham số của mẫu, dạng chuỗi (khoá tối đa 60 ký tự, giá trị tối đa 500) | | `customer_id`, `order_id` | Không | Gắn tin với khách hàng và đơn hàng | | `tracking_id` | Không | Mã tham chiếu của bạn, tối đa 60 ký tự; mặc định là id tin của Hạ Tầng | **cURL** ```bash curl -X POST https://api.hatang.dev/chat/zns \ -H "Authorization: Bearer ht_123..." \ -H "Content-Type: application/json" \ -d '{ "account_id": "cha_123", "template_id": "231234", "phone": "0912345678", "template_data": { "customer_name": "Lan", "order_code": "DH1024", "amount": "350.000đ" }, "order_id": "ord_123" }' ``` **Node.js** ```js import { Hatang } from "@hatang/sdk"; const hatang = new Hatang(process.env.HATANG_API_KEY); const { data, error } = await hatang.chat.zns.send({ account_id: "cha_123", template_id: "231234", phone: "0912345678", template_data: { customer_name: "Lan", order_code: "DH1024", amount: "350.000đ" }, order_id: "ord_123", }); if (error) throw new Error(error.message); if (data.status === "failed") console.error("Zalo từ chối:", data.error); ``` **Python** ```python import os, requests res = requests.post( "https://api.hatang.dev/chat/zns", headers={"Authorization": f"Bearer {os.environ['HATANG_API_KEY']}"}, json={ "account_id": "cha_123", "template_id": "231234", "phone": "0912345678", "template_data": {"customer_name": "Lan", "order_code": "DH1024", "amount": "350.000đ"}, "order_id": "ord_123", }, ) message = res.json() if message.get("status") == "failed": print("Zalo từ chối:", message["error"]) ``` **PHP** ```php 'https://api.hatang.dev']); $res = $client->post('/chat/zns', [ 'headers' => ['Authorization' => 'Bearer ' . getenv('HATANG_API_KEY')], 'json' => [ 'account_id' => 'cha_123', 'template_id' => '231234', 'phone' => '0912345678', 'template_data' => ['customer_name' => 'Lan', 'order_code' => 'DH1024', 'amount' => '350.000đ'], 'order_id' => 'ord_123', ], ]); $message = json_decode($res->getBody(), true); ``` Response (`201 Created`): ```json { "object": "chat_zns_message", "id": "zns_...", "account_id": "cha_123", "customer_id": null, "order_id": "ord_123", "phone": "84912345678", "template_id": "231234", "template_name": null, "template_data": { "customer_name": "Lan", "order_code": "DH1024", "amount": "350.000đ" }, "tracking_id": null, "msg_id": "f1d2...", "status": "sent", "error": null, "created_at": "2026-10-23T03:00:00.000Z" } ``` > **Note** > > Lời gọi trả về `201` cả khi Zalo từ chối tin. Hãy kiểm tra `status`: `sent` nghĩa là Zalo đã nhận (`msg_id` là mã của Zalo); `failed` nghĩa là Zalo từ chối, và `error` chứa mã lỗi, thông báo của Zalo. ### Gửi cho nhiều khách {#send-to-many-customers} `POST /chat/zns/bulk` nhận `account_id`, `template_id`, `mapping` (gán mỗi tham số của mẫu với `customer.name`, `customer.phone`, `customer.email`, `field:` hoặc `text:`), `filters` không bắt buộc (`q`, `stage`, `owner_id`, `label`, `channel`) và `limit` (1 đến 5.000, mặc định 1.000). Response là `202 Accepted`: ```json { "object": "chat_zns_bulk", "queued": 418, "skipped": 12 } ``` `skipped` đếm những khách không có số điện thoại hoặc có số không phải số di động Việt Nam hợp lệ. ### Sự kiện webhook {#webhook-events} Mỗi lần gửi phát sự kiện `chat.zns.sent` hoặc `chat.zns.failed` kèm đối tượng tin như trên. Xem [Webhook](https://hatang.dev/vi/docs/reference/webhooks.md). ## Giới hạn và lưu ý {#limits-and-quirks} - Số điện thoại được chuẩn hoá về dạng `84…`. Số nào sau khi chuẩn hoá không có 11 hoặc 12 chữ số bị từ chối với `422 validation_error`. - Chỉ gửi được mẫu đã duyệt. Mẫu không ở trạng thái bật sẽ không có trong danh sách. - Hạn mức hằng ngày thuộc về OA và do Zalo đặt. Trang Zalo ZNS và `GET /chat/zns/templates` cho biết hạn mức còn lại. - `account_id` phải là một Zalo OA. Tài khoản Zalo cá nhân không gửi được ZNS. ## Xử lý sự cố {#troubleshooting} | Bạn thấy | Nguyên nhân | Cách xử lý | |---|---|---| | "Không tải được mẫu: … ZNS cần OA ở gói trả phí và có mẫu đã duyệt." | OA đang ở gói miễn phí, hoặc kết nối đã hết hạn | Nâng gói OA, hoặc kết nối lại ở **Hỗ trợ → Kênh** | | "Chưa có mẫu nào được duyệt" | Chưa có mẫu nào được Zalo duyệt | Tạo mẫu tại oa.zalo.me → ZNS → Mẫu và chờ duyệt | | "Hãy kết nối Zalo Official Account … trước" | Chưa có Zalo OA nào đang hoạt động | Kết nối OA, hoặc kết nối lại nếu OA cần cấp quyền lại | | Tin có trạng thái `failed` | Zalo từ chối: sai tham số, hết hạn mức, hoặc số điện thoại không dùng Zalo | Đọc `error`, sửa dữ liệu mẫu hoặc chờ hạn mức được đặt lại | | Tin gửi hàng loạt cứ nằm trong hàng đợi | Module Khách hàng đang tắt | Bật lại module | ## Bước tiếp theo {#next-steps} - [Zalo OA](https://hatang.dev/vi/help/channels/zalo-oa): kết nối Official Account dùng để gửi ZNS. - [Tự động hoá](https://hatang.dev/vi/docs/guides/support/automation.md): tự gửi ZNS khi đơn được thanh toán. - [Webhook](https://hatang.dev/vi/docs/reference/webhooks.md): theo dõi `chat.zns.sent` và `chat.zns.failed`. Source: https://hatang.dev/vi/docs/guides/support/zns --- # Mã lỗi Mọi lỗi của REST API dùng cùng một khung. `name` ổn định, dùng để xử lý trong code; `message` là câu tiếng Anh cho người đọc và có thể thay đổi. ```json { "statusCode": 422, "name": "validation_error", "message": "to: Invalid email address" } ``` Cách xử lý chung: `4xx` là lỗi của yêu cầu, sửa rồi gửi lại; `429` và `5xx` là tạm thời, gửi lại có giãn cách (xem [Giới hạn](https://hatang.dev/vi/docs/reference/limits.md)). Xem thêm [hướng dẫn xử lý lỗi](https://hatang.dev/vi/docs/guides/getting-started/errors.md). ## REST API | Mã (`name`) | HTTP | Ý nghĩa và cách xử lý | |---|---|---| | `validation_error` | 422 | Body hoặc tham số không hợp lệ; `message` nêu trường bị sai. | | `missing_required_field` | 422 | Thiếu một trường bắt buộc. | | `missing_api_key` | 401 | Thiếu header `Authorization: Bearer`. | | `invalid_api_key` | 403 | API key không tồn tại hoặc đã bị thu hồi. | | `restricted_api_key` | 401 | Key không có scope endpoint này cần, hoặc chỉ được dùng từ origin khác. | | `invalid_from_address` | 403 | Tên miền của `from` chưa được xác minh cho doanh nghiệp (hoặc key không được dùng tên miền này). | | `not_found` | 404 | Tài nguyên không tồn tại hoặc thuộc doanh nghiệp khác. | | `method_not_allowed` | 405 | Đường dẫn này không hỗ trợ phương thức HTTP đó. | | `invalid_idempotent_request` | 400 | `Idempotency-Key` đã được dùng với một body khác. | | `concurrent_idempotent_requests` | 409 | Một yêu cầu khác cùng `Idempotency-Key` đang chạy; thử lại sau giây lát. | | `rate_limit_exceeded` | 429 | Gửi quá nhiều yêu cầu; chờ số giây trong `retry-after`. | | `daily_quota_exceeded` | 429 | Đã dùng hết hạn mức gửi trong ngày của gói. | | `invalid_attachment` | 422 | Tệp đính kèm sai định dạng, không tải được (`path`) hoặc quá lớn. | | `application_error` | 500 | Lỗi phía Hạ Tầng; thử lại có giãn cách. | | `internal_server_error` | 500 | Lỗi không mong muốn; thử lại có giãn cách, liên hệ hỗ trợ nếu còn lặp lại. | | `origin_not_allowed` | 403 | Origin của trình duyệt không thuộc tên miền hoặc danh sách origin được phép của site. | | `invalid_visitor_token` | 401 | Visitor token thiếu, hết hạn hoặc thuộc site khác; gọi bootstrap lại. | | `invalid_agent_token` | 401 | Agent token thiếu hoặc hết hạn; đăng nhập lại. | | `site_disabled` | 403 | Site chat đang tắt. | | `invalid_app_key` | 401 | Khoá ứng dụng (`hak_…`) không tồn tại hoặc thuộc site khác. | | `app_key_revoked` | 403 | Khoá ứng dụng đã bị thu hồi; phát hành bản mới dùng khoá mới. | | `message_too_long` | 422 | Tin nhắn dài hơn mức cho phép (xem Giới hạn). | | `unsupported_file_type` | 422 | Không tải lên được loại tệp này. | | `file_too_large` | 413 | Tệp lớn hơn mức cho phép (xem Giới hạn). | | `conversation_closed` | 409 | Hội thoại đã đóng, không nhận tin mới. | | `product_not_enabled` | 403 | Doanh nghiệp chưa có sản phẩm này. | | `product_disabled` | 403 | Doanh nghiệp đã tắt module này (Cài đặt → Module). | | `ai_budget_exhausted` | 429 | | | `ai_not_configured` | 503 | | | `ai_unavailable` | 503 | | | `instructions_full` | 409 | | | `forbidden` | 403 | Bạn không có quyền làm việc này. | | `last_owner` | 409 | Doanh nghiệp phải còn ít nhất một chủ sở hữu. | | `domain_taken` | 409 | Tên miền đã được doanh nghiệp khác dùng. | | `host_missing` | 422 | Thiếu tên host. | | `host_mismatch` | 422 | Host không khớp với tên miền. | | `slug_taken` | 409 | Slug đã được dùng. | | `slug_reserved` | 422 | Slug này được giữ lại. | | `environment_mismatch` | 403 | Tài nguyên thuộc môi trường còn lại (development và production). | | `no_bank_connection` | 422 | Cần liên kết tài khoản ngân hàng trước khi tạo thanh toán. | | `code_taken` | 409 | Mã đơn hoặc mã thanh toán đã được dùng. | | `bank_link_failed` | 422 | Ngân hàng từ chối liên kết; kiểm tra thông tin tài khoản. | | `invalid_otp` | 422 | Mã xác nhận không đúng. | | `otp_expired` | 410 | Mã xác nhận đã hết hạn; yêu cầu mã mới. | | `invalid_state` | 409 | Trạng thái hiện tại của tài nguyên không cho phép thao tác này. | | `provider_unavailable` | 503 | Ngân hàng hoặc nhà cung cấp hoá đơn không phản hồi; thử lại sau. | | `invoice_provider_error` | 422 | Nhà cung cấp hoá đơn từ chối yêu cầu; `message` có lý do. | ## Giao thức realtime Khung `error` trên websocket mang một trong các mã sau trong `d.code`; mã trùng tên với REST API có cùng ý nghĩa. | Mã | Ý nghĩa | |---|---| | `validation_error` | Body hoặc tham số không hợp lệ; `message` nêu trường bị sai. | | `rate_limit_exceeded` | Gửi quá nhiều yêu cầu; chờ số giây trong `retry-after`. | | `not_found` | Tài nguyên không tồn tại hoặc thuộc doanh nghiệp khác. | | `unauthenticated` | Có khung gửi tới trước khi `auth` thành công. | | `invalid_visitor_token` | Visitor token thiếu, hết hạn hoặc thuộc site khác; gọi bootstrap lại. | | `origin_not_allowed` | Origin của trình duyệt không thuộc tên miền hoặc danh sách origin được phép của site. | | `site_disabled` | Site chat đang tắt. | | `invalid_app_key` | Khoá ứng dụng (`hak_…`) không tồn tại hoặc thuộc site khác. | | `app_key_revoked` | Khoá ứng dụng đã bị thu hồi; phát hành bản mới dùng khoá mới. | | `conversation_closed` | Hội thoại đã đóng, không nhận tin mới. | | `file_too_large` | Tệp lớn hơn mức cho phép (xem Giới hạn). | | `unsupported_file_type` | Không tải lên được loại tệp này. | | `bad_frame` | Khung không phải JSON hợp lệ hoặc không khớp schema giao thức. | | `error` | Lỗi khác cho một yêu cầu. | ## SMTP relay | Mã trả lời | Ý nghĩa | |---|---| | `535 5.7.8` | sai thông tin đăng nhập (API key sai) | | `530 5.7.0` | cần đăng nhập | | `550 5.7.1` | không được phép (ví dụ tên miền `From` chưa xác minh) | | `553 5.6.0` | thư không hợp lệ (lỗi kiểm tra dữ liệu) | | `452 5.5.3` | quá 50 người nhận | | `421 4.7.0` | đăng nhập sai quá nhiều lần từ IP này; thử lại sau | | `451 4.3.0` | lỗi tạm thời; gửi lại | Source: https://hatang.dev/vi/docs/reference/errors --- # Giới hạn ## Giới hạn tốc độ Giới hạn tính theo doanh nghiệp và theo từng API. Mọi phản hồi có `ratelimit-limit`, `ratelimit-remaining` và `ratelimit-reset`; lỗi `429` có thêm `retry-after` (giây). Gửi lại sau khoảng đó, giãn cách tăng dần nếu còn lặp lại. | API | Đường dẫn | Giới hạn | |---|---|---| | Email và nền tảng | mọi đường dẫn còn lại (`/emails`, `/domains`, `/webhooks`…) | 10 yêu cầu/giây | | Hỗ trợ (chat) | `/chat/*` | 50 yêu cầu/giây | | Thanh toán | `/payments*`, `/einvoices*` | 20 yêu cầu/giây | | API nhân viên (ứng dụng di động và Mac) | `chat.hatang.dev/agent/*` | 300 lượt đọc và 60 lượt ghi mỗi phút cho mỗi người dùng | Việc gửi email còn tính vào hạn mức email mỗi ngày của gói (`daily_quota_exceeded`). ## Email | Mục | Giới hạn | |---|---| | Người nhận mỗi email (`to` + `cc` + `bcc`) | 50 | | Email mỗi lần gửi batch | 1–100 | | Tệp đính kèm mỗi email | 50 | | Toàn bộ email (nội dung + tệp) | 40 MB | | Tiêu đề | 998 ký tự | | Tags | 50 mỗi email; tên và giá trị tối đa 256 ký tự trong `A–Z a–z 0–9 _ -` | | `Idempotency-Key` | 256 ký tự | | Thư qua SMTP relay | 40 MB, 50 người nhận | | Email nhận về | 25 MB, giữ tối đa 50 tệp đính kèm | ## Hỗ trợ (chat) | Mục | Giới hạn | |---|---| | Một khung websocket | 16 KB | | Tin của khách | 4000 ký tự | | Tin của nhân viên | 10000 ký tự | | Tệp mỗi tin | 5 | | Một tệp | 10 MB | | Dữ liệu tuỳ chỉnh của khách (`identify.data`, `setData`) | 8 KB | | Tin khách được gửi | 5 tin / 10 giây, 60 tin / phút | Các mốc thời gian của giao thức (hạn auth, ping, kết nối lại) có trong [tài liệu giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md). Source: https://hatang.dev/vi/docs/reference/limits --- # Tài liệu tham chiếu Phần tham chiếu mô tả mọi endpoint, sự kiện và khung dữ liệu đúng như máy chủ đang chạy. Nội dung được sinh từ mã nguồn và các bản OpenAPI công khai, nên thay đổi theo API. Nếu cần hướng dẫn từng bước, hãy bắt đầu từ phần [Hướng dẫn](https://hatang.dev/vi/docs/guides/getting-started/introduction.md). | Tham chiếu | Nội dung | Base URL | |---|---|---| | [REST API](https://hatang.dev/vi/docs/reference/api.md) | Email, Hỗ trợ (chat), Thanh toán, API key và webhook: mọi endpoint kèm tham số, schema yêu cầu và phản hồi, code mẫu và bảng "Try it" để gọi thử | `https://api.hatang.dev` | | [Chat gateway API](https://hatang.dev/vi/docs/reference/chat-gateway.md) | Những gì widget website, SDK di động và ứng dụng cho nhân viên gọi: bootstrap, hội thoại, tải tệp, thiết bị, API nhân viên | `https://chat.hatang.dev` | | [Sự kiện webhook](https://hatang.dev/vi/docs/reference/webhooks.md) | Mọi sự kiện kèm payload | endpoint của bạn | | [Giao thức realtime](https://hatang.dev/vi/docs/reference/realtime-protocol.md) | Websocket của khách: mọi khung, trường và giới hạn | `wss://chat.hatang.dev/ws` | | [Mã lỗi](https://hatang.dev/vi/docs/reference/errors.md) | Mọi mã lỗi của API, giao thức realtime và SMTP relay | | | [Giới hạn](https://hatang.dev/vi/docs/reference/limits.md) | Giới hạn tốc độ và kích thước | | Phần tham chiếu sinh tự động được viết bằng tiếng Anh, giống tên trường và mã lỗi trong API. ## Tham chiếu SDK Sinh từ chú thích trong mã nguồn của từng SDK, mỗi SDK một trang (chỉ có tiếng Anh): | SDK | Tham chiếu | |---|---| | Node (`@hatang/sdk`) | [TypeDoc](https://hatang.dev/docs/reference/js/index.html) | | Flutter (`hatang_chat`) | [dartdoc](https://hatang.dev/docs/reference/flutter/index.html) | | iOS (`HatangChat`, `HatangChatCore`) | [DocC](https://hatang.dev/docs/reference/ios/documentation) | | Android (`dev.hatang:chat`, `dev.hatang:chat-core`) | [Dokka](https://hatang.dev/docs/reference/android/index.html) | ## Quy ước chung - **Xác thực.** Lệnh gọi REST gửi `Authorization: Bearer ht_...`. Xem [Xác thực](https://hatang.dev/vi/docs/guides/getting-started/authentication.md) để biết về scope và key thử nghiệm. - **Định dạng.** JSON cả chiều gửi và nhận, UTF-8. Thời gian theo ISO 8601, múi giờ UTC; số tiền (đồng) là số nguyên. - **Lỗi.** Mọi lỗi có dạng `{ "statusCode", "name", "message" }`; xử lý theo `name`. - **Phân trang.** Endpoint dạng danh sách nhận `limit` và `after`, trả về `has_more` và `next_cursor`. Xem [Phân trang](https://hatang.dev/vi/docs/guides/getting-started/pagination.md). - **Idempotency.** `POST /emails`, `POST /payments` và gửi tin nhắn chat nhận header `Idempotency-Key`. Xem [Idempotency](https://hatang.dev/vi/docs/guides/getting-started/idempotency.md). ## Bản đặc tả cho máy đọc - REST API: [`openapi.json`](https://api.hatang.dev/openapi.json) (OpenAPI 3.1, có cả phần `webhooks`) - Chat gateway: [`openapi.json`](https://chat.hatang.dev/openapi.json) - Giao thức realtime: JSON Schema cho [khung của khách](https://hatang.dev/docs/schema/chat-protocol-v1/visitor-frames.json), [khung của server](https://hatang.dev/docs/schema/chat-protocol-v1/server-frames.json), [HTTP](https://hatang.dev/docs/schema/chat-protocol-v1/http.json) và [hằng số giao thức](https://hatang.dev/docs/schema/chat-protocol-v1/protocol.json) - Toàn bộ tài liệu cho AI agent và công cụ LLM: [`llms.txt`](https://hatang.dev/docs/llms.txt) (mục lục), [`llms-full.txt`](https://hatang.dev/docs/llms-full.txt) (mọi trang trong một file), và bản Markdown của từng trang ở chính địa chỉ của trang thêm `.md`, ví dụ [`/vi/docs/reference/overview.md`](https://hatang.dev/docs/reference/overview.md) Dùng được với mọi công cụ OpenAPI (Postman, Insomnia, trình sinh code) hoặc nhập vào bộ kiểm thử của bạn. Source: https://hatang.dev/vi/docs/reference/overview --- # 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=`, thêm `&app=&install=` 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/`) 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` | 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` | 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 --- # Sự kiện webhook Hạ Tầng gửi một `POST` cho mỗi sự kiện tới từng webhook đã đăng ký sự kiện đó. Body luôn cùng một khung; `data` tuỳ theo sự kiện. Mỗi sự kiện dưới đây dẫn tới schema đầy đủ của payload và ví dụ. ## Khung dữ liệu ```json { "type": "email.delivered", "created_at": "2026-10-23T08:15:00.000Z", "data": { "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "from": "Shop ", "to": [ "an@example.com" ], "subject": "Your order", "created_at": "2026-10-23T08:14:58.000Z" } } ``` ## Header | Header | Ý nghĩa | |---|---| | `svix-id` / `webhook-id` | Id duy nhất của lần gửi. Gửi lại và phát lại dùng cùng id: hãy chống trùng theo id này. | | `svix-timestamp` / `webhook-timestamp` | Thời điểm ký (giây Unix). Từ chối nếu lệch quá 5 phút. | | `svix-signature` / `webhook-signature` | Chữ ký dạng `v1,`, cách nhau bằng dấu cách (có hai chữ ký trong lúc đổi secret). | Trả về bất kỳ mã 2xx nào trong 15 giây. Mã khác hoặc quá thời gian sẽ được gửi lại sau 5 giây, 5 phút, 30 phút, 2 giờ, 5 giờ, 10 giờ và 10 giờ. Bạn có thể phát lại bất kỳ lần gửi nào trong dashboard hoặc bằng `POST /webhooks/deliveries/{id}/replay`. Cách tạo webhook, mã xác minh chữ ký và cách kiểm thử có trong [hướng dẫn Webhook](https://hatang.dev/vi/docs/guides/getting-started/webhooks.md). ## Sự kiện Email | Sự kiện | Khi nào được gửi | |---|---| | [`email.sent`](https://hatang.dev/vi/docs/reference/api/webhook-email-sent) | Email đã được hạ tầng gửi chấp nhận. | | [`email.delivered`](https://hatang.dev/vi/docs/reference/api/webhook-email-delivered) | Máy chủ thư của người nhận đã nhận email. | | [`email.delivery_delayed`](https://hatang.dev/vi/docs/reference/api/webhook-email-delivery-delayed) | Đang thử gửi lại (ví dụ máy chủ người nhận tạm từ chối). | | [`email.bounced`](https://hatang.dev/vi/docs/reference/api/webhook-email-bounced) | Email bị trả về. `data.bounce.type` là `Permanent` (địa chỉ bị chặn) hoặc `Transient`. | | [`email.complained`](https://hatang.dev/vi/docs/reference/api/webhook-email-complained) | Người nhận đánh dấu spam. Địa chỉ bị chặn. | | [`email.opened`](https://hatang.dev/vi/docs/reference/api/webhook-email-opened) | Người nhận mở email (khi bật theo dõi mở). Mỗi lần mở một sự kiện. | | [`email.clicked`](https://hatang.dev/vi/docs/reference/api/webhook-email-clicked) | Người nhận bấm một liên kết được theo dõi. `data.click.link` là URL gốc. | | [`email.failed`](https://hatang.dev/vi/docs/reference/api/webhook-email-failed) | Không gửi được email. `data.failed.reason` cho biết lý do. | | [`email.received`](https://hatang.dev/vi/docs/reference/api/webhook-email-received) | Có email tới một tên miền nhận thư của bạn. Lấy nội dung và tệp đính kèm bằng API Receiving. | | [`contact.created`](https://hatang.dev/vi/docs/reference/api/webhook-contact-created) | Có liên hệ mới (API, dashboard, nhập file hoặc đồng bộ CRM). | | [`contact.updated`](https://hatang.dev/vi/docs/reference/api/webhook-contact-updated) | Tên, thuộc tính hoặc trạng thái đăng ký của liên hệ thay đổi. | | [`contact.deleted`](https://hatang.dev/vi/docs/reference/api/webhook-contact-deleted) | Liên hệ bị xoá. | | [`domain.created`](https://hatang.dev/vi/docs/reference/api/webhook-domain-created) | Đã thêm tên miền gửi. | | [`domain.updated`](https://hatang.dev/vi/docs/reference/api/webhook-domain-updated) | Tên miền thay đổi, kể cả trạng thái xác minh. | | [`domain.deleted`](https://hatang.dev/vi/docs/reference/api/webhook-domain-deleted) | Tên miền bị xoá. | ## Sự kiện Hỗ trợ | Sự kiện | Khi nào được gửi | |---|---| | [`chat.conversation.created`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-created) | Có hội thoại mới trên bất kỳ kênh nào. | | [`chat.conversation.assigned`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-assigned) | Hội thoại được giao (hoặc bỏ giao). | | [`chat.conversation.resolved`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-resolved) | Hội thoại được đánh dấu đã xử lý. | | [`chat.conversation.closed`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-closed) | Hội thoại bị đóng. | | [`chat.conversation.reopened`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-reopened) | Hội thoại đã xử lý hoặc đã đóng được mở lại. | | [`chat.conversation.handoff`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-handoff) | Trợ lý AI chuyển hội thoại cho nhân viên. | | [`chat.conversation.tagged`](https://hatang.dev/vi/docs/reference/api/webhook-chat-conversation-tagged) | Hội thoại được gắn thêm tag; mỗi tag một sự kiện. | | [`chat.message.created`](https://hatang.dev/vi/docs/reference/api/webhook-chat-message-created) | Có tin nhắn công khai từ khách, nhân viên, trợ lý AI hoặc kịch bản. Ghi chú nội bộ không gửi sự kiện này. | | [`chat.visitor.identified`](https://hatang.dev/vi/docs/reference/api/webhook-chat-visitor-identified) | Khách được nhận diện (`identify` từ widget hoặc SDK). | | [`chat.order.created`](https://hatang.dev/vi/docs/reference/api/webhook-chat-order-created) | Đơn hàng được tạo từ hộp thư hoặc API. | | [`chat.order.updated`](https://hatang.dev/vi/docs/reference/api/webhook-chat-order-updated) | Đơn hàng thay đổi (trạng thái, sản phẩm, vận chuyển). | | [`chat.order.paid`](https://hatang.dev/vi/docs/reference/api/webhook-chat-order-paid) | Đơn hàng đã được thanh toán. | | [`chat.flow.webhook`](https://hatang.dev/vi/docs/reference/api/webhook-chat-flow-webhook) | Kịch bản hoặc quy trình chạy tới bước Webhook. | | [`chat.campaign.lead`](https://hatang.dev/vi/docs/reference/api/webhook-chat-campaign-lead) | Khách gửi form thu thập thông tin. | | [`chat.ticket.created`](https://hatang.dev/vi/docs/reference/api/webhook-chat-ticket-created) | Phiếu hỗ trợ được tạo. | | [`chat.ticket.updated`](https://hatang.dev/vi/docs/reference/api/webhook-chat-ticket-updated) | Phiếu hỗ trợ thay đổi. | | [`chat.ticket.resolved`](https://hatang.dev/vi/docs/reference/api/webhook-chat-ticket-resolved) | Phiếu hỗ trợ được xử lý xong. | | [`chat.ticket.breached`](https://hatang.dev/vi/docs/reference/api/webhook-chat-ticket-breached) | Phiếu trễ một mốc SLA. `clock` cho biết mốc nào. | | [`chat.rating.created`](https://hatang.dev/vi/docs/reference/api/webhook-chat-rating-created) | Khách đánh giá hội thoại (CSAT). | | [`chat.zns.sent`](https://hatang.dev/vi/docs/reference/api/webhook-chat-zns-sent) | Tin Zalo ZNS đã gửi tới Zalo. | | [`chat.zns.failed`](https://hatang.dev/vi/docs/reference/api/webhook-chat-zns-failed) | Tin Zalo ZNS gửi lỗi. `error` có lý do. | ## Sự kiện Thanh toán | Sự kiện | Khi nào được gửi | |---|---| | [`payment.created`](https://hatang.dev/vi/docs/reference/api/webhook-payment-created) | Yêu cầu thanh toán được tạo. | | [`payment.succeeded`](https://hatang.dev/vi/docs/reference/api/webhook-payment-succeeded) | Đã nhận đủ tiền. | | [`payment.partially_paid`](https://hatang.dev/vi/docs/reference/api/webhook-payment-partially-paid) | Nhận được ít hơn số tiền yêu cầu. | | [`payment.overpaid`](https://hatang.dev/vi/docs/reference/api/webhook-payment-overpaid) | Nhận được nhiều hơn số tiền yêu cầu. | | [`payment.expired`](https://hatang.dev/vi/docs/reference/api/webhook-payment-expired) | Thanh toán hết hạn trước khi được trả. | | [`payment.canceled`](https://hatang.dev/vi/docs/reference/api/webhook-payment-canceled) | Thanh toán bị huỷ. | | [`transaction.received`](https://hatang.dev/vi/docs/reference/api/webhook-transaction-received) | Có tiền vào một tài khoản ngân hàng đã liên kết (khớp hoặc chưa khớp). | | [`bank_connection.activated`](https://hatang.dev/vi/docs/reference/api/webhook-bank-connection-activated) | Tài khoản ngân hàng đã liên kết và xác nhận. | | [`bank_connection.revoked`](https://hatang.dev/vi/docs/reference/api/webhook-bank-connection-revoked) | Liên kết ngân hàng bị gỡ. | | [`bank_connection.needs_reauth`](https://hatang.dev/vi/docs/reference/api/webhook-bank-connection-needs-reauth) | Ngân hàng cần chủ tài khoản xác nhận lại liên kết. | | [`invoice.draft_created`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-draft-created) | Hoá đơn nháp được tạo. | | [`invoice.draft_updated`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-draft-updated) | Hoá đơn nháp thay đổi. | | [`invoice.draft_deleted`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-draft-deleted) | Hoá đơn nháp bị xoá. | | [`invoice.issued`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-issued) | Nhà cung cấp đã phát hành hoá đơn điện tử. | | [`invoice.tax_accepted`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-tax-accepted) | Cơ quan thuế chấp nhận hoá đơn. | | [`invoice.tax_rejected`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-tax-rejected) | Cơ quan thuế từ chối hoá đơn. | | [`invoice.canceled`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-canceled) | Hoá đơn bị huỷ. | | [`invoice.replaced`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-replaced) | Hoá đơn được thay thế bằng hoá đơn mới. | | [`invoice.adjusted`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-adjusted) | Đã phát hành hoá đơn điều chỉnh. | | [`invoice.failed`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-failed) | Phát hành hoá đơn điện tử thất bại. | | [`invoice.conflict`](https://hatang.dev/vi/docs/reference/api/webhook-invoice-conflict) | Hoá đơn bị sửa phía nhà cung cấp và xung đột với bản trên Hạ Tầng. | Source: https://hatang.dev/vi/docs/reference/webhooks --- # Các endpoint REST API The Ha Tang REST API: email, support (chat) and payments. Authenticate with `Authorization: Bearer ht_...`. Every error uses the `{statusCode, name, message}` envelope. Send `Idempotency-Key` on `POST /emails` to make retries safe. Base URL: https://api.hatang.dev ## Emails - `POST /emails`: Send an email - `GET /emails`: List emails - `POST /emails/batch`: Send up to 100 emails in one call - `GET /emails/{id}`: Retrieve an email - `PATCH /emails/{id}`: Reschedule a scheduled email - `GET /emails/{id}/events`: Delivery events and per-recipient status - `POST /emails/{id}/cancel`: Cancel a scheduled email ## Receiving - `GET /emails/receiving`: List received emails (domains with receiving on; needs email:read) - `GET /emails/receiving/{id}`: Retrieve a received email: text, HTML, headers and attachment metadata - `GET /emails/receiving/{id}/attachments`: List a received email's attachments with download URLs (valid for an hour) - `GET /emails/receiving/{id}/attachments/{attachment_id}`: Retrieve one attachment with a download URL - `GET /files/received/{attachment_id}`: Download a received attachment (the signed download_url; no API key) ## Domains - `POST /domains`: Add a domain - `GET /domains`: List domains - `GET /domains/{id}`: Retrieve a domain - `PATCH /domains/{id}`: Update tracking or TLS settings - `DELETE /domains/{id}`: Delete a domain - `POST /domains/{id}/verify`: Check the DNS records now and keep re-checking - `GET /domains/{id}/dns-provider`: Detect the DNS host (Cloudflare or other) and the zone holding the records - `GET /domains/{id}/domain-connect`: Is one-click DNS setup (Domain Connect) available at this domain's DNS host? - `POST /domains/{id}/domain-connect`: Start Domain Connect: get the signed URL to send the user to their DNS host ## API Keys - `POST /api-keys`: Create an API key - `GET /api-keys`: List API keys - `DELETE /api-keys/{id}`: Revoke an API key ## Webhooks - `POST /webhooks`: Create a webhook endpoint - `GET /webhooks`: List webhooks - `GET /webhooks/{id}`: Retrieve a webhook - `PATCH /webhooks/{id}`: Update a webhook - `DELETE /webhooks/{id}`: Delete a webhook - `POST /webhooks/{id}/rotate`: Rotate the signing secret (old secret stays valid 24h) - `GET /webhooks/{id}/deliveries`: Recent delivery attempts - `POST /webhooks/deliveries/{deliveryId}/replay`: Re-send a delivery with the same svix-id ## Audiences - `POST /audiences`: Create an audience - `GET /audiences`: List audiences - `GET /audiences/{id}`: Retrieve an audience - `DELETE /audiences/{id}`: Delete an audience and its contacts ## Contacts - `POST /audiences/{id}/contacts`: Create or update a contact - `GET /audiences/{id}/contacts`: List contacts - `GET /audiences/{id}/contacts/{contact}`: Retrieve a contact by id or email - `PATCH /audiences/{id}/contacts/{contact}`: Update a contact - `DELETE /audiences/{id}/contacts/{contact}`: Delete a contact - `POST /contacts`: Create a contact (or update the one with that email) - `GET /contacts`: List contacts - `GET /contacts/{id}`: Retrieve a contact by id or email - `PATCH /contacts/{id}`: Update a contact - `DELETE /contacts/{id}`: Delete a contact - `GET /contacts/{id}/segments`: Segments the contact belongs to - `POST /contacts/{id}/segments/{segment}`: Add the contact to a segment - `DELETE /contacts/{id}/segments/{segment}`: Remove the contact from a segment - `GET /contacts/{id}/topics`: The contact's subscription to every topic - `PATCH /contacts/{id}/topics`: Set the contact's topic subscriptions ## Broadcasts - `POST /broadcasts`: Create a broadcast draft - `GET /broadcasts`: List broadcasts - `GET /broadcasts/{id}`: Retrieve a broadcast - `PATCH /broadcasts/{id}`: Update a draft or scheduled broadcast - `DELETE /broadcasts/{id}`: Delete a broadcast - `POST /broadcasts/{id}/send`: Send now or schedule - `POST /broadcasts/{id}/cancel`: Cancel a scheduled broadcast ## Templates - `POST /templates`: Create a template (draft) - `GET /templates`: List templates - `GET /templates/{id}`: Retrieve a template by id or alias - `PATCH /templates/{id}`: Update the draft of a template - `DELETE /templates/{id}`: Delete a template - `POST /templates/{id}/publish`: Publish the draft; sends use the published version - `POST /templates/{id}/duplicate`: Copy a template as a new draft ## Metrics - `GET /emails/metrics`: Email metrics per period (and domain): counts and rates ## Logs - `GET /logs`: API request logs, newest first - `GET /logs/{id}`: One request log with its request and response bodies (secrets redacted) ## Usage - `GET /usage`: What the business used against its limits ## Segments - `POST /segments`: Create a segment - `GET /segments`: List segments - `GET /segments/{id}`: Retrieve a segment with its contact count - `PATCH /segments/{id}`: Rename a segment - `DELETE /segments/{id}`: Delete a segment (its contacts stay) - `GET /segments/{id}/contacts`: Contacts in a segment ## Topics - `POST /topics`: Create a topic - `GET /topics`: List topics - `GET /topics/{id}`: Retrieve a topic - `PATCH /topics/{id}`: Update a topic's name, description or visibility - `DELETE /topics/{id}`: Delete a topic ## Contact properties - `POST /contact-properties`: Declare a contact property - `GET /contact-properties`: List contact properties - `PATCH /contact-properties/{id}`: Change a property's fallback value - `DELETE /contact-properties/{id}`: Delete a property and its values ## Events - `POST /events`: Define a custom event (name and payload schema) - `GET /events`: List event definitions - `POST /events/send`: Send an event for a contact: starts automations triggered by it and resumes runs waiting for it - `GET /events/{id}`: Retrieve an event definition by id or name - `PATCH /events/{id}`: Change an event's payload schema - `DELETE /events/{id}`: Delete an event definition ## Automations - `POST /automations`: Create an automation (steps and connections) - `GET /automations`: List automations - `GET /automations/{id}`: Retrieve an automation - `PATCH /automations/{id}`: Update an automation; steps can only change while it is disabled - `DELETE /automations/{id}`: Delete an automation and its runs - `POST /automations/{id}/stop`: Disable the automation and cancel its runs in flight - `POST /automations/{id}/duplicate`: Copy an automation as a new disabled one - `GET /automations/{id}/runs`: Runs of an automation - `GET /automations/{id}/runs/{run_id}`: One run with its steps ## Chat sites - `POST /chat/sites`: Add an extra chat site on a subdomain (the domain's own widget is created automatically) - `GET /chat/sites`: List chat sites - `GET /chat/sites/{id}`: Retrieve a site - `PATCH /chat/sites/{id}`: Update a site's settings, origins or status - `DELETE /chat/sites/{id}`: Delete a site and everything under it - `POST /chat/sites/{id}/identity-secret`: Rotate the secret used to sign identify() calls - `GET /chat/profile-links`: The business's profile links (URL templates agents open for the person in a conversation) - `PUT /chat/profile-links`: Replace the profile links (max 10; https, known {{variables}}, none in the host) - `GET /chat/sites/{id}/kb/sources`: List the business's knowledge sources - `POST /chat/sites/{id}/kb/sources`: Add a knowledge source (FAQ, text or website crawl); indexing runs in the background - `GET /chat/sites/{id}/kb/sources/{sourceId}`: Get a knowledge source - `PATCH /chat/sites/{id}/kb/sources/{sourceId}`: Update a knowledge source and re-index it - `DELETE /chat/sites/{id}/kb/sources/{sourceId}`: Delete a knowledge source and its chunks - `POST /chat/sites/{id}/kb/sources/{sourceId}/reindex`: Re-index a knowledge source - `POST /chat/sites/{id}/kb/files`: Upload a PDF or DOCX as a knowledge source (multipart: file, optional title). Text is extracted on upload, up to 20 MB; scanned PDFs need OCR first. - `GET /chat/sites/{id}/kb/search`: Search the knowledge base the assistant answers from (keyword and semantic matches, fused by rank) - `GET /chat/subscriptions`: List subscriptions (web, App Store, Google Play); filter by external_user_id, platform, status (comma-separated) - `PUT /chat/subscriptions/{platform}/{original_id}`: Create or update a subscription from your backend (App Store and Google Play ones also arrive from the stores once connected) - `POST /chat/subscriptions/{id}/refresh`: Ask the App Store / Google Play about one subscription again (needs the app's App Store Server API key or Play service account) - `POST /chat/subscriptions/import`: Import existing subscribers of an app: Apple transaction ids (any of the customer's; their one-time purchases come too) or Google Play purchase tokens, up to 500 per call - `GET /chat/sites/{id}/purchase-token`: The app account token of one of your users: pass it as Apple appAccountToken / Google obfuscatedExternalAccountId when buying ## Conversations - `GET /chat/conversations`: List conversations - `GET /chat/conversations/{id}`: Retrieve a conversation - `PATCH /chat/conversations/{id}`: Assign, change status or replace tags - `GET /chat/conversations/{id}/messages`: Timeline (messages and events) of a conversation - `POST /chat/conversations/{id}/messages`: Send a message as your team (Idempotency-Key supported) - `POST /chat/conversations/{id}/products`: Send product cards into a conversation - `POST /chat/conversations/{id}/flows/{flowId}`: Start a flow in a conversation right now - `POST /chat/conversations/{id}/workflows/{workflowId}`: Run a workflow for a conversation now (its trigger conditions are skipped) - `POST /chat/conversations/{id}/workflows/stop`: Stop every running or waiting workflow run in a conversation ## Customers - `GET /chat/customers`: List customers (one person across channels) - `GET /chat/customers/export.csv`: Export the matching customers as CSV - `GET /chat/customers/merge-suggestions`: Pairs of customers that share an e-mail or phone number - `GET /chat/customers/{id}`: Retrieve a customer with their visitors - `PATCH /chat/customers/{id}`: Update contact details, owner, stage, labels or custom fields - `POST /chat/customers/{id}/merge`: Merge another customer into this one - `GET /chat/customers/{id}/conversations`: A customer's conversations across all channels - `GET /chat/customer-fields`: List custom customer fields - `POST /chat/customer-fields`: Create a custom customer field - `PATCH /chat/customer-fields/{id}`: Update a custom customer field - `DELETE /chat/customer-fields/{id}`: Delete a custom customer field - `GET /chat/customers/{id}/subscriptions`: A customer's subscriptions, newest first - `GET /chat/customers/{id}/purchases`: A customer's one-time in-app purchases (consumable, non-consumable, non-renewing), verified with the App Store or Google Play, newest first ## Products - `GET /chat/products`: List catalog products - `POST /chat/products`: Add a product - `GET /chat/products/search`: Relevance search over the catalog (accent-insensitive) - `GET /chat/products/categories`: Distinct product categories - `POST /chat/products/import`: Import products from CSV text, a CSV/Google Sheets link, or a website's schema.org Product JSON-LD - `GET /chat/products/{id}`: Retrieve a product - `PATCH /chat/products/{id}`: Update a product - `DELETE /chat/products/{id}`: Delete a product ## Orders - `GET /chat/orders`: List orders - `POST /chat/orders`: Create an order (optionally from a conversation, posting an order card) - `GET /chat/orders/stats`: Orders per stage and paid revenue - `GET /chat/orders/{id}`: Retrieve an order by id or code - `PATCH /chat/orders/{id}`: Update items, stage, address or note - `POST /chat/orders/{id}/payment-link`: Create a VietQR payment for the order and post it into the conversation ## ZNS - `GET /chat/zns/templates`: Approved ZNS templates of a Zalo OA account (with the daily quota) - `GET /chat/zns/templates/{id}`: A ZNS template with its parameters - `GET /chat/zns`: ZNS messages sent by the team - `POST /chat/zns`: Send one ZNS template message to a phone number (billed by Zalo) - `POST /chat/zns/bulk`: Queue a ZNS template to every customer matching the filters (one message per customer with a phone) ## Reports and ratings - `GET /chat/reports`: Support report for a date range: overview, per-day, per-agent, channels, tags (defaults to the last 30 days) - `GET /chat/ratings`: CSAT ratings in a date range ## Tickets - `GET /chat/tickets`: List tickets - `POST /chat/tickets`: Create a ticket (SLA deadlines follow the team policy) - `GET /chat/tickets/stats`: Open, overdue, unassigned counts and this week's resolution figures - `GET /chat/tickets/{id}`: Retrieve a ticket by id or code (TK000123) - `PATCH /chat/tickets/{id}`: Update a ticket (status, priority, assignee, due date…) - `DELETE /chat/tickets/{id}`: Delete a ticket - `GET /chat/sla`: The team's SLA policy (minutes per priority) - `PUT /chat/sla`: Replace the SLA policy ## Flows - `GET /chat/flows`: List scripted flows - `POST /chat/flows`: Create a scripted flow (trigger + steps) - `GET /chat/flows/templates`: Ready-made flow templates - `GET /chat/flows/{id}`: Retrieve a flow - `PATCH /chat/flows/{id}`: Replace a flow's definition - `DELETE /chat/flows/{id}`: Delete a flow ## Workflows - `GET /chat/workflows`: List workflows (event-driven automation) - `POST /chat/workflows`: Create a workflow: a trigger event, optional conditions and a list of steps - `GET /chat/workflows/templates`: Ready-made workflow templates - `GET /chat/workflows/{id}`: Retrieve a workflow - `PATCH /chat/workflows/{id}`: Replace a workflow's definition - `DELETE /chat/workflows/{id}`: Delete a workflow - `GET /chat/workflows/{id}/runs`: Recent runs of a workflow, with the log of each step ## Campaigns - `GET /chat/campaigns`: List widget campaigns - `POST /chat/campaigns`: Create a campaign (proactive message or lead form) - `GET /chat/campaigns/{id}`: Retrieve a campaign - `PATCH /chat/campaigns/{id}`: Update a campaign - `DELETE /chat/campaigns/{id}`: Delete a campaign ## Visitors - `GET /chat/live`: Visitors currently on your sites - `POST /chat/live/greet`: Start a conversation with a visitor who has not written yet - `GET /chat/visitors/{id}`: Retrieve a visitor - `PATCH /chat/visitors/{id}`: Update a visitor's identity or custom data - `DELETE /chat/visitors/{id}`: Anonymise a visitor (GDPR-style delete) - `GET /chat/visitors/{id}/conversations`: A visitor's conversations ## Knowledge - `GET /chat/knowledge/documents`: List knowledge documents, in id order - `POST /chat/knowledge/documents/batch`: Create or update up to 100 documents - `GET /chat/knowledge/documents/{id}`: Get a knowledge document - `PUT /chat/knowledge/documents/{id}`: Create or update a knowledge document under your own id; indexing runs in the background - `DELETE /chat/knowledge/documents/{id}`: Delete a knowledge document - `POST /chat/knowledge/collections/{name}/sync`: Make a collection hold exactly these documents (up to 1,000): listed ones are written, the rest of the collection deleted - `GET /chat/knowledge/search`: Search the knowledge the assistant answers from, preferring a language ## Canned responses - `GET /chat/canned-responses`: List canned responses - `POST /chat/canned-responses`: Create a canned response - `PATCH /chat/canned-responses/{id}`: Update a canned response - `DELETE /chat/canned-responses/{id}`: Delete a canned response ## Routing rules - `GET /chat/routing-rules`: List distribution rules (evaluated top-down by position) - `POST /chat/routing-rules`: Create a distribution rule - `POST /chat/routing-rules/reorder`: Reorder distribution rules - `PATCH /chat/routing-rules/{id}`: Update a distribution rule - `DELETE /chat/routing-rules/{id}`: Delete a distribution rule ## Payments - `POST /payments`: Create a payment (Idempotency-Key supported) - `GET /payments`: List payments - `GET /payments/{id}`: Retrieve a payment - `GET /payments/{id}/ipn-deliveries`: IPN calls made for this payment (newest first) - `POST /payments/{id}/cancel`: Cancel a pending payment - `POST /payments/{id}/mark-paid`: Record money received outside the bank feed - `GET /payments/qr`: VietQR for any amount and description (webhook-only integrations; nothing is stored) - `POST /payments/simulate/transfer`: Simulate a bank transfer (development only) ## Transactions - `GET /payments/transactions`: List bank transactions - `GET /payments/transactions/{id}`: Retrieve a transaction - `POST /payments/statements`: Import a bank statement (CSV) to recover transactions the bank never pushed - `GET /payments/statements/{id}`: Statement import progress - `POST /payments/transactions/{id}/invoice-exempt`: Mark money in as needing no invoice (left out of invoice reconciliation) - `DELETE /payments/transactions/{id}/invoice-exempt`: Put the transaction back into invoice reconciliation ## Bank connections - `GET /payments/bank-connections`: List linked bank accounts - `POST /payments/bank-connections`: Start linking a bank account; the bank texts a code to the holder - `GET /payments/bank-connections/{id}`: Retrieve a bank connection - `PATCH /payments/bank-connections/{id}`: Change the label or matching settings - `POST /payments/bank-connections/{id}/confirm`: Confirm linking with the code - `POST /payments/bank-connections/{id}/unlink`: Start unlinking; returns a challenge when the bank confirms with a code - `POST /payments/bank-connections/{id}/unlink/confirm`: Confirm unlinking with the code - `POST /payments/bank-connections/{id}/acb/notification`: ACB: register the transaction-notification service (a code follows) - `PUT /payments/bank-connections/{id}/acb/notification`: ACB: change which transactions are notified (a code follows) - `DELETE /payments/bank-connections/{id}/acb/notification`: ACB: cancel the notification service (a code follows) - `GET /payments/bank-connections/{id}/acb/notification`: ACB: what the holder has registered - `POST /payments/bank-connections/{id}/acb/notification/confirm`: ACB: confirm a notification registration, change or cancellation with the code - `GET /payments/bank-connections/{id}/acb/virtual-accounts`: ACB: the virtual accounts under this account - `GET /payments/acb/virtual-accounts`: ACB: the virtual accounts an account already has, before it is linked — pick one in config.acb.virtual_account_number to adopt it instead of opening another - `GET /payments/acb/notifications`: ACB: every account registered for notifications under this partner ## E-invoices - `GET /einvoices/reconciliation`: Money in against invoices: what has one, what is missing one, what needs none, per day - `GET /einvoices/providers`: Providers you can connect, with the fields each needs - `POST /einvoices/connections/check`: Check provider credentials and list the account's templates (nothing is saved) - `GET /einvoices/connections`: List e-invoice provider connections - `POST /einvoices/connections`: Connect an e-invoice provider account (credentials and template are checked) - `GET /einvoices/connections/{id}`: Retrieve a provider connection - `DELETE /einvoices/connections/{id}`: Unlink a provider (its invoices are kept; the next provider becomes the default) - `PATCH /einvoices/connections/{id}`: Change default mode, template, credentials or make it the default - `POST /einvoices/connections/{id}/default`: Make this the default provider - `GET /einvoices/connections/{id}/templates`: Templates this provider account may issue on - `POST /einvoices/connections/{id}/sync`: Check every draft and unsettled invoice of this connection at the provider now - `POST /einvoices`: Create an invoice: the draft is created first, then (mode publish) sent for approval - `GET /einvoices`: List invoices - `GET /einvoices/{id}`: Retrieve an invoice - `PATCH /einvoices/{id}`: Edit a draft - `DELETE /einvoices/{id}`: Delete a draft - `POST /einvoices/{id}/publish`: Send a draft for approval (sign and issue it) - `POST /einvoices/{id}/retry`: Run the failed step again - `POST /einvoices/{id}/replace`: Issue a replacement invoice - `POST /einvoices/{id}/resolve`: Settle a conflict: keep your draft or the provider's - `POST /einvoices/{id}/sync`: Check this invoice at the provider now - `GET /einvoices/{id}/history`: Status history (who changed what, on which side) ## Checkout - `GET /pay/{id}`: What the hosted checkout page shows - `GET /pay/{id}/qr.svg`: VietQR image, with the hatang mark in the middle unless `logo=0` - `GET /pay/{id}/events`: Server-sent events with the payment status ## Integrations - `GET /integrations/{provider}/{action}`: Provider callback (verification) - `POST /integrations/{provider}/{action}`: Provider callback: MB Bank token (API 32) and balance change (API 90); e-invoice status changes ## Tracking - `POST /stores/apple/{appId}`: App Store Server Notifications v2 for one connected app (Apple-signed JWS in signedPayload) - `POST /stores/google/{appId}`: Google Play real-time developer notifications (Pub/Sub push) for one connected app - `GET /channels/{channel}/webhook`: Channel webhook verification (Zalo, Messenger) - `POST /channels/{channel}/webhook`: Channel webhook receiver - `GET /o/{token}`: Open-tracking pixel - `GET /c/{token}`: Click redirect - `GET /internal/tls-ask`: On-demand TLS check used by the reverse proxy - `POST /ses/events`: Amazon SNS endpoint for SES delivery, bounce and complaint events (SES mode only) - `POST /ses/inbound`: Amazon SNS endpoint for mail SES received for the chat inbox domain (stored in S3) - `GET /u/{token}`: Email preferences page (topics, unsubscribe from all); opening it changes nothing - `POST /u/{token}`: One-click unsubscribe (RFC 8058): from the topic in the link, else from everything - `POST /u/{token}/preferences`: Save the preferences page form --- # Các endpoint chat gateway Public endpoints used by the chat widget and the native SDKs. Every /widget/v1 route checks the request Origin against the site's domain and allowed origins, unless the caller presents a site app key (x-hatang-app-key) or a visitor token minted through one; authenticated calls carry the visitor token from bootstrap. Realtime traffic uses the websocket at /ws?site=…(&app=…&install=… for apps), protocol v1; close codes: 4001 auth_timeout, 4003 auth_failed, 4004 protocol_outdated, 4005 session_replaced. See docs/chat-protocol.md and docs/chat-native-sdk.md. Base URL: https://chat.hatang.dev ## Widget - `POST /widget/v1/sites/{siteId}/bootstrap`: Create or resume a visitor session (browsers: allowed Origin; native SDKs: app key + `app.install_id`) - `POST /widget/v1/sites/{siteId}/conversations`: Start a conversation (HTTP fallback) - `GET /widget/v1/sites/{siteId}/conversations`: The visitor's own conversations, newest activity first, resolved ones included (an app's inbox) - `GET /widget/v1/sites/{siteId}/conversations/{id}/messages`: Timeline items of one of the visitor's conversations: after `after_seq` (sync) or before `before_seq` (earlier history); `hasMore` is kept one release for old bundles - `POST /widget/v1/sites/{siteId}/conversations/{id}/messages`: Send a message (HTTP fallback); content may be empty when attachments are given - `POST /widget/v1/sites/{siteId}/leads`: Capture a lead from any form: creates/identifies the visitor, opens a conversation, routes it and counts a conversion (no visitor token needed; 10/min per IP) - `POST /widget/v1/sites/{siteId}/offline-message`: Pre-chat form: name, email and first message while nobody is online - `POST /widget/v1/sites/{siteId}/uploads`: Upload an attachment (multipart, field `file`, 10 MB; images incl. HEIC, PDF, office files, zip, mp4, m4a) - `POST /widget/v1/sites/{siteId}/purchases`: Report a finished App Store / Google Play purchase from the app (SDK reportPurchase). With `signed_transaction` (StoreKit 2 JWS) or `purchase_token` (Play) it is checked with the store and recorded for this visitor (Play purchases are acknowledged); a store that says no is a 422. The team also gets an internal note in the open conversation - `PUT /widget/v1/sites/{siteId}/devices`: Native SDKs: register this phone's push token for the visitor (FCM v1 with the site's Firebase credentials); re-registering moves the token to the current visitor - `DELETE /widget/v1/sites/{siteId}/devices`: Forget this phone's push token - `POST /widget/v1/sites/{siteId}/reset`: Forget this visitor: the old token stops working and a fresh visitor is issued - `POST /widget/v1/sites/{siteId}/sessions/restore`: Exchange a magic-link token from a chat email for a visitor token (with an app key, the token is minted for that app) ## Files - `GET /files/{id}`: Download an attachment with a signed URL - `POST /agent/uploads`: Upload an attachment as an agent (Bearer agent token, multipart `file` + `site_id`; 10 MB, videos 25 MB, a .mov is stored as MP4 when the host has ffmpeg) ## Agent - `GET /app/version`: Support app update check (public): `update` is none, available (below APP__LATEST) or required (below APP__MIN). Apps send `X-Hatang-App: /` on /agent/* requests; a build below the minimum gets 426 upgrade_required - `PUT /agent/devices`: Register the support app on this phone or Mac (Bearer mobile agent token); upserts by device_id. Phones send `expo_push_token`; the Mac app (platform macos) sends `apns_token` and `apns_env` - `DELETE /agent/devices/{deviceId}`: Forget this phone (sign-out) - `GET /agent/devices/{deviceId}/prefs`: This phone's notification preferences - `PATCH /agent/devices/{deviceId}/prefs`: Change mute, event kinds, quiet hours or time zone for this phone - `GET /agent/canned`: Canned responses: the agent's own (private) ones first, then the team's - `POST /agent/canned`: Add a canned response: the agent's own, or the team's with `shared` (chat:manage). An empty shortcut is made from the title - `GET /agent/conversations/{id}/scheduled`: The conversation's scheduled messages (waiting or failed), soonest first - `POST /agent/conversations/{id}/scheduled`: Schedule a reply: sent at `send_at` as this agent's message. At least a minute ahead, within 30 days and before the channel's reply window closes (chat:reply) - `PATCH /agent/scheduled/{id}`: Change the text or the time of a scheduled (or failed) message - `DELETE /agent/scheduled/{id}`: Cancel a scheduled message - `POST /agent/scheduled/{id}/send`: Send a scheduled message now - `PATCH /agent/canned/{id}`: Edit one of the agent's own responses (the team's need chat:manage); fields are optional - `DELETE /agent/canned/{id}`: Delete one of the agent's own responses (the team's need chat:manage) - `GET /agent/profile-links`: The business's profile links: URL templates (https, {{user_id}} = verified identify() id else the claimed one marked unverified, {{email}}, {{phone}}, {{name}}, {{customer_id}}, {{conversation_id}}, {{site_id}}, {{field.}}, {{data.}}) the client fills per conversation; links with a missing value are hidden - `GET /agent/home`: Home summary for the signed-in employee: chat, orders, tickets, payments and employees sections by role and switched-on module, plus an attention list - `GET /agent/reminders`: The signed-in agent's open reminders (create and complete them over the socket) - `GET /agent/agents`: Employees who can pick up conversations (assign picker) - `GET /agent/conversations`: Inbox snapshot: `status` open (default; includes pending) | pending | resolved | closed | all, `scope` mine | unassigned | all, `q`, `after` cursor, `unanswered=1`, `flag`, `limit` ≤ 100 - `GET /agent/conversations/{id}`: One conversation with the latest 100 timeline items (messages carry signed attachment links) - `GET /agent/conversations/{id}/email`: Email side of a conversation: subject, CC participants, first-answer deadline and how each emailed reply landed (null when the conversation never used email) - `POST /agent/conversations/{id}/rating-request`: Ask the customer for a rating now (whether or not automatic CSAT is on); result "rated" when they already did - `POST /agent/conversations/{id}/block`: Block the conversation's customer: this and their later conversations go to Spam - `POST /agent/conversations/{id}/mute`: Mute (body { muted: true }) or unmute the conversation for the signed-in agent - `POST /agent/conversations/{id}/ai`: Pause (body { paused: true }) or resume the AI assistant in the conversation - `POST /agent/conversations/{id}/learn`: Teach the AI from one of our public replies: the AI reads the exchange, the closest knowledge and the AI instructions and drafts a Q/A and/or one instruction line (nothing is saved). Needs chat:reply; 429 ai_budget_exhausted, 503 ai_not_configured / ai_unavailable - `POST /agent/conversations/{id}/learn/save`: Save the confirmed draft: the Q/A joins the "Câu hỏi từ khách" FAQ (skipped when the same question is there; a matching open knowledge gap is answered), the instruction is appended as "- …" (409 instructions_full past 4000 characters). The reply is then re-sent as a message frame with metadata.learned - `POST /agent/messages/{id}/recall`: Recall a message we sent (web chat; personal Zalo on Zalo too); the customer keeps a recalled marker, the team a private note - `GET /agent/customers/{id}`: The customer card: record, latest notes and the business's custom fields (CRM on and readable) - `PATCH /agent/customers/{id}`: Edit any of name, email, phone, owner_id, stage, labels, fields - `POST /agent/customers/{id}/notes`: Add a note about the customer (body { body }) - `DELETE /agent/customers/{id}/notes/{noteId}`: Delete one of your own notes - `POST /agent/ai/drafts/{id}`: Insert or discard the AI assistant's suggested reply ## Hosted - `GET /c/{token}`: Hosted continuation page opened from a chat email; loads the widget full-page and restores the session - `GET /rate/{id}`: One-click CSAT from an email: records the score (s=1..5, signed link) and offers a comment form - `POST /rate/{id}`: Comment for an email rating (form post from the rating page) ## Assets - `GET /widget.js`: Widget loader (mutable, 5 min cache) - `GET /w/{file}`: Hashed widget bundle (immutable) - `GET /preview`: Live widget preview for the dashboard's site settings: the real bundle with a stub boot and no network, configured by postMessage from the dashboard (the only page allowed to frame it)