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 (version, limits, close codes, error codes) and the JSON Schema files visitor-frames.json, server-frames.json, http.json and 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
- Call
POST /widget/v1/sites/{siteId}/bootstrap. It creates or refreshes the visitor token and returnsws_url. A browser needs an Origin allowed for the site; a native app sends its app key in thex-hatang-app-keyheader instead, and the token it gets back carries the app's id. - 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. - Send
authas the first frame, within 5 seconds.
Every frame is a JSON object:
{ "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
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:
- Generate a
client_idfor it and show the bubble as sending. - Send
message.sendand wait up to 10 seconds formessage.ack. - On a timeout or an error, keep the bubble in a failed state and retry with the same
client_id. The server answers a repeatedclient_idwith the original message instead of creating a second one. - The
messageframe for your own message arrives too; dedupe it byid.
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
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). 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
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.
If you write your own client:
- Text above a card. For
product,orderandpaymentcards,contentis 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,csatand unknown kinds still showcontent. - VietQR code.
qr_image_urlis an SVG (/pay/{id}/qr.svg). Browsers draw it, but many mobile image loaders cannot. Draw the code yourself fromqr_payload(the EMVCo string); for older cards without it, readqr.payloadfrom the publicGET /pay/{id}. - Paid state. The message holding a payment card is not updated when the customer pays. Treat the card as paid when its
statusispaidorsucceeded, when the timeline has a message withmetadata.kind = "order_paid"for the sameorder_id, or when anorder_paidevent has a matchingdata.payment_idordata.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
HatangCommercehook that hands the buy button to your in-app purchase code; physical goods keep VietQR.
HTTP endpoints
Every path below is under /widget/v1/sites/{siteId}/. The full OpenAPI is in the Chat gateway reference.
| 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) |
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). 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) | yes | |
conversation | Conversation | null (Conversation) | yes | |
timeline | (Message | Event)[] (Message, Event) | yes | |
timeline_has_more | boolean | no | |
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) | 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, 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) | 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
| Field | Type | Required | Constraints |
|---|---|---|---|
visitor_token | string | yes | |
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) | yes | |
conversation | Conversation | null (Conversation) | yes | |
timeline | (Message | Event)[] (Message, 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
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
| 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
| 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
| 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) | 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
| 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
| Field | Type | Required | Constraints |
|---|---|---|---|
id | string | yes | 1–64 chars |
name | string | yes | |
size | integer | yes | |
type | string | yes | |
url | string | no |
Availability
| Field | Type | Required | Constraints |
|---|---|---|---|
online | boolean | yes | |
within_hours | boolean | yes | |
next_open_at | string | null | yes | |
agents_online | integer | yes |