Skip to main content

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​

  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:

{ "t": "message.send", "id": "req_1", "v": 1, "d": { "client_id": "c_9f2", "content": "Is this still in stock?" } }
KeyMeaning
tFrame type
dData
vProtocol version (1)
idOptional 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:

  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​

CodeMeaningWhat the client does
4001No auth within 5 secondsReconnect and send auth first
4003Authentication refused or session revoked (app key revoked, for example)Stop; drop the token and bootstrap a new visitor
4004The client's protocol is older than the gateway supports; a reload frame comes firstBrowsers reload the page; native clients stop reconnecting and ask the user to update the app
4005A session frame just carried a new visitor token (identity merge)Reconnect at once with the new token
otherNetwork trouble, gateway restartReconnect 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.

CodeHTTPMeaning
invalid_app_key401The x-hatang-app-key header is missing, wrong or belongs to another site
app_key_revoked403The key was revoked in the dashboard; the app needs an update with a new key
invalid_visitor_token401The visitor token expired, was reset or was revoked; bootstrap again
unauthenticated401No token
origin_not_allowed403The browser's Origin is not allowed for this site
site_disabled403The site is turned off
product_disabled403The 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_error422The data has the wrong shape or exceeds a limit
rate_limit_exceeded429See the rate limits below; try again later
conversation_closed409Sending into a closed conversation
file_too_large, unsupported_file_type413, 422Uploads: 10 MB at most; images (HEIC included), PDF, office files, zip, mp4 and m4a
not_found404The conversation, message or file does not belong to this visitor
bad_frame—The frame is not valid JSON or has no t

Rate limits:

WhatLimit
Bootstrap30 per minute per app install, or per IP address for browsers
Open sockets3 at a time per app install
Visitor messages5 per 10 seconds and 60 per minute per visitor
Uploads10 per minute per visitor
Device registration10 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, 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​

Every path below is under /widget/v1/sites/{siteId}/. The full OpenAPI is in the Chat gateway reference.

EndpointWhat it does
POST bootstrapCreates 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&afterThe visitor's conversations, resolved ones included, for an in-app inbox
POST conversationsReturns the visitor's active conversation, or opens a new one
GET conversations/{id}/messages?before_seq&limit or ?after_seqA page of older history, or a catch-up
POST conversations/{id}/messagesSend a message over HTTP, when no socket is available
POST uploadsMultipart file; returns { id, name, size, type }. Then send message.send { content: "", attachments: [id] }
PUT devices, DELETE devicesRegister or remove a push token (Push notifications)
POST leads, POST offline-messageA lead from a form; a message left outside office hours
POST resetForget the visitor; the old token stops working and a new one is returned
POST sessions/restoreSwap 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.

LimitValue
One frame16384 B
Visitor message4000 chars
Agent message10000 chars
Attachments per message5
One attachment10 MB
Custom data (identify.data, setData)8 KB
Send auth within5 s
ping interval (both sides)25 s
Wait for message.ack / a reply10 s
Reconnectmin(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).

FieldTypeRequiredConstraints
tokenstringyes≤ 2048 chars
site_idstringyes1–64 chars
pageobject {url, title?, referrer?}no
screenobject {name, params?}no
appobject {install_id, version?, sdk?, os?, os_version?, device_model?, locale?}no
clientobject {sdk, version}no
localestringno≤ 10 chars
app_stateobject {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).

FieldTypeRequiredConstraints
urlstringyes≤ 2048 chars
titlestringno≤ 300 chars
referrerstringno≤ 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).

FieldTypeRequiredConstraints
namestringyes1–200 chars
paramsobject<string, string>nokey ≤ 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).

FieldTypeRequiredConstraints
foregroundbooleanyes
conversation_idstring | nullno1–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).

FieldTypeRequiredConstraints
conversation_idstringno1–64 chars
client_idstringyes1–64 chars
contentstringyes≤ 4000 chars
attachmentsstring[]no≤ 5 items; each item ≤ 64 chars

typing​

