Support REST API
The Support REST API lets your server work the inbox like an agent: list conversations from every channel, read their timeline, reply or leave a private note, assign, tag and resolve. Use it to connect the inbox to your ERP, CRM, order system or your own bot. Webhooks tell you when something happens, so you do not have to poll.
Before you start
-
An API key from the dashboard (API Keys). Send it as
Authorization: Bearer ht_...tohttps://api.hatang.dev. See Authentication. -
The key needs the right scopes:
Scope Lets the key chat:readList and read sites, conversations, timelines, visitors, tickets, reports, flows, workflows, campaigns, canned responses, distribution rules and knowledge sources chat:writeEverything chat:readdoes, plus reply, update conversations, tickets, sites, flows, workflows, campaigns, canned responses, distribution rules and knowledge sourcescrm:read/crm:writeCustomers, custom customer fields and Zalo ZNS (keys with chat:*scopes also work here)products:read/products:writeProducts and orders (keys with chat:*scopes also work here)A
writescope includesread;chat:*and*cover everything. -
The Support module must be on for the business. While it is off, every endpoint answers
403 product_disabled(Modules).
chat:reply and chat:manage are permissions of employees in the dashboard (Team and roles), not API key scopes.
1. List conversations
GET /chat/conversations returns the newest activity first, 50 per page by default (up to 100).
| Query parameter | Meaning |
|---|---|
status | Comma-separated: open, pending, resolved, closed |
unanswered | true: only conversations where the customer sent the last message |
assignee_id | Conversations assigned to this employee |
site_id | Conversations of one site |
visitor_id | Conversations of one visitor |
q | Search text (up to 200 characters) |
flag | hidden (snoozed), spam, or none. By default only unflagged conversations are listed |
limit | 1–100, default 50 |
after | Cursor: the id of the last conversation of the previous page |
- cURL
- Node.js
- Python
- PHP
curl "https://api.hatang.dev/chat/conversations?status=open,pending&unanswered=true&limit=20" \
-H "Authorization: Bearer ht_123..."
import { Hatang } from "@hatang/sdk";
const hatang = new Hatang("ht_123...");
const { data, error } = await hatang.chat.conversations.list({
status: ["open", "pending"],
unanswered: true,
limit: 20,
});
if (error) throw new Error(error.message);
console.log(data.data.map((c) => `${c.id} ${c.channel} ${c.last_message_preview}`));
from hatang import Hatang
hatang = Hatang("ht_123...")
page = hatang.chat.conversations.list(status=["open", "pending"], unanswered=True, limit=20)
for c in page["data"]:
print(c["id"], c["channel"], c["last_message_preview"])
$client = new GuzzleHttp\Client(['base_uri' => 'https://api.hatang.dev']);
$res = $client->get('/chat/conversations', [
'headers' => ['Authorization' => 'Bearer ht_123...'],
'query' => ['status' => 'open,pending', 'unanswered' => 'true', 'limit' => 20],
]);
$page = json_decode((string) $res->getBody(), true);
{
"object": "list",
"has_more": true,
"data": [
{
"id": "cnv_...",
"site_id": "site_...",
"visitor_id": "vis_...",
"channel": "zalo",
"channel_account_id": "...",
"status": "open",
"assignee_kind": null,
"assignee_id": null,
"seq": 14,
"agent_unread": 2,
"visitor_unread": 0,
"last_message_at": "2026-10-23T08:15:02.000Z",
"last_message_preview": "Shop còn size M không ạ?",
"last_message_sender_type": "visitor",
"first_response_at": null,
"tags": [],
"member_ids": [],
"flag": null,
"subject": null,
"created_at": "2026-10-23T08:14:40.000Z",
"updated_at": "2026-10-23T08:15:02.000Z",
"visitor": { "id": "vis_...", "name": "Lan", "...": "..." }
}
]
}
channel is one of web, zalo, zalo_personal, messenger, instagram, tiktok, tiktokshop, whatsapp, telegram, x, line or email. The same fields are used everywhere a conversation appears, including webhooks.
Boolean query parameters are read as "present means true". Send unanswered=true to filter, and leave the parameter out otherwise; unanswered=false does not turn the filter off.
2. Read the timeline
GET /chat/conversations/{id}/messages returns messages and events in seq order. Each item has kind: "message" or kind: "event".
| Query parameter | Meaning |
|---|---|
after_seq | Return items after this seq (oldest first). Without it you get the latest page |
limit | 1–200, default 100 |
include_private | true to include private notes. Leave it out to skip them |
- cURL
- Node.js
- Python
- PHP
curl "https://api.hatang.dev/chat/conversations/cnv_123/messages?after_seq=10" \
-H "Authorization: Bearer ht_123..."
const { data } = await hatang.chat.conversations.messages("cnv_123", { after_seq: 10 });
for (const item of data.data) {
if (item.kind === "message") console.log(item.seq, item.sender_type, item.content);
}
items = hatang.chat.conversations.messages("cnv_123", after_seq=10)["data"]
$res = $client->get('/chat/conversations/cnv_123/messages', [
'headers' => ['Authorization' => 'Bearer ht_123...'],
'query' => ['after_seq' => 10],
]);
{
"kind": "message",
"id": "msg_...",
"conversation_id": "cnv_123",
"seq": 11,
"sender_type": "visitor",
"sender_id": "vis_...",
"sender_name": "Lan",
"visibility": "public",
"content_type": "text",
"content": "Shop còn size M không ạ?",
"attachments": [],
"metadata": {},
"delivery_status": null,
"delivery_error": null,
"read_at": null,
"created_at": "2026-10-23T08:15:02.000Z"
}
sender_type is visitor, agent, bot or system. Attachments carry a signed url that works for 24 hours; read the message again for a fresh link.
3. Reply or add a note
POST /chat/conversations/{id}/messages posts a message as your team. Ha Tang delivers it on the conversation's own channel: the widget, Zalo, Messenger, email and so on.
| Field | Type | Notes |
|---|---|---|
content | string | Required, 1–10,000 characters |
visibility | public | private | Default public. private is an internal note the customer never sees |
sender_name | string | Name shown to the customer, up to 100 characters. Default Support |
attachments | string[] | Up to 5 ids of files already uploaded to the conversation |
metadata | object | A rich card (product, order, payment, quick_replies), up to 4 KB |
Send an Idempotency-Key header to make retries safe. A second request with the same key in the same conversation returns the original message with 200 and "duplicate": true instead of posting twice.
- cURL
- Node.js
- Python
- PHP
curl -X POST "https://api.hatang.dev/chat/conversations/cnv_123/messages" \
-H "Authorization: Bearer ht_123..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8812-shipped" \
-d '{"content": "Đơn #8812 đã giao cho đơn vị vận chuyển, dự kiến tới vào thứ Sáu.", "sender_name": "Shop Trà Xanh"}'
const { data, error } = await hatang.chat.conversations.send(
"cnv_123",
{ content: "Đơn #8812 đã giao cho đơn vị vận chuyển, dự kiến tới vào thứ Sáu.", sender_name: "Shop Trà Xanh" },
{ idempotencyKey: "order-8812-shipped" },
);
hatang.chat.conversations.send(
"cnv_123",
{"content": "Đơn #8812 đã giao cho đơn vị vận chuyển, dự kiến tới vào thứ Sáu.", "sender_name": "Shop Trà Xanh"},
idempotency_key="order-8812-shipped",
)
$res = $client->post('/chat/conversations/cnv_123/messages', [
'headers' => ['Authorization' => 'Bearer ht_123...', 'Idempotency-Key' => 'order-8812-shipped'],
'json' => ['content' => 'Đơn #8812 đã giao cho đơn vị vận chuyển, dự kiến tới vào thứ Sáu.', 'sender_name' => 'Shop Trà Xanh'],
]);
{ "object": "chat_message", "id": "msg_...", "seq": 15, "conversation_id": "cnv_123", "duplicate": false }
What can go wrong:
409 conversation_closed: the conversation isclosedand takes no more messages.422 validation_error: emptycontent, too long, ormetadataover 4 KB.- Delivery on social channels is asynchronous. The API accepts the message right away; if the channel refuses it later (for example a 24-hour window has passed on Messenger or WhatsApp), the message gets
delivery_status: "failed"and adelivery_error. Read the timeline or watch the inbox to see it. The channel pages explain each platform's rules.
To post product cards, use POST /chat/conversations/{id}/products with product_ids (up to 10) and an optional intro.
4. Assign, tag, snooze and resolve
PATCH /chat/conversations/{id} changes one or more of these fields:
| Field | Values |
|---|---|
status | open, pending, resolved, closed |
assignee_id | An employee's user id, or null to unassign |
tags | The full list of tags (replaces the current tags; up to 20, each up to 40 characters) |
member_ids | The full list of employees following the conversation (up to 20) |
flag | hidden (snooze), spam, or null to clear |
- cURL
- Node.js
- Python
- PHP
curl -X PATCH "https://api.hatang.dev/chat/conversations/cnv_123" \
-H "Authorization: Bearer ht_123..." \
-H "Content-Type: application/json" \
-d '{"status": "resolved", "tags": ["shipping"]}'
await hatang.chat.conversations.update("cnv_123", { status: "resolved", tags: ["shipping"] });
hatang.chat.conversations.update("cnv_123", {"status": "resolved", "tags": ["shipping"]})
$client->patch('/chat/conversations/cnv_123', [
'headers' => ['Authorization' => 'Bearer ht_123...'],
'json' => ['status' => 'resolved', 'tags' => ['shipping']],
]);
The response is the updated conversation. Each change is recorded in the timeline as an event, attributed to the API key.
5. Get notified with webhooks
Create a webhook endpoint (dashboard Webhooks, or POST /webhooks) and subscribe to the Support events you need. Each delivery is { "type", "created_at", "data" }, signed like every Ha Tang webhook (Webhooks).
| Event | When |
|---|---|
chat.conversation.created | A new conversation starts on any channel |
chat.conversation.assigned | The assignee changes |
chat.conversation.resolved, chat.conversation.closed, chat.conversation.reopened | Status changes |
chat.conversation.handoff | The AI assistant hands the conversation to a person |
chat.conversation.tagged | A tag is added (one event per new tag, with tag) |
chat.message.created | A public message is posted, by the customer, an agent, the bot or the API |
chat.visitor.identified | A visitor is identified |
chat.ticket.created, chat.ticket.updated, chat.ticket.resolved, chat.ticket.breached | Ticket changes and SLA breaches |
chat.rating.created | A customer rates a conversation |
chat.order.created, chat.order.updated, chat.order.paid | Orders |
chat.flow.webhook, chat.campaign.lead | A flow's webhook step runs; a campaign form collects a lead |
chat.zns.sent, chat.zns.failed | Zalo ZNS results |
chat.message.created carries the message plus a conversation object. Private notes do not fire it.
chat.message.created also fires for your own replies. If a bot answers this event, act only when data.sender_type is visitor.
6. Link agents to your admin
Profile links put your own URLs next to every conversation, filled for the person in it. PUT /chat/profile-links (scope chat:write) replaces the whole list; GET /chat/profile-links (chat:read) returns it.
- cURL
- Node.js
- Python
- PHP
curl -X PUT "https://api.hatang.dev/chat/profile-links" \
-H "Authorization: Bearer ht_123..." \
-H "Content-Type: application/json" \
-d '{"data": [{"label": "Admin", "url": "https://example.com/admin/users/{{user_id}}"}, {"label": "Orders", "url": "https://example.com/admin/orders?email={{email}}"}]}'
await hatang.chat.profileLinks.set([
{ label: "Admin", url: "https://example.com/admin/users/{{user_id}}" },
{ label: "Orders", url: "https://example.com/admin/orders?email={{email}}" },
]);
requests.put(
"https://api.hatang.dev/chat/profile-links",
headers={"Authorization": "Bearer ht_123..."},
json={"data": [
{"label": "Admin", "url": "https://example.com/admin/users/{{user_id}}"},
{"label": "Orders", "url": "https://example.com/admin/orders?email={{email}}"},
]},
)
$client->put('/chat/profile-links', [
'headers' => ['Authorization' => 'Bearer ht_123...'],
'json' => ['data' => [
['label' => 'Admin', 'url' => 'https://example.com/admin/users/{{user_id}}'],
['label' => 'Orders', 'url' => 'https://example.com/admin/orders?email={{email}}'],
]],
]);
The response is { "object": "list", "data": [...] } with an id (pl_…) on each link; send the ids back to keep them. Add site_ids to show a link only in some sites' conversations.
| Variable | Value |
|---|---|
{{user_id}} | The verified user_id from a signed identify; otherwise the one the visitor claimed, and agents see the link marked unverified (Identity verification) |
{{email}}, {{phone}}, {{name}} | The customer's contact details |
{{customer_id}}, {{conversation_id}}, {{site_id}} | Ha Tang ids |
{{field.<key>}} | A customer field |
{{data.<key>}} | The visitor's custom data (always marked unverified) |
Rules: https:// only, variables only after the host (never in the domain), up to 500 characters per URL and 10 links. Anything else is a 422 validation_error. Values are URL-encoded, and a link with a variable that has no value is not shown.
More endpoints
The same API covers the rest of Support. All paths start with https://api.hatang.dev/chat:
| Area | Paths |
|---|---|
| Sites | /sites, /sites/{id}, /sites/{id}/identity-secret |
| Visitors and live board | /visitors/{id}, /visitors/{id}/conversations, /live, /live/greet |
| Customers | /customers, /customers/{id}, /customers/{id}/merge, /customers/{id}/conversations, /customers/export.csv, /customer-fields |
| Tickets and SLA | /tickets, /tickets/stats, /tickets/{id}, /sla |
| Profile links | /profile-links |
| Automation | /flows, /workflows, /campaigns, /routing-rules, /canned-responses |
| AI knowledge | /sites/{id}/kb/sources, /sites/{id}/kb/files, /sites/{id}/kb/search |
| Commerce | /products, /orders, /orders/{id}/payment-link |
| Zalo ZNS | /zns/templates, /zns, /zns/bulk |
| Reports | /reports, /ratings |
Every request and response is in the REST API reference. The Node.js SDK wraps most of them under hatang.chat.*; see Node.js SDK.
Errors and limits
Errors use the shared envelope { "statusCode", "name", "message" }. Branch on name, not on message.
| Status | name | Cause |
|---|---|---|
| 401 | missing_api_key | No Authorization header |
| 403 | invalid_api_key | Unknown or revoked key |
| 401 | restricted_api_key | The key lacks the scope for this endpoint |
| 403 | product_disabled | The Support module is off |
| 404 | not_found | The id does not exist in this business |
| 409 | conversation_closed | Posting into a closed conversation |
| 422 | validation_error | The body or query failed validation |
| 429 | rate_limit_exceeded | Over the rate limit |
Support endpoints allow 50 requests per second per business. See Errors and Limits.
Next steps
- Concepts and message flow
- Automation: flows, workflows and distribution rules you can also manage through the API
- Realtime: how the widget and agent apps talk to the gateway
- Webhooks: verify signatures and handle retries