Skip to content

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.

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

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

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.

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.

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_pat cookie. Browsers on ecency.com send 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.

  1. Your app
  2. Chat API
  3. Websocket
  1. Your app: Get a Hive access token
  2. Your app to Chat API: POST /bootstrap
  3. Chat API: Check token with HiveSigner
  4. Chat API to Your app: 200 with chat token
  5. Your app to Chat API: REST call + X‑MM‑Token header
  6. Your app to Websocket: Connect with Bearer token
  7. Websocket to Your app: hello event
Sign in once with bootstrap, then send the chat token as X-MM-Token on REST calls and as a Bearer token on the websocket.

All paths are relative to https://ecency.com/api/mattermost. Request and response bodies are JSON. Errors look like { "error": "..." }.

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 }.
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:

  • props keeps only these string fields: parent_id, parent_username, parent_message (reply context) and link_url, link_title, link_summary, link_image (link preview). Anything else is dropped.
  • pendingPostId is your own id for the message. It comes back on the created post and on the websocket posted event, so you can match it to your optimistic copy.
  • A banned user gets 403 with bannedUntil and reason. Channel-wide mentions (such as @all) are refused with 403 for users who aren’t allowed to use them.
  • If the user starts conversations with too many people in a short time, the route answers 429 with Retry-After.
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.

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 Origin header, it must be on the allowlist. That list holds https://ecency.com, local development origins and any extra origins the server operator adds with MATTERMOST_WS_ALLOWED_ORIGINS. Any other origin is closed with code 1008 (“Origin not allowed”). Clients that send no Origin (most native apps) are allowed.
  • Token. The proxy reads the token from Authorization: Bearer <token>, then the ?token= query parameter, then the mm_pat cookie. Browser WebSocket can’t set headers, so use ?token= there. The X-MM-Token header is not read here. With no token the socket opens and is then closed with 1008 (“Unauthorized”).
  • Plain HTTP. A normal GET without an upgrade gets 426 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.
});

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.post is a JSON string. Parse it to read message, root_id (thread parent) and pending_post_id.
  • post_edited and post_deleted: a message changed or was removed. Use post.id to find it.
  • reaction_added and reaction_removed: a reaction changed. The reaction has user_id, post_id and emoji_name.
  • typing: someone is typing. Includes user_id and parent_id for threads. It has no post.
  • 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" }
}
  1. After the user signs in, get a Hive access token and call POST /bootstrap once.
  2. Store the returned token securely for the session.
  3. Send X-MM-Token on every REST call. Bootstrap again on 401.
  4. Open the websocket with ?token= (or Authorization: Bearer where you can set headers).
  5. Handle 403 with bannedUntil by showing when the user can chat again.
  6. Retry 503 and 504 with backoff and honor Retry-After.

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_SMTPUSERNAME: [email protected]
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.