# 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_...` to `https://api.hatang.dev`. See [Authentication](https://hatang.dev/docs/guides/getting-started/authentication.md).
- The key needs the right scopes:

  | Scope | Lets the key |
  |---|---|
  | `chat:read` | List and read sites, conversations, timelines, visitors, tickets, reports, flows, workflows, campaigns, canned responses, distribution rules and knowledge sources |
  | `chat:write` | Everything `chat:read` does, plus reply, update conversations, tickets, sites, flows, workflows, campaigns, canned responses, distribution rules and knowledge sources |
  | `crm:read` / `crm:write` | Customers, custom customer fields and Zalo ZNS (keys with `chat:*` scopes also work here) |
  | `products:read` / `products:write` | Products and orders (keys with `chat:*` scopes also work here) |

  A `write` scope includes `read`; `chat:*` and `*` cover everything.
- The Support module must be on for the business. While it is off, every endpoint answers `403 product_disabled` ([Modules](https://hatang.dev/help/getting-started/modules)).

> **Note**
>
> `chat:reply` and `chat:manage` are permissions of employees in the dashboard ([Team and roles](https://hatang.dev/help/account/employees-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**

```bash
curl "https://api.hatang.dev/chat/conversations?status=open,pending&unanswered=true&limit=20" \
  -H "Authorization: Bearer ht_123..."
```

**Node.js**

```js
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}`));
```

**Python**

```python
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"])
```

**PHP**

```php
$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);
```

```json title="Response (shortened)"
{
  "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.

> **Caution**
>
> 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**

```bash
curl "https://api.hatang.dev/chat/conversations/cnv_123/messages?after_seq=10" \
  -H "Authorization: Bearer ht_123..."
```

**Node.js**

```js
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);
}
```

**Python**

```python
items = hatang.chat.conversations.messages("cnv_123", after_seq=10)["data"]
```

**PHP**

```php
$res = $client->get('/chat/conversations/cnv_123/messages', [
    'headers' => ['Authorization' => 'Bearer ht_123...'],
    'query'   => ['after_seq' => 10],
]);
```

```json title="One message item"
{
  "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**

```bash
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"}'
```

**Node.js**

```js
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" },
);
```

**Python**

```python
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",
)
```

**PHP**

```php
$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'],
]);
```

```json title="201 Created"
{ "object": "chat_message", "id": "msg_...", "seq": 15, "conversation_id": "cnv_123", "duplicate": false }
```

What can go wrong:

- `409 conversation_closed`: the conversation is `closed` and takes no more messages.
- `422 validation_error`: empty `content`, too long, or `metadata` over 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 a `delivery_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**

```bash
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"]}'
```

**Node.js**

```js
await hatang.chat.conversations.update("cnv_123", { status: "resolved", tags: ["shipping"] });
```

**Python**

```python
hatang.chat.conversations.update("cnv_123", {"status": "resolved", "tags": ["shipping"]})
```

**PHP**

```php
$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](https://hatang.dev/docs/guides/getting-started/webhooks.md)).

| 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.

> **Caution: Avoid reply loops**
>
> `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**

```bash
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}}"}]}'
```

**Node.js**

```js
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}}" },
]);
```

**Python**

```python
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}}"},
    ]},
)
```

**PHP**

```php
$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](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md#profile-links)) |
| `{{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](https://hatang.dev/docs/reference/api.md). The Node.js SDK wraps most of them under `hatang.chat.*`; see [Node.js SDK](https://hatang.dev/docs/guides/sdks/node.md).

## 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](https://hatang.dev/docs/reference/errors.md) and [Limits](https://hatang.dev/docs/reference/limits.md).

## Next steps

- [Concepts and message flow](https://hatang.dev/docs/guides/support/overview.md)
- [Automation](https://hatang.dev/docs/guides/support/automation.md): flows, workflows and distribution rules you can also manage through the API
- [Realtime](https://hatang.dev/docs/guides/support/realtime.md): how the widget and agent apps talk to the gateway
- [Webhooks](https://hatang.dev/docs/guides/getting-started/webhooks.md): verify signatures and handle retries

Source: https://hatang.dev/docs/guides/support/rest-api