The visitor is typing.

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
onbooleanyes

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
up_to_seqintegeryes≥ 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).

FieldTypeRequiredConstraints
user_idstringno≤ 200 chars
emailstringno≤ 320 chars; matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$; email
namestringno≤ 200 chars
phonestringno≤ 40 chars
hashstringno≤ 128 chars
dataobjectno

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

FieldTypeRequiredConstraints
namestringyes≤ 100 chars
dataobjectno

resume​

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

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
after_seqintegeryes≥ 0; ≤ 2147483647

rate​

Rate a resolved conversation (1–5).

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
scoreintegeryes≥ 1; ≤ 5
commentstringno≤ 1000 chars

campaign​

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

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

FieldTypeRequiredConstraints
campaign_idstringyes1–64 chars
action"shown" | "clicked" | "open" | "submit"yes
buttonstringno≤ 40 chars
fieldsobject {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).

FieldTypeRequiredConstraints
visitorVisitor (Visitor)yes
conversationConversation | null (Conversation)yes
timeline(Message | Event)[] (Message, Event)yes
timeline_has_morebooleanno
availabilityAvailability (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).

FieldTypeRequiredConstraints
idstringyes1–64 chars
conversation_idstringyes1–64 chars
seqintegeryes
sender_type"visitor" | "agent" | "bot" | "system"yes
sender_idstring | nullyes
sender_namestring | nullno
visibility"public" | "private"yes
content_type"text" | "file"yes
contentstringyes
attachmentsAttachment[] (Attachment)yes
client_idstring | nullno
metadataobjectno
delivery_status"pending" | "sent" | "failed" | nullno
delivery_errorstring | nullno
read_atstring | nullno
created_atstringyes

message.ack​

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

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

FieldTypeRequiredConstraints
client_idstringyes
idstringyes1–64 chars
seqintegeryes
conversation_idstringyes1–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).

FieldTypeRequiredConstraints
idstringyes1–64 chars
conversation_idstringyes1–64 chars
seqintegeryes
typestringyes
actor_type"visitor" | "agent" | "bot" | "system"yes
actor_idstring | nullyes
dataobjectyes
created_atstringyes

typing​

An agent is typing.

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
side"visitor" | "agent"yes
onbooleanyes
namestringno

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

FieldTypeRequiredConstraints
onlinebooleanyes
within_hoursbooleanyes
next_open_atstring | nullyes
agents_onlineintegeryes

read​

The other side has read up to up_to_seq.

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
side"visitor" | "agent"yes
up_to_seqintegeryes

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

FieldTypeRequiredConstraints
idstringyes1–64 chars
site_idstringyes1–64 chars
status"open" | "pending" | "resolved" | "closed"yes
seqintegeryes
visitor_unreadintegeryes
last_message_atstring | nullyes
last_message_previewstring | nullyes
last_message_sender_type"visitor" | "agent" | "bot" | "system" | nullyes
first_response_atstring | nullyes
resolved_atstring | nullyes
subjectstring | nullyes
created_atstringyes
updated_atstringyes
agentobject {name, avatar_url} | nullyes

resume​

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

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

FieldTypeRequiredConstraints
conversation_idstringyes1–64 chars
items(Message | Event)[] (Message, Event)yes
has_morebooleanyes

conversation.start​

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

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

FieldTypeRequiredConstraints
conversationConversation (Conversation)yes
openbooleanyes

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

FieldTypeRequiredConstraints
visitor_tokenstringyes
visitor_idstringyes1–64 chars
reason"identity_merged" | "reset"yes

identified​

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

FieldTypeRequiredConstraints
visitor_idstringyes1–64 chars
verifiedbooleanyes

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

FieldTypeRequiredConstraints
reasonstringyes

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

FieldTypeRequiredConstraints
codestringyes
messagestringyes
forstringno

pong​

Answer to ping.

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

FieldTypeRequiredConstraints
nowstringyes

HTTP​

POST bootstrap response​

