# Chat realtime protocol (v1)

Every chat client (the web widget and the Flutter, React Native, iOS and Android SDKs) speaks the same WebSocket protocol with the gateway at `chat.hatang.dev`. Read this page to write your own client or to understand what the SDKs do under the hood. The frame tables further down are generated from the JSON Schema shipped with the `@hatang/chat-protocol` package, so they always match the gateway.

Machine-readable versions: [`chat.hatang.dev/protocol.json`](https://chat.hatang.dev/protocol.json) (version, limits, close codes, error codes) and the JSON Schema files [`visitor-frames.json`](https://hatang.dev/docs/schema/chat-protocol-v1/visitor-frames.json), [`server-frames.json`](https://hatang.dev/docs/schema/chat-protocol-v1/server-frames.json), [`http.json`](https://hatang.dev/docs/schema/chat-protocol-v1/http.json) and [`protocol.json`](https://hatang.dev/docs/schema/chat-protocol-v1/protocol.json). What a native SDK must do on top of the protocol (session lifecycle, history, attachments, push) is checked by the conformance scenarios C01–C12 that every SDK passes before a release.

## Envelope {#envelope}

1. Call `POST /widget/v1/sites/{siteId}/bootstrap`. It creates or refreshes the visitor token and returns `ws_url`. A browser needs an Origin allowed for the site; a native app sends its app key in the `x-hatang-app-key` header instead, and the token it gets back carries the app's id.
2. Open `ws_url`: `wss://chat.hatang.dev/ws?site=<site_id>`, plus `&app=<app_id>&install=<install_id>` for an app. Use it exactly as returned; an app socket needs no Origin.
3. Send `auth` as the first frame, within 5 seconds.

Every frame is a JSON object:

```json
{ "t": "message.send", "id": "req_1", "v": 1, "d": { "client_id": "c_9f2", "content": "Is this still in stock?" } }
```

| Key | Meaning |
|---|---|
| `t` | Frame type |
| `d` | Data |
| `v` | Protocol version (`1`) |
| `id` | Optional request id, echoed in `message.ack` or in `error.for` so you can match a reply to its request |

Both sides send `ping` every 25 seconds and answer with `pong`. For the HTTP endpoints, send the visitor token in the `x-visitor-token` header. In the `identify` frame the user id field is `user_id`; every native SDK sends that name, and the web widget's `$hatang` API also accepts `userId` and maps it to `user_id`.

## Ordering and resume {#ordering-and-resume}

Every message and event in a conversation carries a `seq` that grows by one. Store the highest `seq` you have seen. After a reconnect (or right after `ready` when you already have one), send `resume { conversation_id, after_seq }`; the server replays everything after it from the database, so a frame lost while the socket was down is never lost for good. Load older history over HTTP with `before_seq`, and catch up with `after_seq`.

To send a message:

1. Generate a `client_id` for it and show the bubble as sending.
2. Send `message.send` and wait up to 10 seconds for `message.ack`.
3. On a timeout or an error, keep the bubble in a failed state and retry with the **same** `client_id`. The server answers a repeated `client_id` with the original message instead of creating a second one.
4. The `message` frame for your own message arrives too; dedupe it by `id`.

## Close codes {#close-codes}

| Code | Meaning | What the client does |
|---|---|---|
| 4001 | No `auth` within 5 seconds | Reconnect and send `auth` first |
| 4003 | Authentication refused or session revoked (app key revoked, for example) | Stop; drop the token and bootstrap a new visitor |
| 4004 | The client's protocol is older than the gateway supports; a `reload` frame comes first | Browsers reload the page; native clients stop reconnecting and ask the user to update the app |
| 4005 | A `session` frame just carried a new visitor token (identity merge) | Reconnect at once with the new token |
| other | Network trouble, gateway restart | Reconnect with backoff `min(30 s, 500 ms · 2^n)` plus up to 500 ms of jitter |

## Error codes {#error-codes}

HTTP errors are `{ statusCode, name, message }`; socket errors are an `error { code, message, for? }` frame.

| Code | HTTP | Meaning |
|---|---|---|
| `invalid_app_key` | 401 | The `x-hatang-app-key` header is missing, wrong or belongs to another site |
| `app_key_revoked` | 403 | The key was revoked in the dashboard; the app needs an update with a new key |
| `invalid_visitor_token` | 401 | The visitor token expired, was reset or was revoked; bootstrap again |
| `unauthenticated` | 401 | No token |
| `origin_not_allowed` | 403 | The browser's Origin is not allowed for this site |
| `site_disabled` | 403 | The site is turned off |
| `product_disabled` | 403 | The business has turned off the Customer Support module ([Businesses and modules](https://hatang.dev/help/getting-started/modules)). The widget hides its button; the SDKs report the error when the chat starts and the rest of the app keeps working. Turning the module back on is enough to reconnect |
| `validation_error` | 422 | The data has the wrong shape or exceeds a limit |
| `rate_limit_exceeded` | 429 | See the rate limits below; try again later |
| `conversation_closed` | 409 | Sending into a closed conversation |
| `file_too_large`, `unsupported_file_type` | 413, 422 | Uploads: 10 MB at most; images (HEIC included), PDF, office files, zip, mp4 and m4a |
| `not_found` | 404 | The conversation, message or file does not belong to this visitor |
| `bad_frame` | — | The frame is not valid JSON or has no `t` |

Rate limits:

| What | Limit |
|---|---|
| Bootstrap | 30 per minute per app install, or per IP address for browsers |
| Open sockets | 3 at a time per app install |
| Visitor messages | 5 per 10 seconds and 60 per minute per visitor |
| Uploads | 10 per minute per visitor |
| Device registration | 10 per minute per visitor |

## Rendering cards {#card-rendering}

A message whose `metadata.kind` is `product`, `order`, `payment`, `quick_replies` or `csat` carries structured data the client draws as a card: products, an order, a payment button, quick replies or a rating prompt. Its `content` is always plain text, so channels without cards can still read it. Tapping a quick reply sends its label as the visitor's message. `csat` invites a rating (stars in the widget, quick replies elsewhere), and the client answers with a `rate` frame. Unknown kinds are shown as plain messages. The shape of each card is in [Cards in a message's `metadata`](#cards).

If you write your own client:

- **Text above a card.** For `product`, `order` and `payment` cards, `content` is the full summary meant for email, push and other channels; do not show it in a bubble next to the card. Show only the intro line, `metadata.intro`, when there is one, and no bubble otherwise. `quick_replies`, `csat` and unknown kinds still show `content`.
- **VietQR code.** `qr_image_url` is an SVG (`/pay/{id}/qr.svg`). Browsers draw it, but many mobile image loaders cannot. Draw the code yourself from `qr_payload` (the EMVCo string); for older cards without it, read `qr.payload` from the public `GET /pay/{id}`.
- **Paid state.** The message holding a payment card is not updated when the customer pays. Treat the card as paid when its `status` is `paid` or `succeeded`, when the timeline has a message with `metadata.kind = "order_paid"` for the same `order_id`, or when an `order_paid` event has a matching `data.payment_id` or `data.order_id`. Then hide the QR code, the transfer note and the pay button.
- **Apps that sell digital goods.** On iOS and Android, digital goods (courses, memberships and so on) must be sold through in-app purchase. The mobile SDKs have a `HatangCommerce` hook that hands the buy button to your in-app purchase code; physical goods keep VietQR.

## HTTP endpoints {#http-endpoints}

Every path below is under `/widget/v1/sites/{siteId}/`. The full OpenAPI is in the [Chat gateway reference](https://hatang.dev/docs/reference/chat-gateway.md).

| Endpoint | What it does |
|---|---|
| `POST bootstrap` | Creates or refreshes the visitor token. Returns `visitor`, `conversation`, `timeline`, `availability`, `ws_url` and the site config. Native SDKs send `app { install_id, version, sdk, os, os_version, device_model, locale }` and `screen` |
| `GET conversations?status=all\|open\|resolved&limit&after` | The visitor's conversations, resolved ones included, for an in-app inbox |
| `POST conversations` | Returns the visitor's active conversation, or opens a new one |
| `GET conversations/{id}/messages?before_seq&limit` or `?after_seq` | A page of older history, or a catch-up |
| `POST conversations/{id}/messages` | Send a message over HTTP, when no socket is available |
| `POST uploads` | Multipart `file`; returns `{ id, name, size, type }`. Then send `message.send { content: "", attachments: [id] }` |
| `PUT devices`, `DELETE devices` | Register or remove a push token ([Push notifications](https://hatang.dev/docs/guides/sdks/push-notifications.md)) |
| `POST leads`, `POST offline-message` | A lead from a form; a message left outside office hours |
| `POST reset` | Forget the visitor; the old token stops working and a new one is returned |
| `POST sessions/restore` | Swap the token from a "we replied" email link (`/c/<token>`) for that visitor's session |

Attachment URLs in `attachments[].url` are signed links to `GET /files/{id}?exp&sig` and stay valid for 24 hours. When one expires, load the message again to get a fresh link.

## Limits and timing

Protocol version: **1**.

| Limit | Value |
|---|---|
| One frame | 16384 B |
| Visitor message | 4000 chars |
| Agent message | 10000 chars |
| Attachments per message | 5 |
| One attachment | 10 MB |
| Custom data (`identify.data`, `setData`) | 8 KB |
| Send `auth` within | 5 s |
| `ping` interval (both sides) | 25 s |
| Wait for `message.ack` / a reply | 10 s |
| Reconnect | `min(30 s, 500 ms · 2^n)` + jitter ≤ 500 ms; immediately after close codes 4005 |

Close codes: `4001` auth_timeout · `4003` auth_failed · `4004` protocol_outdated · `4005` session_replaced.

Error codes: `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`.

## Visitor → server frames

### `auth`

First frame, within 5 seconds of opening the socket. Browsers send `page`; native SDKs send `screen`, `app` and `client`. `app_state` lets a socket opened in the background avoid counting as online.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `token` | `string` | yes | ≤ 2048 chars |
| `site_id` | `string` | yes | 1–64 chars |
| `page` | `object {url, title?, referrer?}` | no |  |
| `screen` | `object {name, params?}` | no |  |
| `app` | `object {install_id, version?, sdk?, os?, os_version?, device_model?, locale?}` | no |  |
| `client` | `object {sdk, version}` | no |  |
| `locale` | `string` | no | ≤ 10 chars |
| `app_state` | `object {foreground}` | no |  |

### `page`

The visitor moved to another page (browser); recorded on the active conversation, or in the journey before writing.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `url` | `string` | yes | ≤ 2048 chars |
| `title` | `string` | no | ≤ 300 chars |
| `referrer` | `string` | no | ≤ 2048 chars |

### `screen`

The visitor moved to another screen (native SDKs); replaces `page` and is recorded the same way.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | `string` | yes | 1–200 chars |
| `params` | `object<string, string>` | no | key ≤ 60, value ≤ 500 chars |

### `app.state`

The app went to the background or came back. A backgrounded app counts as offline: replies go out by push notification or email; the conversation on screen (`conversation_id`) gets no push.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `foreground` | `boolean` | yes |  |
| `conversation_id` | `string \| null` | no | 1–64 chars |

### `message.send`

Send a message. `client_id` is generated by the client and reused on retry; the server de-duplicates. An empty `content` needs at least one `attachments` item. Limited to 5 messages / 10 seconds and 60 / minute.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | no | 1–64 chars |
| `client_id` | `string` | yes | 1–64 chars |
| `content` | `string` | yes | ≤ 4000 chars |
| `attachments` | `string[]` | no | ≤ 5 items; each item ≤ 64 chars |

### `typing`

The visitor is typing.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `on` | `boolean` | yes |  |

### `read`

The visitor has read up to `up_to_seq`; send only while the conversation is visible and the app is in the foreground.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `up_to_seq` | `integer` | yes | ≥ 0; ≤ 2147483647 |

### `identify`

Attach an identity. `hash` is the hex HMAC-SHA256 of `user_id` with the site's identity secret (see [Identity verification](https://hatang.dev/docs/guides/support/web-widget/identity-verification.md)). A signed identity that already belongs to another visitor is merged and answered with a `session` frame.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `user_id` | `string` | no | ≤ 200 chars |
| `email` | `string` | no | ≤ 320 chars; matches `^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$`; email |
| `name` | `string` | no | ≤ 200 chars |
| `phone` | `string` | no | ≤ 40 chars |
| `hash` | `string` | no | ≤ 128 chars |
| `data` | `object` | no |  |

### `event`

Any custom event from the app (`pushEvent`), recorded on the active conversation. Before the visitor has a conversation, pages, screens and events are kept as their journey (the last 20, for 24 hours) and attached to the conversation they start, for agents only. Values under secret-looking keys (password, token, secret, card, cvv, otp) are replaced with `[redacted]`.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `name` | `string` | yes | ≤ 100 chars |
| `data` | `object` | no |  |

### `resume`

Replay the items after `after_seq` of one of the visitor's conversations, after reconnecting.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `after_seq` | `integer` | yes | ≥ 0; ≤ 2147483647 |

### `rate`

Rate a resolved conversation (1–5).

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `score` | `integer` | yes | ≥ 1; ≤ 5 |
| `comment` | `string` | no | ≤ 1000 chars |

### `campaign`

Interaction with a campaign (shown, clicked, opened, form sent).

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `campaign_id` | `string` | yes | 1–64 chars |
| `action` | `"shown" \| "clicked" \| "open" \| "submit"` | yes |  |
| `button` | `string` | no | ≤ 40 chars |
| `fields` | `object {name?, phone?, email?}` | no |  |

### `ping`

Keep-alive every 25 seconds; the server answers `pong`.

`id`: optional (matched against `message.ack` / `error`).

_No data (`d` is `null`)._

## Server → visitor frames

### `ready`

Authenticated: the visitor, the open conversation (or `null`), the latest 100 items and the team's availability.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `visitor` | `Visitor` ([Visitor](#visitor)) | yes |  |
| `conversation` | `Conversation \| null` ([Conversation](#conversation)) | yes |  |
| `timeline` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | yes |  |
| `timeline_has_more` | `boolean` | no |  |
| `availability` | `Availability` ([Availability](#availability)) | yes |  |

### `message`

A new message in a conversation (including the echo of the visitor's own; de-duplicate by `id`).

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `conversation_id` | `string` | yes | 1–64 chars |
| `seq` | `integer` | yes |  |
| `sender_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes |  |
| `sender_id` | `string \| null` | yes |  |
| `sender_name` | `string \| null` | no |  |
| `visibility` | `"public" \| "private"` | yes |  |
| `content_type` | `"text" \| "file"` | yes |  |
| `content` | `string` | yes |  |
| `attachments` | `Attachment[]` ([Attachment](#attachment)) | yes |  |
| `client_id` | `string \| null` | no |  |
| `metadata` | `object` | no |  |
| `delivery_status` | `"pending" \| "sent" \| "failed" \| null` | no |  |
| `delivery_error` | `string \| null` | no |  |
| `read_at` | `string \| null` | no |  |
| `created_at` | `string` | yes |  |

### `message.ack`

Confirms a `message.send` with the real `id`, `seq` and `conversation_id`.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `client_id` | `string` | yes |  |
| `id` | `string` | yes | 1–64 chars |
| `seq` | `integer` | yes |  |
| `conversation_id` | `string` | yes | 1–64 chars |

### `event`

An event in a conversation. Visitors only get `status_changed`, `handoff`, `bot_resumed`, `rated`, `order_paid`, `ticket_created`, `ticket_status` and `notice`; ignore unknown types.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `conversation_id` | `string` | yes | 1–64 chars |
| `seq` | `integer` | yes |  |
| `type` | `string` | yes |  |
| `actor_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes |  |
| `actor_id` | `string \| null` | yes |  |
| `data` | `object` | yes |  |
| `created_at` | `string` | yes |  |

### `typing`

An agent is typing.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `side` | `"visitor" \| "agent"` | yes |  |
| `on` | `boolean` | yes |  |
| `name` | `string` | no |  |

### `availability`

Whether the team is online and within business hours, when it reopens and how many agents are online.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `online` | `boolean` | yes |  |
| `within_hours` | `boolean` | yes |  |
| `next_open_at` | `string \| null` | yes |  |
| `agents_online` | `integer` | yes |  |

### `read`

The other side has read up to `up_to_seq`.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `side` | `"visitor" \| "agent"` | yes |  |
| `up_to_seq` | `integer` | yes |  |

### `conversation.update`

One of the visitor's conversations changed status, unread count or assignee (a visitor-safe view).

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `site_id` | `string` | yes | 1–64 chars |
| `status` | `"open" \| "pending" \| "resolved" \| "closed"` | yes |  |
| `seq` | `integer` | yes |  |
| `visitor_unread` | `integer` | yes |  |
| `last_message_at` | `string \| null` | yes |  |
| `last_message_preview` | `string \| null` | yes |  |
| `last_message_sender_type` | `"visitor" \| "agent" \| "bot" \| "system" \| null` | yes |  |
| `first_response_at` | `string \| null` | yes |  |
| `resolved_at` | `string \| null` | yes |  |
| `subject` | `string \| null` | yes |  |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |
| `agent` | `object {name, avatar_url} \| null` | yes |  |

### `resume`

Answer to `resume`: the items after `after_seq`, with `has_more` when there are more.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation_id` | `string` | yes | 1–64 chars |
| `items` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | yes |  |
| `has_more` | `boolean` | yes |  |

### `conversation.start`

The team messaged first; `open` says whether to open the chat screen right away.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `conversation` | `Conversation` ([Conversation](#conversation)) | yes |  |
| `open` | `boolean` | yes |  |

### `session`

The session changed owner (identity merge or reset): store the new `visitor_token` and bootstrap again. Web sockets get `reload` right after; app sockets are closed with code 4005.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `visitor_token` | `string` | yes |  |
| `visitor_id` | `string` | yes | 1–64 chars |
| `reason` | `"identity_merged" \| "reset"` | yes |  |

### `identified`

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `visitor_id` | `string` | yes | 1–64 chars |
| `verified` | `boolean` | yes |  |

### `reload`

Web clients reload the page; native clients bootstrap again (with close code 4004 they stop reconnecting and ask for an app update).

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `reason` | `string` | yes |  |

### `error`

An error for one request (`for` is the `id` of the frame that caused it) or a general error.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `code` | `string` | yes |  |
| `message` | `string` | yes |  |
| `for` | `string` | no |  |

### `pong`

Answer to `ping`.

`id`: optional (matched against `message.ack` / `error`).

| Field | Type | Required | Constraints |
|---|---|---|---|
| `now` | `string` | yes |  |

## HTTP

### `POST bootstrap` response {#bootstrap}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `visitor_token` | `string` | yes |  |
| `visitor` | `Visitor` ([Visitor](#visitor)) | yes |  |
| `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?}` | yes |  |
| `availability` | `Availability` ([Availability](#availability)) | yes |  |
| `conversation` | `Conversation \| null` ([Conversation](#conversation)) | yes |  |
| `timeline` | `(Message \| Event)[]` ([Message](#message), [Event](#event)) | yes |  |
| `timeline_has_more` | `boolean` | no |  |
| `bundle` | `string` | yes |  |
| `ws_url` | `string` | yes |  |
| `protocol_version` | `integer` | yes |  |
| `app` | `object {id, platform} \| null` | yes |  |

### Cards in a message's `metadata` {#cards}

A message whose `metadata.kind` is listed below carries structured data; `content` is always plain text so email, previews and channels without a template can still read it. Unknown kinds render as plain messages.

#### `product`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `kind` | `"product"` | yes |  |
| `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?})[]` | yes |  |
| `intro` | `string` | no |  |

#### `order`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `kind` | `"order"` | yes |  |
| `order` | `object {id, code, status, items, subtotal, discount, shipping, total, currency, payment_status}` | yes |  |
| `intro` | `string` | no |  |

#### `payment`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `kind` | `"payment"` | yes |  |
| `payment` | `object {id, order_id, order_code, amount, currency, checkout_url, qr_image_url, qr_payload?, code, expires_at, status}` | yes |  |
| `intro` | `string` | no |  |

#### `quick_replies`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `kind` | `"quick_replies"` | yes |  |
| `options` | `string[]` | yes |  |

#### `csat`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `kind` | `"csat"` | yes |  |
| `options` | `string[]` | yes |  |
| `answered` | `boolean` | no |  |

## Shared types

Objects that appear in several frames. In `ready.timeline` and `resume.items` every item also has `kind: "message" | "event"`.

### Visitor {#visitor}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `site_id` | `string` | yes | 1–64 chars |
| `channel` | `string` | yes |  |
| `email` | `string \| null` | yes |  |
| `name` | `string \| null` | yes |  |
| `phone` | `string \| null` | yes |  |
| `avatar_url` | `string \| null` | yes |  |
| `external_id` | `string \| null` | yes |  |
| `custom_data` | `object` | yes |  |
| `locale` | `"vi" \| "en" \| null` | yes |  |
| `browser` | `string \| null` | yes |  |
| `os` | `string \| null` | yes |  |
| `device` | `string \| null` | yes |  |
| `country` | `string \| null` | yes |  |
| `city` | `string \| null` | yes |  |
| `current_url` | `string \| null` | yes |  |
| `current_title` | `string \| null` | yes |  |
| `referrer` | `string \| null` | yes |  |
| `first_seen_at` | `string` | yes |  |
| `last_seen_at` | `string` | yes |  |
| `identified_at` | `string \| null` | yes |  |
| `utm` | `object<string, string>` | yes |  |
| `visits` | `integer` | yes |  |
| `customer_id` | `string \| null` | yes |  |
| `client` | `"web" \| "app"` | yes |  |
| `app` | `object {id, platform, version, sdk} \| null` | no |  |
| `current_screen` | `string \| null` | no |  |

### Conversation {#conversation}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `site_id` | `string` | yes | 1–64 chars |
| `visitor_id` | `string` | yes | 1–64 chars |
| `channel` | `string` | yes |  |
| `channel_account_id` | `string \| null` | no |  |
| `status` | `"open" \| "pending" \| "resolved" \| "closed"` | yes |  |
| `assignee_kind` | `"user" \| "bot" \| null` | yes |  |
| `assignee_id` | `string \| null` | yes |  |
| `seq` | `integer` | yes |  |
| `visitor_unread` | `integer` | yes |  |
| `agent_unread` | `integer` | yes |  |
| `last_message_at` | `string \| null` | yes |  |
| `last_message_preview` | `string \| null` | yes |  |
| `last_message_sender_type` | `"visitor" \| "agent" \| "bot" \| "system" \| null` | yes |  |
| `last_visitor_message_at` | `string \| null` | no |  |
| `first_response_at` | `string \| null` | yes |  |
| `resolved_at` | `string \| null` | yes |  |
| `tags` | `string[]` | yes |  |
| `member_ids` | `string[]` | yes |  |
| `flag` | `"hidden" \| "spam" \| null` | yes |  |
| `snoozed_until` | `string` | no |  |
| `muted_by` | `string[]` | no |  |
| `subject` | `string \| null` | yes |  |
| `created_at` | `string` | yes |  |
| `updated_at` | `string` | yes |  |

### Message {#message}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `conversation_id` | `string` | yes | 1–64 chars |
| `seq` | `integer` | yes |  |
| `sender_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes |  |
| `sender_id` | `string \| null` | yes |  |
| `sender_name` | `string \| null` | no |  |
| `visibility` | `"public" \| "private"` | yes |  |
| `content_type` | `"text" \| "file"` | yes |  |
| `content` | `string` | yes |  |
| `attachments` | `Attachment[]` ([Attachment](#attachment)) | yes |  |
| `client_id` | `string \| null` | no |  |
| `metadata` | `object` | no |  |
| `delivery_status` | `"pending" \| "sent" \| "failed" \| null` | no |  |
| `delivery_error` | `string \| null` | no |  |
| `read_at` | `string \| null` | no |  |
| `created_at` | `string` | yes |  |
| `kind` | `"message"` | yes |  |

### Event {#event}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `conversation_id` | `string` | yes | 1–64 chars |
| `seq` | `integer` | yes |  |
| `type` | `string` | yes |  |
| `actor_type` | `"visitor" \| "agent" \| "bot" \| "system"` | yes |  |
| `actor_id` | `string \| null` | yes |  |
| `data` | `object` | yes |  |
| `created_at` | `string` | yes |  |
| `kind` | `"event"` | yes |  |

### Attachment {#attachment}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `id` | `string` | yes | 1–64 chars |
| `name` | `string` | yes |  |
| `size` | `integer` | yes |  |
| `type` | `string` | yes |  |
| `url` | `string` | no |  |

### Availability {#availability}

| Field | Type | Required | Constraints |
|---|---|---|---|
| `online` | `boolean` | yes |  |
| `within_hours` | `boolean` | yes |  |
| `next_open_at` | `string \| null` | yes |  |
| `agents_online` | `integer` | yes |  |

Source: https://hatang.dev/docs/reference/realtime-protocol
