Ecency chats
Use Ecency's hosted chat API and websocket from your Hive app, or run the same stack on your own Mattermost server.
Ecency chat runs on Mattermost. The Ecency web app wraps it in a small API under /api/mattermost/* and a websocket proxy, so Hive apps can offer chat without running any chat servers. This page explains how to sign a user in and which routes you can call. It also shows how to self-host the same setup.
How it works
Section titled “How it works”- Hive identity. A user signs in with a Hive access token. The bootstrap route checks that token with HiveSigner and creates (or finds) a chat user with the same Hive username.
- Personal chat token. Bootstrap returns a chat token for that user. Every other route uses it to act as the user.
- Communities become channels. Each Hive community maps to a public channel named after the community id (for example
hive-123). Bootstrap joins the user to channels for the communities they subscribe to, unless they left a channel on purpose. - Realtime. A websocket proxy streams Mattermost events (new posts, reactions, typing) to the client.
Who can call the API
Section titled “Who can call the API”The REST routes send no CORS headers, so a web page on another origin can’t call them from the browser. These clients work:
- Native apps (mobile apps or other native clients you build)
- Browser extensions that have host permission for
ecency.com - Your own server, acting for the user
The websocket checks the Origin header instead (see Realtime websocket).
Authentication
Section titled “Authentication”Step 1: get a Hive access token
Section titled “Step 1: get a Hive access token”Bootstrap needs a HiveSigner-compatible accessToken for the user. If your app uses HiveSigner login, use the access token it gives you. If the user signs in with a posting key, you can build the token yourself by signing a small JSON message. This example uses the Ecency SDK’s own crypto:
import { PrivateKey, sha256 } from "@ecency/sdk";
function buildAccessToken(appAccount: string, username: string, postingWif: string): string { const payload: Record<string, unknown> = { signed_message: { type: "posting", app: appAccount }, authors: [username], timestamp: Math.floor(Date.now() / 1000), };
// Sign the exact JSON string with the user's posting key. const hash = sha256(JSON.stringify(payload)); const signature = PrivateKey.fromString(postingWif).sign(hash).toString();
payload.signatures = [signature]; return Buffer.from(JSON.stringify(payload)).toString("base64url");}Set app to your own app’s Hive account. With Keychain or HiveAuth, ask the wallet to sign the same JSON string with the posting key and attach the signature it returns.
Step 2: bootstrap
Section titled “Step 2: bootstrap”const res = await fetch("https://ecency.com/api/mattermost/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "alice", accessToken, community: "hive-123", // optional: also join this community channel }),});const { ok, userId, channelId, token } = await res.json();Request body:
| Field | Required | Notes |
|---|---|---|
username |
Yes | Hive username. Must match the account in accessToken. |
accessToken |
Yes | HiveSigner-compatible access token (see step 1). |
displayName |
No | Used as a fallback channel title. |
community |
No | Community id to join. This also clears an earlier “left” mark for that channel. |
communityTitle |
No | Title used if the community channel has to be created. |
A refresh token is not used. If bootstrap rejects the access token (for example because the user changed the key that signed it), get a new access token in your app and call bootstrap again.
Success (200) returns { ok: true, userId, channelId, token } and sets an httpOnly mm_pat cookie on ecency.com that lasts 30 days. channelId is set only when you passed community.
Bootstrap is idempotent and safe to repeat, but call it once per session and reuse the token. Call it again only when a route answers 401.
Status codes:
| Status | Meaning | What to do |
|---|---|---|
400 |
Invalid JSON or missing username |
Fix the request. |
401 |
Access token missing or invalid, or it belongs to a different account | Get a new access token and retry. |
403 |
The user is banned from chat. The body has bannedUntil (epoch ms) and, when known, reason. |
Show the user when the ban ends. Don’t retry before then. |
499 |
The client disconnected before bootstrap finished | Nothing; retry if you still need it. |
500 |
Unexpected server error | Retry later. |
502 |
Chat service unavailable | Retry later with backoff. |
503 |
Temporary problem (for example the token check or chat service is busy) | Retry. Honor Retry-After when it’s present. |
504 |
Bootstrap or the chat service timed out | Retry with backoff. Honor Retry-After when it’s present. |
503 and 504 are temporary. Don’t sign the user out because of them.
Step 3: send the token on every call
Section titled “Step 3: send the token on every call”Every route below needs the chat token. Send it one of two ways:
X-MM-Token: <token>header. Use this in native apps, extensions and servers.mm_patcookie. Browsers onecency.comsend it automatically.
The REST routes don’t read Authorization: Bearer. Bearer only works on the websocket.
const channels = await fetch("https://ecency.com/api/mattermost/channels", { headers: { "X-MM-Token": token },}).then((r) => r.json());A missing or expired token returns 401 { "error": "unauthorized" }. Bootstrap again to get a new one.
- Your app
- Chat API
- Websocket
- Your app: Get a Hive access token
- Your app to Chat API: POST /bootstrap
- Chat API: Check token with HiveSigner
- Chat API to Your app: 200 with chat token
- Your app to Chat API: REST call + X‑MM‑Token header
- Your app to Websocket: Connect with Bearer token
- Websocket to Your app: hello event
API routes
Section titled “API routes”All paths are relative to https://ecency.com/api/mattermost. Request and response bodies are JSON. Errors look like { "error": "..." }.
Channels
Section titled “Channels”| Method and path | Body or query | Description |
|---|---|---|
GET /channels |
The user’s channels with favorite and mute flags, unread counts and direct-message partner details. | |
GET /channels/unreads |
Unread and mention counts across channels. | |
POST /channels/search |
{ term, public?, private? } |
Search channels by name. Terms shorter than 2 characters return { channels: [] }. |
POST /channels/[id]/join |
Join a channel. | |
POST /channels/[id]/leave |
Leave a channel. If it’s a community channel, bootstrap won’t rejoin it automatically. | |
POST /channels/[id]/favorite |
{ favorite: boolean } |
Add or remove from favorites. |
POST /channels/[id]/mute |
{ mute: boolean } |
Mute (mentions only) or unmute. |
POST /channels/[id]/view |
Mark the channel as read. | |
GET /channels/[id]/members |
Mattermost paging query, such as ?page=0&per_page=60 |
Channel members, returned as { members }. |
PUT /channels/[id]/name |
{ name } |
Name a group conversation, or clear the name with "". Only the person who started the group can do this. |
GET /channels/[id]/pinned |
Pinned posts as { posts, users }. |
Messages
Section titled “Messages”| Method and path | Body or query | Description |
|---|---|---|
GET /channels/[id]/posts |
before, around, thread, per_page (default 40), include_online=1 |
Posts plus a user map, channel, membership, community, moderation flags, hasMore and memberCount. |
POST /channels/[id]/posts |
{ message, rootId?, props?, pendingPostId? } |
Send a message, or a reply when rootId is set. Returns { post }. |
PATCH /channels/[id]/posts/[postId] |
{ message } |
Edit your own message. |
DELETE /channels/[id]/posts/[postId] |
Delete your own message. Community moderators can delete any message in their community channel. | |
POST /channels/[id]/posts/[postId]/reactions |
{ emoji } |
Add a reaction. Returns the reaction. |
DELETE /channels/[id]/posts/[postId]/reactions |
{ emoji } |
Remove your reaction. |
POST /channels/[id]/posts/[postId]/pin |
Pin a message. In community channels only moderators can pin. A channel holds at most 5 pinned messages. | |
DELETE /channels/[id]/posts/[postId]/pin |
Unpin a message. | |
POST /search/posts |
{ term } |
Search messages. Returns { posts }. |
Notes on sending messages:
propskeeps only these string fields:parent_id,parent_username,parent_message(reply context) andlink_url,link_title,link_summary,link_image(link preview). Anything else is dropped.pendingPostIdis your own id for the message. It comes back on the created post and on the websocketpostedevent, so you can match it to your optimistic copy.- A banned user gets
403withbannedUntilandreason. Channel-wide mentions (such as@all) are refused with403for users who aren’t allowed to use them. - If the user starts conversations with too many people in a short time, the route answers
429withRetry-After.
Direct messages and groups
Section titled “Direct messages and groups”| Method and path | Body | Description |
|---|---|---|
POST /direct |
{ username } |
Open or create a direct message with a Hive user. Returns { channelId }. |
POST /group |
{ usernames: string[] } |
Open or create a group conversation with 2 to 7 other people. Returns { channelId }. |
GET /me/dm-privacy |
The user’s direct-message setting: { privacy }. |
|
PUT /me/dm-privacy |
{ privacy: "all" | "followers" | "none" } |
Choose who can message the user. |
/direct and /group return 404 when someone isn’t on Ecency chat (or isn’t a Hive account) and 403 when someone’s privacy setting doesn’t allow the message. The 403 body explains who and why.
| Method and path | Body or query | Description |
|---|---|---|
GET /users/search?q= |
Search users for mentions and new messages. Returns { users }. |
|
POST /users/ids |
{ ids: string[] } |
Look up up to 200 user ids, for example a user you first see in a websocket event. Unknown ids are left out. Returns { users }. |
GET /users/[userId]/image |
The user’s profile image. Needs the token like every other route. |
Realtime websocket
Section titled “Realtime websocket”Connect to wss://ecency.com/api/mattermost/websocket. The proxy opens a connection to Mattermost and sends the authentication challenge for you. Then it forwards frames in both directions.
How the proxy checks a connection:
- Origin. If the request has an
Originheader, it must be on the allowlist. That list holdshttps://ecency.com, local development origins and any extra origins the server operator adds withMATTERMOST_WS_ALLOWED_ORIGINS. Any other origin is closed with code1008(“Origin not allowed”). Clients that send noOrigin(most native apps) are allowed. - Token. The proxy reads the token from
Authorization: Bearer <token>, then the?token=query parameter, then themm_patcookie. BrowserWebSocketcan’t set headers, so use?token=there. TheX-MM-Tokenheader is not read here. With no token the socket opens and is then closed with1008(“Unauthorized”). - Plain HTTP. A normal
GETwithout an upgrade gets426 Upgrade Required. - Upstream problems close the socket with
1011.
The route runs on the Node.js runtime.
const socket = new WebSocket( `wss://ecency.com/api/mattermost/websocket?token=${encodeURIComponent(token)}`);
socket.addEventListener("message", (event) => { const frame = JSON.parse(event.data as string);
if (frame.event === "hello") { // Authenticated. Mattermost is ready to send events. }
if (frame.event === "posted") { const post = JSON.parse(frame.data.post); // post is a JSON string // Add post to the channel (or to a thread when post.root_id is set). }});
socket.addEventListener("close", (event) => { if (event.code === 1008) { // Bad origin or missing/expired token: bootstrap again, then reconnect. } // Otherwise reconnect with backoff.});Events you’ll use most
Section titled “Events you’ll use most”Each frame is a JSON object with event, data and broadcast. The full list is in the Mattermost websocket reference.
hello: sent once after authentication succeeds.posted: a new message or reply.data.postis a JSON string. Parse it to readmessage,root_id(thread parent) andpending_post_id.post_editedandpost_deleted: a message changed or was removed. Usepost.idto find it.reaction_addedandreaction_removed: a reaction changed. The reaction hasuser_id,post_idandemoji_name.typing: someone is typing. Includesuser_idandparent_idfor threads. It has nopost.user_updated: a profile change, for example a new avatar.
broadcast.channel_id tells you which channel the event belongs to.
Example posted frame, trimmed:
{ "event": "posted", "seq": 4, "data": { "channel_display_name": "hive-123", "channel_type": "O", "post": "{\"id\":\"abc123\",\"channel_id\":\"...\",\"message\":\"hello world\",\"root_id\":\"\"}", "sender_name": "alice" }, "broadcast": { "channel_id": "CHANNEL_ID" }}Integration checklist
Section titled “Integration checklist”- After the user signs in, get a Hive access token and call
POST /bootstraponce. - Store the returned
tokensecurely for the session. - Send
X-MM-Tokenon every REST call. Bootstrap again on401. - Open the websocket with
?token=(orAuthorization: Bearerwhere you can set headers). - Handle
403withbannedUntilby showing when the user can chat again. - Retry
503and504with backoff and honorRetry-After.
Self-hosting
Section titled “Self-hosting”You only need this if you run your own copy of the Ecency web app. Apps that call ecency.com/api/mattermost don’t need any of it.
Set these variables for the web app (see apps/web/.env.template in the vision-web repo):
| Variable | Description |
|---|---|
MATTERMOST_BASE_URL |
Mattermost REST base, for example https://chat.example.com/api/v4. The websocket URL is derived from it. |
MATTERMOST_ADMIN_TOKEN |
Admin personal access token. It creates users, teams and channels. It never leaves the server. |
MATTERMOST_TEAM_ID |
The team that holds the chat channels. |
MATTERMOST_WS_ALLOWED_ORIGINS |
Optional. Extra origins allowed to open the websocket, comma-separated, for example https://app.example.com,https://example.com. |
A minimal Mattermost Team Edition setup with Postgres and Traefik for TLS:
services: db: image: postgres:15 restart: always environment: POSTGRES_USER: mattermost POSTGRES_PASSWORD: change_me POSTGRES_DB: mattermost volumes: - db_data:/var/lib/postgresql/data
mattermost: image: mattermost/mattermost-team-edition:10.11 restart: always depends_on: - db environment: MM_SQLSETTINGS_DRIVERNAME: postgres MM_SQLSETTINGS_DATASOURCE: postgres://mattermost:change_me@db:5432/mattermost?sslmode=disable&connect_timeout=10 MM_SERVICESETTINGS_SITEURL: https://chat.example.com MM_SERVICESETTINGS_LISTENADDRESS: :8065 MM_LOGSETTINGS_ENABLECONSOLE: "true" MM_FILESETTINGS_DRIVERNAME: local MM_FILESETTINGS_DIRECTORY: /mattermost/data MM_EMAILSETTINGS_SMTPSERVER: smtp.example.com MM_EMAILSETTINGS_SMTPPORT: "587" MM_EMAILSETTINGS_SMTPPASSWORD: change_me MM_EMAILSETTINGS_ENABLESMTPAUTH: "true" MM_EMAILSETTINGS_ENABLESECURESMTP: "true" MM_EMAILSETTINGS_REQUIREEMAILVERIFICATION: "false" volumes: - app_data:/mattermost/data labels: - "traefik.enable=true" - "traefik.http.routers.mm.rule=Host(`chat.example.com`)" - "traefik.http.routers.mm.entrypoints=websecure" - "traefik.http.routers.mm.tls=true" - "traefik.http.routers.mm.tls.certresolver=le" - "traefik.http.services.mm.loadbalancer.server.port=8065"
reverse-proxy: image: traefik:v3.0 restart: always command: - "--providers.docker=true" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.le.acme.httpchallenge=true" - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web" - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik_data:/letsencrypt
volumes: db_data: app_data: traefik_data:Replace every example.com value and every change_me password with your own. Then create a team and an admin personal access token in Mattermost. Put both into the web app’s environment.