Chat API reference: conversations, messages, read receipts and events
How to call the Hibira Chat API: signed requests, conversations and members, messages, catching up, read receipts and unread counts, user tokens for browsers, attachments, push, moderation, events and limits.
Before you start
The Chat API stores conversations and their messages and delivers them over the realtime connection your app already has. Your server decides who is in each conversation and calls the API; clients connect, sign in and listen.
- It is in beta. An account can use it once it is in the beta or holds a Chat add-on. Until then every call answers
403 the Chat API is not enabled for this app. Join the waitlist to get in. - Base URL:
https://ws.hibira.io/apps/APP_ID/chat(use your app's host from its page in the portal). - Bodies are JSON. Errors are
{"error": "..."}with a 4xx or 5xx status. - Your users' IDs are your own: use the same IDs your clients pass to
pusher.signin().
How it works
- Your server creates a conversation and its members (
PUT /conversations/{id}). - Each client connects with pusher-js and calls
pusher.signin()as its user. That subscribes it to#server-to-user-<user_id>, so a member needs no channel authorization per conversation. - Your server sends a message on a user's behalf (
POST /conversations/{id}/messages). Hibira stores it with the conversation's next sequence number (seq), then sendschat.messageto every member and calls yourchat_messagewebhook. - A client that was offline reads what it missed with
GET /conversations/{id}/messages?after=<last seq it has>.
Every change is stored before any event is sent. If the app is over its daily message quota the change is still stored but no events go out, so clients catch up by reading.
Signing requests
Requests from your server are signed exactly like the rest of the Pusher HTTP API, with the app key and secret: the query string carries auth_key, auth_timestamp, auth_version=1.0, body_md5 (when there is a body) and auth_signature, the HMAC-SHA256 of METHOD\nPATH\nQUERY with the query parameters sorted by name. Any Pusher server library's signing code works; this is the whole thing in Node.js:
import crypto from 'node:crypto';
const { PUSHER_APP_ID: id, PUSHER_APP_KEY: key, PUSHER_APP_SECRET: secret } = process.env;
export async function chat(method, path, body) {
const fullPath = `/apps/${id}/chat${path}`;
const json = body === undefined ? '' : JSON.stringify(body);
const params = { auth_key: key, auth_timestamp: Math.floor(Date.now() / 1000), auth_version: '1.0' };
if (json) params.body_md5 = crypto.createHash('md5').update(json).digest('hex');
const query = Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join('&');
const signature = crypto.createHmac('sha256', secret).update(`${method}\n${fullPath}\n${query}`).digest('hex');
const res = await fetch(`https://ws.hibira.io${fullPath}?${query}&auth_signature=${signature}`, {
method, body: json || undefined, headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error}`);
return res.json();
}
await chat('PUT', '/conversations/order-1042', { name: 'Order #1042', members: ['ann', 'support-bob'] });
await chat('POST', '/conversations/order-1042/messages', { user_id: 'ann', name: 'Ann', text: 'Where is my parcel?' });Query parameters of a GET (such as after or limit) are part of the signed query too.
Conversations and members
| Method and path | Body or query | Returns |
|---|---|---|
PUT /conversations/{id} | name, metadata, members (user IDs; leave out to keep the current ones), roles ({user_id: role}), watchers (up to 10 private or presence channels) | 201 new or 200 replaced, {conversation} |
GET /conversations/{id} | {conversation} | |
DELETE /conversations/{id} | {}; deletes its members, messages and files | |
POST /conversations/{id}/members | user_ids | {conversation} |
PUT /conversations/{id}/members/{user_id} | role (1–32 of a-z 0-9 _ -; "" for none) | {conversation} |
DELETE /conversations/{id}/members/{user_id} | {conversation} | |
GET /users/{user_id}/conversations | limit (1–100), before (a previous next) | {conversations, next}, latest activity first, each with unread |
A conversation:
{"id": "order-1042", "name": "Order #1042", "metadata": {"kind": "support"},
"members": [{"user_id": "ann", "read_seq": 3}, {"user_id": "support-bob", "read_seq": 1, "role": "agent"}],
"last_seq": 3, "created_at": "...", "updated_at": "..."}Roles and internal notes. A message with visible_to (up to 5 roles) reaches only members with one of those roles: everyone else never receives it, reads it or counts it as unread. Watchers are private or presence channels that receive every event of the conversation, so a team inbox follows all of its conversations with one subscription. Authorize only your staff on them.
Messages
| Method and path | Body or query | Returns |
|---|---|---|
POST /conversations/{id}/messages | user_id (a member), name, text, metadata, push, push_title, push_url, visible_to, attachments | 201 {message, push} |
GET /conversations/{id}/messages | after (seq) and limit, or last (the newest n) | {messages, last_seq}, oldest first |
GET /conversations/{id}/search | q (1–100 characters), before, limit (1–200) | {messages, next}, newest first |
PUT /conversations/{id}/messages/{seq} | user_id (the sender), text, metadata | {message} with edited_at |
DELETE /conversations/{id}/messages/{seq} | user_id (the sender) in the query; leave out to delete as the app | {message} with deleted_at |
POST /conversations/{id}/messages/{seq}/reactions | user_id, emoji | {message} |
DELETE /conversations/{id}/messages/{seq}/reactions | user_id, emoji in the query | {message} |
POST /conversations/{id}/system | text, metadata (such as {"event": "transferred", "to": "bob"}) | 201 {message} with kind: "system" |
POST /conversations/{id}/typing | user_id, typing (true or false) | {}; nothing is stored |
A message:
{"id": "...", "seq": 3, "sender": "ann", "name": "Ann", "kind": "text",
"text": "hi", "metadata": {"reply_to": 2}, "reactions": {"👍": ["support-bob"]}, "created_at": "..."}An edited message keeps its seq and gets edited_at. A deleted one keeps its place with deleted_at and no text, and can no longer be edited or reacted to (409). Reading after a number only returns newer messages, so a client that comes back re-reads the messages it shows (last=n) to pick up edits, deletes and reactions. Search matches any part of a word, ignoring case and full-width forms, so it works for Chinese, Japanese and Korean. System messages say what happened (joined, transferred, closed): only your server posts them, and they are never pushed or counted as unread.
Read receipts and unread counts
| Method and path | Body | Returns |
|---|---|---|
POST /conversations/{id}/read | user_id, seq | {read_seq} |
How far a member has read only moves forward and is capped at last_seq; the sender of a message has read it. unread in a user's conversation list counts the messages after their read_seq that are not system messages. Every member gets chat.read when someone's read_seq moves.
Browsers and apps: user tokens
Clients can call the Chat API themselves under /apps/APP_ID/chat/me, with Authorization: Bearer <user token> and no proxy on your server. The token is a JWT signed with the app secret (HS256, {sub, exp}, at most 30 days); your server makes one with any JWT library or with POST /tokens:
| Method and path | Body | Returns |
|---|---|---|
POST /tokens | user_id, conversation_id (limits the token to it), join (adds the user to it on first use), ttl (60–86400 s) | 201 {token, expires_at} |
With a token, a client lists its conversations (GET /me/conversations), reads, searches, sends, edits and deletes its own messages, reacts, marks read and sends typing. The user is always the token's sub. A client cannot create conversations, change members, set push options, mute or ban: those stay on your server. Each user may write at most 2 times a second, in bursts of up to 10. A token with conversation_id and join suits anonymous visitors of a support chat: your server creates the conversation, checks the visitor, then issues a short-lived token for an ID it makes up.
Attachments
- Your server asks for an upload:
POST /conversations/{id}/attachmentswithuser_id,name,content_typeandsize(up to 25 MB). The answer hasattachmentandupload(url,method,headers). - The client uploads the file straight to
upload.urlwithin 15 minutes, with exactly those headers. - Your server sends the message with
attachments: theid,nameandcontent_typefrom step 1 (up to 10 per message;textmay then be empty).
Each attachment read from the API or an event carries a url that downloads it for an hour. Uploads not sent within a day are removed, and deleting a message or a conversation deletes its files.
Push to offline members
A new message is pushed through the app's Hibira Push to members who are not connected (push: "offline", the default), to every member but the sender ("always"), or to nobody ("never"). The title is the sender's and the conversation's name (or push_title), the body the first 180 characters of the text, and push_url opens when it is tapped. Devices register with the Beams-compatible push SDKs under the same user IDs. The answer's push says how many members were pushed, or why none were.
Moderation
| Method and path | Body or query | Returns |
|---|---|---|
PUT /moderation/filter | words (up to 500), action (mask or reject) | {filter} |
POST /users/{user_id}/ban | reason, until (RFC 3339; leave out for no end) | {user_id, banned, until} |
DELETE /users/{user_id}/ban | {user_id, banned} | |
POST /conversations/{id}/members/{user_id}/mute | reason, until | {conversation_id, user_id, muted, until} |
DELETE /conversations/{id}/members/{user_id}/mute | {conversation_id, user_id, muted} | |
POST /conversations/{id}/messages/{seq}/reports | user_id, reason | 201 {report} |
GET /moderation/reports | limit (1–200) | {reports}, newest first |
The word filter checks every new or edited message, inside words and ignoring case and full-width forms, so it also catches Chinese and Japanese. mask replaces each match with *; reject answers 422 and stores nothing. A banned user gets 403 on send, edit, reactions and typing in every conversation of the app; a muted member gets 403 on send and typing in that conversation and can still read it.
Events and webhook
Events go to #server-to-user-<user_id> of each member concerned, and to the conversation's watchers. Each one counts as one message toward the daily quota.
| Event | To | Data |
|---|---|---|
chat.conversation | every member, when it is created or changed, or a member is added | {conversation} |
chat.message | every member | {conversation_id, message} |
chat.message_updated | every member, after an edit, a delete or a reaction | {conversation_id, message} |
chat.typing | every member but the one typing | {conversation_id, user_id, typing} |
chat.read | every member | {conversation_id, user_id, read_seq} |
chat.left | a member who was removed | {conversation_id} |
chat.deleted | every member of a deleted conversation | {conversation_id} |
const pusher = new Pusher('APP_KEY', { cluster: 'hkg', wsHost: 'ws.hibira.io', userAuthentication: { endpoint: '/pusher/user-auth' } });
pusher.signin();
pusher.user.bind('chat.message', ({ conversation_id, message }) => showMessage(conversation_id, message));Your server also gets the chat_message webhook for every new message: channel is the conversation ID, user_id the sender, and data the JSON {"conversation_id", "message"}. Add chat_message to the events of one of the app's webhooks to receive it.
Limits
| Conversation ID | 1–128 of A-Z a-z 0-9 _ - = @ , . ; |
| User ID | the same rules as pusher.signin user IDs |
| Members per conversation | 100 |
| Message text | 4,000 characters, not blank |
| Conversation or sender name | 100 characters |
metadata | a JSON object, at most 4,096 bytes |
| Messages per read | 200 |
| Reactions | 1–32 bytes each, 20 different per message |
| Attachments | 25 MB each, 10 per message |
Writes share the app's HTTP API rate limit (429 beyond it).
Monthly active users and retention
A user is active in a month (UTC) when they send a message or mark a conversation read, counted once across all of the account's apps. Every account includes 500 a month; the Chat add-ons raise it to 1,000, 5,000, 20,000 or 60,000 (plans). Once the month is full, a user not yet counted gets 429 on send and read until the next month; users already counted carry on. GET /usage answers {month, active_users, limit}.
Messages stay fully editable for 30 days. After that, accounts with a paid plan or a Chat add-on keep them in an archive that the API keeps returning (read-only); on the Free plan without an add-on they are deleted.
Using it from an AI agent
The Hibira MCP server has Chat API tools, so an AI coding agent can set up and test chat for you: create_conversation, send_message, list_conversations and list_messages. They act on your own apps with the same checks as a signed request, and need the account to have the Chat API. The agent plugin for Claude Code, Codex, Cursor and others adds a skill that wires the Chat API into a project with these steps. For a ready conversation view in React or plain JavaScript, see @hibira-io/chat-ui.