Returns server status.
curl -sS https://api.heyyo.dev/health
{
"status": "ok",
"version": "0.1.0",
"uptime": 123.45
}
Heyyo is a multi-tenant Chat-as-a-Service API: create an Application (tenant), issue user JWTs, then let users create/join channels and exchange real-time messages.
POST /api/apps is currently unauthenticated in this
codebase. In production, create apps via a dashboard or admin-only API.
Copy/paste this guide to go from zero → real-time messages. You’ll: create an app, issue a user token, create a channel, then send messages via REST and WebSocket.
https://heyyo-production-c881.up.railway.app
Tip: For local dev, use http://localhost:4000.
Create a tenant and capture api_key + api_secret.
curl -sS -X POST https://heyyo-production-c881.up.railway.app/api/apps \
-H 'Content-Type: application/json' \
-d '{"name":"Acme Chat"}'
Do this on your backend (never ship api_secret to browsers).
curl -sS -X POST https://heyyo-production-c881.up.railway.app/api/auth/token \
-H 'Content-Type: application/json' \
-d '{
"api_key":"cb_...",
"api_secret":"cbs_...",
"user_id":"user-123",
"display_name":"Jane Doe"
}'
Requires Node 18+ (global fetch). You’ll also install
socket.io-client.
mkdir cb-quickstart && cd cb-quickstart
npm init -y
npm i socket.io-client
# Set creds from POST /api/apps output:
export CB_API_KEY='cb_...'
export CB_API_SECRET='cbs_...'
node quickstart.mjs
import { io } from 'socket.io-client';
const BASE_URL = process.env.CB_BASE_URL ?? 'https://heyyo-production-c881.up.railway.app';
async function json(res) {
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function assertOk(res) {
if (res.ok) return;
const data = await json(res).catch(() => null);
const msg = data?.error || data?.message || `Request failed (${res.status})`;
throw new Error(msg);
}
async function main() {
const apiKey = process.env.CB_API_KEY;
const apiSecret = process.env.CB_API_SECRET;
if (!apiKey || !apiSecret) throw new Error('Missing CB_API_KEY / CB_API_SECRET');
// 1) Issue user JWT
const tokenRes = await fetch(`${BASE_URL}/api/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
api_secret: apiSecret,
user_id: 'user-123',
display_name: 'Jane Doe',
}),
});
await assertOk(tokenRes);
const { token } = await json(tokenRes);
// 2) Create channel
const channelRes = await fetch(`${BASE_URL}/api/channels`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name: 'general', type: 'messaging' }),
});
await assertOk(channelRes);
const channel = await json(channelRes);
// 3) Send message via REST
const msgRes = await fetch(`${BASE_URL}/api/channels/${channel.id}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text: 'Hello from REST 👋' }),
});
await assertOk(msgRes);
const msg = await json(msgRes);
console.log('REST message created:', msg.id);
// 4) Connect WebSocket and send a message
const socket = io(BASE_URL, { auth: { token } });
socket.on('connect', () => {
console.log('WS connected:', socket.id);
socket.emit('message.send', { channelId: channel.id, text: 'Hello from WebSocket ⚡' }, (ack) => {
if (ack?.error) console.error('WS send error:', ack.error);
else console.log('WS message ack:', ack.message?.id);
});
});
socket.on('message.new', (m) => {
if (m.channel_id !== channel.id) return;
console.log('WS message.new:', m.id, m.text);
});
socket.on('connect_error', (err) => {
console.error('WS connect_error:', err.message);
});
// exit after a short demo window
setTimeout(() => {
socket.disconnect();
console.log('done');
}, 3500);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
The SDK wraps REST + Socket.IO. Use apiUrl for the server origin.
import { HeyyoClient } from '@heyyo/sdk';
const client = new HeyyoClient({
apiUrl: 'https://heyyo-production-c881.up.railway.app',
token: '<USER_JWT>',
});
const channel = await client.channels.create('general', { type: 'messaging' });
// When websocket is connected, this uses WS; otherwise it falls back to REST.
const message = await client.messages.send(channel.id, 'Hello from the SDK');
client.on('message.new', (m) => {
console.log('message.new', m.id);
});
POST /api/apps is unauthenticated in this demo codebase. In
production, create apps via a dashboard or admin-only flow.
In a typical integration, your backend creates an application once, then issues short-lived JWTs for your end-users. Your front-end uses the JWT for all REST calls and WebSocket connections.
Get your api_key and api_secret (store the secret
securely).
curl -sS -X POST https://api.heyyo.dev/api/apps \
-H 'Content-Type: application/json' \
-d '{"name":"Acme Chat"}'
const resp = await fetch('https://api.heyyo.dev/api/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Acme Chat' }),
});
const app = await resp.json();
// app.api_key, app.api_secret (only returned at creation time)
console.log(app);
Your backend exchanges credentials + user_id for a user JWT.
curl -sS -X POST https://api.heyyo.dev/api/auth/token \
-H 'Content-Type: application/json' \
-d '{
"api_key":"cb_...",
"api_secret":"cbs_...",
"user_id":"user-123",
"display_name":"Jane Doe",
"avatar_url":"https://example.com/jane.png"
}'
// Your backend (recommended): keep api_secret off the client.
const resp = await fetch('https://api.heyyo.dev/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.CB_API_KEY,
api_secret: process.env.CB_API_SECRET,
user_id: 'user-123',
display_name: 'Jane Doe',
avatar_url: 'https://example.com/jane.png',
}),
});
const { token } = await resp.json();
return token;
Use the JWT as a Bearer token.
curl -sS https://api.heyyo.dev/api/channels \
-H 'Authorization: Bearer <USER_JWT>'
import { HeyyoClient } from '@heyyo/sdk';
const client = new HeyyoClient({
apiUrl: 'https://api.heyyo.dev',
token: '<USER_JWT>',
});
const channels = await client.channels.list();
console.log(channels);
SDK surface is illustrative. Adjust to your installed SDK version.
Heyyo has two authentication modes: server credentials for your backend, and user JWTs for clients.
Use your app’s api_key and api_secret to mint user JWTs.
Keep the secret on your server.
authenticateApiKey middleware using
X-API-Key, but it is not currently wired to routes.
For most REST endpoints, send:
Authorization: Bearer <USER_JWT>
Provide the JWT at connection time via auth.token.
import { io } from 'socket.io-client';
const socket = io('wss://api.heyyo.dev', {
auth: { token: '<USER_JWT>' },
});
socket.on('connect', () => {
console.log('connected', socket.id);
});
socket.on('connect_error', (err) => {
console.error('ws auth failed', err.message);
});
A typed client for REST and WebSocket workflows.
import { HeyyoClient } from '@heyyo/sdk';
const cb = new HeyyoClient({
apiUrl: 'https://api.heyyo.dev',
token: '<USER_JWT>',
});
const channel = await cb.channels.create('general', {
type: 'messaging',
description: 'Company-wide chat',
});
// Uses WebSocket when connected; falls back to REST.
await cb.messages.send(channel.id, 'Hello world');
Method names are illustrative; match the SDK you ship.
Drop-in components for channel lists, message streams, and typing indicators.
import { HeyyoProvider, ChannelList, ChatView } from '@heyyo/react';
export function App() {
return (
<HeyyoProvider
apiUrl="https://api.heyyo.dev"
token="<USER_JWT>"
>
<div style={{ display: 'grid', gridTemplateColumns: '320px 1fr', height: '100vh' }}>
<ChannelList />
<ChatView />
</div>
</HeyyoProvider>
);
}
All endpoints accept and return JSON. Set
Content-Type: application/json when sending a body.
https://api.heyyo.dev/api (placeholder).
Locally, the server runs at http://localhost:4000/api.
Returns server status.
curl -sS https://api.heyyo.dev/health
{
"status": "ok",
"version": "0.1.0",
"uptime": 123.45
}
Create a new application (tenant).
{ name: string }
Status 201
curl -sS -X POST https://api.heyyo.dev/api/apps \
-H 'Content-Type: application/json' \
-d '{"name":"Acme Chat"}'
const res = await fetch('https://api.heyyo.dev/api/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Acme Chat' }),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || `Failed (${res.status})`);
console.log('api_key:', data.api_key);
console.log('api_secret:', data.api_secret);
{
"name": "Acme Chat"
}
{
"id": "7b6b3f4f-...",
"name": "Acme Chat",
"api_key": "cb_...",
"api_secret": "cbs_...",
"created_at": "2026-01-31T...Z",
"message": "Store your API secret securely. It will not be shown again."
}
List applications (admin/demo).
curl -sS https://api.heyyo.dev/api/apps
[
{
"id": "uuid",
"name": "Acme Chat",
"api_key": "cb_...",
"created_at": "timestamp"
}
]
Generate a user JWT. Call this from your backend.
api_key, api_secret,
user_id
Status 200
curl -sS -X POST https://api.heyyo.dev/api/auth/token \
-H 'Content-Type: application/json' \
-d '{
"api_key":"cb_...",
"api_secret":"cbs_...",
"user_id":"user-123"
}'
const res = await fetch('https://api.heyyo.dev/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.CB_API_KEY,
api_secret: process.env.CB_API_SECRET,
user_id: 'user-123',
}),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error || `Failed (${res.status})`);
console.log('USER_JWT:', data.token);
{
"api_key": "cb_...",
"api_secret": "cbs_...",
"user_id": "user-123",
"display_name": "Jane Doe",
"avatar_url": "https://example.com/jane.png"
}
Note: display_name and avatar_url are accepted
but not persisted in the current implementation.
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Create a channel. The authenticated user becomes the owner.
Authorization: Bearer <USER_JWT>
Status 201
members expects internal user UUIDs, not
external IDs.
curl -sS -X POST https://api.heyyo.dev/api/channels \
-H 'Authorization: Bearer <USER_JWT>' \
-H 'Content-Type: application/json' \
-d '{"name":"general","type":"messaging"}'
{
"name": "general",
"type": "messaging",
"description": "Company-wide chat",
"metadata": { "team": "all" },
"members": ["<user_uuid>"]
}
{
"id": "uuid",
"app_id": "uuid",
"type": "messaging",
"name": "general",
"description": "Company-wide chat",
"metadata": { "team": "all" },
"created_by": "uuid",
"created_at": "timestamp",
"updated_at": "timestamp"
}
List channels the authenticated user is a member of.
curl -sS https://api.heyyo.dev/api/channels \
-H 'Authorization: Bearer <USER_JWT>'
[
{
"id": "uuid",
"name": "general",
"type": "messaging",
"member_count": "2",
"message_count": "18",
"updated_at": "timestamp"
}
]
member_count and message_count are returned as
strings in current SQL.
Discover/list all channels in the authenticated user’s application.
curl -sS https://api.heyyo.dev/api/channels/discover \
-H 'Authorization: Bearer <USER_JWT>'
[
{
"id": "uuid",
"name": "general",
"member_count": "10",
"created_at": "timestamp"
}
]
Get channel details, including members. Returns 404 if not found or
not a member.
curl -sS https://api.heyyo.dev/api/channels/<CHANNEL_ID> \
-H 'Authorization: Bearer <USER_JWT>'
{
"id": "uuid",
"name": "general",
"member_count": "2",
"members": [
{
"id": "uuid",
"external_id": "user-123",
"display_name": "Jane Doe",
"avatar_url": null,
"is_online": true,
"role": "owner",
"joined_at": "timestamp"
}
]
}
Join a channel (adds the authenticated user as member).
curl -sS -X POST https://api.heyyo.dev/api/channels/<CHANNEL_ID>/join \
-H 'Authorization: Bearer <USER_JWT>'
{
"message": "Joined channel"
}
Add a member to a channel by internal user_id.
curl -sS -X POST https://api.heyyo.dev/api/channels/<CHANNEL_ID>/members \
-H 'Authorization: Bearer <USER_JWT>' \
-H 'Content-Type: application/json' \
-d '{"user_id":"<user_uuid>"}'
{
"user_id": "<user_uuid>"
}
{
"message": "Member added"
}
List messages in a channel.
limit (≤ 100), before (timestamp)
Membership enforced (403)
curl -sS 'https://api.heyyo.dev/api/channels/<CHANNEL_ID>/messages?limit=50' \
-H 'Authorization: Bearer <USER_JWT>'
[
{
"id": "uuid",
"channel_id": "uuid",
"user_id": "uuid",
"text": "Hello",
"created_at": "timestamp",
"user_external_id": "user-123",
"user_display_name": "Jane Doe",
"user_avatar_url": null,
"reactions": [
{ "type": ":heart:", "user_id": "uuid", "count": 1 }
]
}
]
Note: list responses currently return flattened user fields (not nested
user).
Send a message via REST (fallback; primary method is WebSocket).
text or non-empty
attachments
Status 201
curl -sS -X POST https://api.heyyo.dev/api/channels/<CHANNEL_ID>/messages \
-H 'Authorization: Bearer <USER_JWT>' \
-H 'Content-Type: application/json' \
-d '{"text":"Hello from REST"}'
{
"text": "Hello world",
"attachments": [],
"metadata": { "client_ref": "abc" },
"parent_id": null
}
{
"id": "uuid",
"channel_id": "uuid",
"user_id": "uuid",
"text": "Hello from REST",
"attachments": [],
"metadata": {},
"parent_id": null,
"created_at": "timestamp",
"updated_at": "timestamp",
"deleted_at": null,
"user": {
"external_id": "user-123",
"display_name": "Jane Doe",
"avatar_url": null
}
}
Soft-delete a message. Only the sender can delete.
curl -sS -X DELETE https://api.heyyo.dev/api/channels/<CHANNEL_ID>/messages/<MESSAGE_ID> \
-H 'Authorization: Bearer <USER_JWT>'
{
"message": "Message deleted"
}
On failure:
404 {"error":"Message not found or unauthorized"}
Add a reaction to a message. Duplicate reactions (same user + type) are ignored.
curl -sS -X POST https://api.heyyo.dev/api/channels/<CHANNEL_ID>/messages/<MESSAGE_ID>/reactions \
-H 'Authorization: Bearer <USER_JWT>' \
-H 'Content-Type: application/json' \
-d '{"type":":thumbsup:"}'
{
"type": ":thumbsup:"
}
{
"message": "Reaction added"
}
After connecting with auth.token, the server joins the socket to rooms
for every channel the user belongs to (room name
channel:<channelId>), and broadcasts presence events.
Authentication required when missing a tokenInvalid token when the token fails verificationimport { io } from 'socket.io-client';
const socket = io('wss://api.heyyo.dev', {
auth: { token: '<USER_JWT>' },
transports: ['websocket'],
});
Send a message to a channel via WebSocket.
{
"channelId": "uuid",
"text": "string|null",
"attachments": [],
"metadata": {},
"parentId": "uuid|null"
}
socket.emit(
'message.send',
{ channelId, text: 'Hello from WS' },
(ack) => {
if (ack?.error) return console.error(ack.error);
console.log('sent', ack.message);
}
);
// success
{ "message": { /* Message */ } }
// error
{ "error": "Not a member of this channel" }
Start typing indicator in a channel.
{ "channelId": "uuid" }
Stop typing indicator in a channel.
{ "channelId": "uuid" }
Join a channel (adds member and joins room).
{ "channelId": "uuid" }
{ "success": true }
Add a reaction to a message.
{ "messageId": "uuid", "type": ":heart:" }
{ "success": true }
Emitted when a new message is created via WebSocket. Payload is a Message with
nested user.
socket.on('message.new', (message) => {
console.log('new message', message);
});
{
"id": "uuid",
"channel_id": "uuid",
"user_id": "uuid",
"text": "Hello from WS",
"created_at": "timestamp",
"user": {
"external_id": "user-123",
"display_name": "Jane Doe",
"avatar_url": null
}
}
Emitted to other channel members when a user starts/stops typing.
{
"channelId": "uuid",
"userId": "uuid",
"externalId": "user-123"
}
Broadcast when a user connects or disconnects.
{
"userId": "uuid",
"externalId": "user-123",
"status": "online"
}
Broadcast when a reaction is added.
{
"messageId": "uuid",
"userId": "uuid",
"externalId": "user-123",
"type": ":heart:"
}
Broadcast when a user joins a channel (via WebSocket join).
{
"channelId": "uuid",
"userId": "uuid",
"externalId": "user-123"
}
Current implementation returns minimal errors:
{ "error": "Human readable message" }
400 invalid request • 401 missing auth •
403 invalid/expired token or not a member • 404 not found
• 500 internal
GET /api/channels/:channelId/messages supports:
limit (default 50, max 100) and before (timestamp).
curl -sS 'https://api.heyyo.dev/api/channels/<CHANNEL_ID>/messages?limit=50&before=2026-01-31T00:00:00.000Z' \
-H 'Authorization: Bearer <USER_JWT>'
Messages are returned ordered ascending by created_at.