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

Source: https://hibira.io/chat-api

## 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](https://hibira.io/chat) 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

1. Your server creates a conversation and its members (`PUT /conversations/{id}`).
2. 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.
3. 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 sends `chat.message` to every member and calls your `chat_message` webhook.
4. 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:

```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:

```json
{"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:

```json
{"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

1. Your server asks for an upload: `POST /conversations/{id}/attachments` with `user_id`, `name`, `content_type` and `size` (up to 25 MB). The answer has `attachment` and `upload` (`url`, `method`, `headers`).
2. The client uploads the file straight to `upload.url` within 15 minutes, with exactly those headers.
3. Your server sends the message with `attachments`: the `id`, `name` and `content_type` from step 1 (up to 10 per message; `text` may 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}` |

```js
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](https://hibira.io/chat)). 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](https://hibira.io/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](https://hibira.io/guides/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](https://hibira.io/guides/chat-ui).