FieldTypeRequiredConstraints
visitor_tokenstringyes
visitorVisitor (Visitor)yes
configobject {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
availabilityAvailability (Availability)yes
conversationConversation | null (Conversation)yes
timeline(Message | Event)[] (Message, Event)yes
timeline_has_morebooleanno
bundlestringyes
ws_urlstringyes
protocol_versionintegeryes
appobject {id, platform} | nullyes

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​

FieldTypeRequiredConstraints
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
introstringno

order​

FieldTypeRequiredConstraints
kind"order"yes
orderobject {id, code, status, items, subtotal, discount, shipping, total, currency, payment_status}yes
introstringno

payment​

FieldTypeRequiredConstraints
kind"payment"yes
paymentobject {id, order_id, order_code, amount, currency, checkout_url, qr_image_url, qr_payload?, code, expires_at, status}yes
introstringno

quick_replies​

FieldTypeRequiredConstraints
kind"quick_replies"yes
optionsstring[]yes

csat​

FieldTypeRequiredConstraints
kind"csat"yes
optionsstring[]yes
answeredbooleanno

Shared types​

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

Visitor​

FieldTypeRequiredConstraints
idstringyes1–64 chars
site_idstringyes1–64 chars
channelstringyes
emailstring | nullyes
namestring | nullyes
phonestring | nullyes
avatar_urlstring | nullyes
external_idstring | nullyes
custom_dataobjectyes
locale"vi" | "en" | nullyes
browserstring | nullyes
osstring | nullyes
devicestring | nullyes
countrystring | nullyes
citystring | nullyes
current_urlstring | nullyes
current_titlestring | nullyes
referrerstring | nullyes
first_seen_atstringyes
last_seen_atstringyes
identified_atstring | nullyes
utmobject<string, string>yes
visitsintegeryes
customer_idstring | nullyes
client"web" | "app"yes
appobject {id, platform, version, sdk} | nullno
current_screenstring | nullno

Conversation​

FieldTypeRequiredConstraints
idstringyes1–64 chars
site_idstringyes1–64 chars
visitor_idstringyes1–64 chars
channelstringyes
channel_account_idstring | nullno
status"open" | "pending" | "resolved" | "closed"yes
assignee_kind"user" | "bot" | nullyes
assignee_idstring | nullyes
seqintegeryes
visitor_unreadintegeryes
agent_unreadintegeryes
last_message_atstring | nullyes
last_message_previewstring | nullyes
last_message_sender_type"visitor" | "agent" | "bot" | "system" | nullyes
last_visitor_message_atstring | nullno
first_response_atstring | nullyes
resolved_atstring | nullyes
tagsstring[]yes
member_idsstring[]yes
flag"hidden" | "spam" | nullyes
snoozed_untilstringno
muted_bystring[]no
subjectstring | nullyes
created_atstringyes
updated_atstringyes

Message​

FieldTypeRequiredConstraints
idstringyes1–64 chars
conversation_idstringyes1–64 chars
seqintegeryes
sender_type"visitor" | "agent" | "bot" | "system"yes
sender_idstring | nullyes
sender_namestring | nullno
visibility"public" | "private"yes
content_type"text" | "file"yes
contentstringyes
attachmentsAttachment[] (Attachment)yes
client_idstring | nullno
metadataobjectno
delivery_status"pending" | "sent" | "failed" | nullno
delivery_errorstring | nullno
read_atstring | nullno
created_atstringyes
kind"message"yes

Event​

FieldTypeRequiredConstraints
idstringyes1–64 chars
conversation_idstringyes1–64 chars
seqintegeryes
typestringyes
actor_type"visitor" | "agent" | "bot" | "system"yes
actor_idstring | nullyes
dataobjectyes
created_atstringyes
kind"event"yes

Attachment​

FieldTypeRequiredConstraints
idstringyes1–64 chars
namestringyes
sizeintegeryes
typestringyes
urlstringno

Availability​

FieldTypeRequiredConstraints
onlinebooleanyes
within_hoursbooleanyes
next_open_atstring | nullyes
agents_onlineintegeryes

View this page as Markdown