/ ⌘C REST API

Heyyo API

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.

REST Base URL https://api.heyyo.dev/api
WebSocket wss://api.heyyo.dev (Socket.IO)
Auth API key + secret (server), JWT (users)
Demo note: POST /api/apps is currently unauthenticated in this codebase. In production, create apps via a dashboard or admin-only API.

Quickstart (5 minutes)

Copy/paste this guide to go from zeroreal-time messages. You’ll: create an app, issue a user token, create a channel, then send messages via REST and WebSocket.

Production URL: https://heyyo-production-c881.up.railway.app Tip: For local dev, use http://localhost:4000.
1) Create an app

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"}'
2) Issue a user JWT

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"
  }'
3) Full working Node.js example (REST + WebSocket)

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);
});
4) SDK usage (@heyyo/sdk)

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);
});
Note: POST /api/apps is unauthenticated in this demo codebase. In production, create apps via a dashboard or admin-only flow.

Getting Started

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.

1) Create an application

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);
2) Issue a user token (JWT)

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;
3) Make your first authenticated request

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.

Authentication

Heyyo has two authentication modes: server credentials for your backend, and user JWTs for clients.

Server authentication (API key + secret)

Use your app’s api_key and api_secret to mint user JWTs. Keep the secret on your server.

The codebase also contains authenticateApiKey middleware using X-API-Key, but it is not currently wired to routes.

User authentication (JWT)

For most REST endpoints, send: Authorization: Bearer <USER_JWT>

WebSocket authentication (Socket.IO)

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);
});

SDK & React

@heyyo/sdk

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.

@heyyo/react

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>
  );
}

REST API Reference

All endpoints accept and return JSON. Set Content-Type: application/json when sending a body.

Base URL used in examples: https://api.heyyo.dev/api (placeholder). Locally, the server runs at http://localhost:4000/api.

Health

GET /health
No auth

Returns server status.

curl -sS https://api.heyyo.dev/health
{
  "status": "ok",
  "version": "0.1.0",
  "uptime": 123.45
}

Apps

POST /api/apps
No auth (demo)

Create a new application (tenant).

Body { 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."
}
GET /api/apps
No auth (demo)

List applications (admin/demo).

curl -sS https://api.heyyo.dev/api/apps
[
  {
    "id": "uuid",
    "name": "Acme Chat",
    "api_key": "cb_...",
    "created_at": "timestamp"
  }
]

Auth

POST /api/auth/token
Server creds

Generate a user JWT. Call this from your backend.

Body 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..."
}

Channels

POST /api/channels
Bearer JWT

Create a channel. The authenticated user becomes the owner.

Auth Authorization: Bearer <USER_JWT> Status 201
Current behavior: 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"
}
GET /api/channels
Bearer JWT

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.

GET /api/channels/discover
Bearer JWT

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 /api/channels/:channelId
Bearer JWT

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"
    }
  ]
}
POST /api/channels/:channelId/join
Bearer JWT

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"
}
POST /api/channels/:channelId/members
Bearer JWT

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"
}

Messages

GET /api/channels/:channelId/messages
Bearer JWT

List messages in a channel.

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

POST /api/channels/:channelId/messages
Bearer JWT

Send a message via REST (fallback; primary method is WebSocket).

Validation must provide 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
  }
}
DELETE /api/channels/:channelId/messages/:messageId
Bearer JWT

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"}

POST /api/channels/:channelId/messages/:messageId/reactions
Bearer JWT

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"
}

WebSocket (Socket.IO)

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.

Connect errors:
  • Authentication required when missing a token
  • Invalid token when the token fails verification
import { io } from 'socket.io-client';

const socket = io('wss://api.heyyo.dev', {
  auth: { token: '<USER_JWT>' },
  transports: ['websocket'],
});

Client → Server events

emit message.send
Ack

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" }
emit typing.start
No ack

Start typing indicator in a channel.

{ "channelId": "uuid" }
emit typing.stop
No ack

Stop typing indicator in a channel.

{ "channelId": "uuid" }
emit channel.join
Ack

Join a channel (adds member and joins room).

{ "channelId": "uuid" }
{ "success": true }
emit reaction.add
Ack

Add a reaction to a message.

{ "messageId": "uuid", "type": ":heart:" }
{ "success": true }

Server → Client events

on message.new
broadcast

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
  }
}
on typing.start / typing.stop
presence

Emitted to other channel members when a user starts/stops typing.

{
  "channelId": "uuid",
  "userId": "uuid",
  "externalId": "user-123"
}
on user.presence
broadcast

Broadcast when a user connects or disconnects.

{
  "userId": "uuid",
  "externalId": "user-123",
  "status": "online"
}
on reaction.new
broadcast

Broadcast when a reaction is added.

{
  "messageId": "uuid",
  "userId": "uuid",
  "externalId": "user-123",
  "type": ":heart:"
}
on channel.member_added
broadcast

Broadcast when a user joins a channel (via WebSocket join).

{
  "channelId": "uuid",
  "userId": "uuid",
  "externalId": "user-123"
}

Errors

Current implementation returns minimal errors:

{ "error": "Human readable message" }
Common HTTP status codes

400 invalid request • 401 missing auth • 403 invalid/expired token or not a member • 404 not found • 500 internal

Pagination

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.