Integration guide

Put realtime rooms
in another website.

Connect a separate frontend to your Roomly deployment. Subscribe to presence, receive user behaviour, and publish your own events.

Production shapePOST /api/tokenWSS  /api/ws

One HTTP endpoint. One socket endpoint. No vendor SDK required.

00

How the integration works

Your website authenticates its user and checks room access on its own backend. That backend uses a private API key to request a short-lived Roomly token. The browser receives only that token and uses it to open the WebSocket.

01Browser

Requests room access from its own website backend.

02App backend

Authenticates the user and checks room permission.

03Roomly API

Verifies the private server key and signs a room token.

04WebSocket

Streams presence and behaviour to every room member.

01

Create server-to-server trust

Generate separate values for the WebSocket signing secret and the server API key. Store ROOMLY_API_KEY in both deployments as a server-only variable. Add every browser origin that may open a socket; origins include the scheme and host with no trailing path. For the usage dashboard, configure a GitHub OAuth app with /api/auth/callback/github as its callback and set the one verified email that may sign in.

Roomly · environment variables
# Roomly deployment environment
REALTIME_SECRET=<at-least-32-random-characters>
ROOMLY_API_KEY=<a-different-32-character-random-value>
REDIS_URL=<your-upstash-redis-url>
ALLOWED_ORIGINS=https://www.example.com,https://preview.example.com

# GitHub-protected /dashboard
AUTH_SECRET=<another-random-value>
AUTH_GITHUB_ID=<github-oauth-client-id>
AUTH_GITHUB_SECRET=<github-oauth-client-secret>
ROOMLY_ADMIN_EMAIL=<one-verified-github-email>
Neither secret belongs in browser code.

Never prefix REALTIME_SECRET or ROOMLY_API_KEY with NEXT_PUBLIC_, VITE_, or similar. Production fails closed when the API key is absent or shorter than 32 characters.

Local development permits anonymous token requests only when ROOMLY_API_KEY is unset. Any production runtime fails closed without a valid key.

02

Protect the dashboard with GitHub

Register a GitHub OAuth app against the stable origin that serves Roomly. GitHub OAuth apps accept one callback URL, so create a separate app for another domain or environment.

01

In the GitHub account that may use the dashboard, open your profile picture → Settings Emails. Make sure the address used for ROOMLY_ADMIN_EMAIL is present and verified. It may remain private and does not need to be primary.

02

Open Settings Developer settings OAuth appsNew OAuth App. GitHub may label the button Register a new application for the first app. Do not choose GitHub Apps; those apps use a different permission model.

03

Enter the exact production origin in the homepage and callback fields. Leave Enable Device Flow off, then select Register application.

GitHub · new OAuth app
Application name: Roomly dashboard
Homepage URL: https://roomly.example.com
Authorization callback URL:
https://roomly.example.com/api/auth/callback/github
04

Copy the Client ID, select Generate a new client secret, and copy the secret immediately. Save them in Vercel as AUTH_GITHUB_ID and AUTH_GITHUB_SECRET.

05

Generate AUTH_SECRET with openssl rand -base64 32. In the same Vercel environment, set ROOMLY_ADMIN_EMAIL to the exact verified GitHub email address.

06

Redeploy, open /dashboard, and select Continue with GitHub. Roomly requests read-only profile and email access, then admits only the configured verified address.

Using a GitHub App instead?

In the GitHub App's Permissions & events settings, set Account permissions → Email addresses to Read-only, save, and reauthorize the app. GitHub App tokens use these fine-grained permissions; the OAuth user:email scope does not grant that access.

Keep both secrets server-side. Never expose AUTH_GITHUB_SECRET or AUTH_SECRET through a NEXT_PUBLIC_ variable. Environment changes apply only after a new Vercel deployment.

See GitHub's OAuth app setup and email verification instructions.

03

Add an authenticated token proxy

Put this route in the consuming website, not in Roomly. It trusts the website's existing session, ignores any browser-supplied user identity, checks application-specific room permission, and calls Roomly with the private server key.

Consuming website · server environment
# Consuming website: server-only environment
ROOMLY_URL=https://realtime.example.com
ROOMLY_API_KEY=<the-same-private-key-configured-in-roomly>
app/api/roomly-token/route.ts
import { auth } from "@/auth";
import { canUserJoinRoom } from "@/lib/permissions";

export async function POST(request: Request) {
  const session = await auth();
  if (!session?.user?.id) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { room } = await request.json();
  if (
    typeof room !== "string" ||
    !(await canUserJoinRoom(session.user.id, room))
  ) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  const response = await fetch(`${process.env.ROOMLY_URL}/api/token`, {
    method: "POST",
    cache: "no-store",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.ROOMLY_API_KEY}`,
    },
    body: JSON.stringify({
      room,
      user: {
        id: session.user.id,
        name: session.user.name ?? "Anonymous",
      },
    }),
  });

  return new Response(await response.text(), {
    status: response.status,
    headers: { "content-type": "application/json", "cache-control": "no-store" },
  });
}
Adapt the two application-specific imports.

Replace auth() and canUserJoinRoom()with the consuming repository's authentication and authorization code. The important rule is that the server derives the user and approves the room.

04

Add the browser client

Roomly is not published as an npm package yet. Copy lib/realtime/client.ts and lib/realtime/protocol.ts from this repository into the consuming project, then adjust the protocol import path. The client has no browser runtime dependencies.

Consuming repository
# Copy the browser client into the consuming repository
lib/roomly/client.ts
lib/roomly/protocol.ts

# Consuming website: safe to expose to the browser
NEXT_PUBLIC_ROOMLY_URL=https://realtime.example.com

The wrapper handles token requests, heartbeat messages, reconnects with backoff, typed listeners, and the local member list.

05

Connect a user to a room

Room names and event names may contain letters, numbers, dots, dashes, underscores, and colons, up to 64 characters. A room is created when its first connection joins and disappears when its last member leaves.

room.ts
import { RealtimeRoom } from "@/lib/roomly/client";

const baseUrl = process.env.NEXT_PUBLIC_ROOMLY_URL!;
const websocketUrl = new URL("/api/ws", baseUrl);
websocketUrl.protocol = websocketUrl.protocol === "https:" ? "wss:" : "ws:";

const room = new RealtimeRoom({
  room: "document:42",
  user: {
    id: currentUser.id,
    name: currentUser.name,
    data: { avatarUrl: currentUser.avatarUrl },
  },
  // Same-origin route in the consuming website. It authenticates the user,
  // checks room permission, and calls Roomly from the server.
  authUrl: "/api/roomly-token",
  websocketUrl: websocketUrl.toString(),
});

await room.connect();
Use a stable application user ID.

Roomly creates a separate connectionId for each browser tab or device, so one user may appear more than once.

06

Listen for presence and events

Register listeners before calling connect() so the initial snapshot cannot be missed. Each on() call returns an unsubscribe function.

room.ts
const stopSnapshot = room.on("room.snapshot", ({ members }) => {
  renderMembers(members);
});

const stopJoined = room.on("member.joined", ({ member }) => {
  showToast(`${member.name} joined`);
  renderMembers(room.getMembers());
});

const stopLeft = room.on("member.left", ({ member, reason }) => {
  showToast(`${member.name} left: ${reason}`);
  renderMembers(room.getMembers());
});

const stopEvents = room.on("event", ({ name, data, member }) => {
  if (name === "cursor.moved") {
    moveRemoteCursor(member.id, data);
  }
});
room.snapshot

The complete member list immediately after joining.

member.joined

A connection became present in this room.

member.left

A connection left, disconnected, or timed out.

event

A named behaviour event published by a member.

connection.state

Connecting, connected, reconnecting, or closed.

error

A protocol, authentication, size, or rate error.

07

Publish user behaviour

Events are live and room-scoped. Every connected member, including the sender, receives the event with its publisher and timestamp.

room.ts
room.publish("cursor.moved", { x: 42, y: 18 });
room.publish("document.saved", { version: 7 });
room.publish("typing.changed", { typing: true });

Use domain-specific names such as cursor.moved, typing.changed, or game.player-jumped. Event delivery is at most once and is not stored for replay.

08

Leave cleanly

Disconnect when a view unmounts or the active room changes. This emits member.left immediately instead of waiting for the 45-second presence timeout.

room.ts
// Run this when the page unmounts, the user signs out,
// or they switch to another room.
stopSnapshot();
stopJoined();
stopLeft();
stopEvents();
room.disconnect();
React lifecycle example
use-room.ts
useEffect(() => {
  const room = new RealtimeRoom({
    room: roomId,
    user: { id: user.id, name: user.name },
    authUrl: "/api/roomly-token",
    websocketUrl: ROOMLY_WEBSOCKET_URL,
  });

  const stop = room.on("room.snapshot", ({ members }) => {
    setMembers(members);
  });

  void room.connect().catch(setConnectionError);

  return () => {
    stop();
    room.disconnect();
  };
}, [roomId, user.id, user.name]);
09

Raw protocol reference

Use the wrapper above for normal applications. If the consuming repo uses another language or needs its own transport layer, these are the complete endpoints and client messages.

POST/api/token

Server-only Bearer key exchange for a five-minute token.

WSS/api/ws?token=…

Open the realtime room connection.

GET/api/health

Check service and Redis configuration status.

Raw browser WebSocket
// The browser calls your authenticated, same-origin token proxy.
// ROOMLY_API_KEY is never sent to the browser.
const tokenResponse = await fetch("/api/roomly-token", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ room: "document:42" }),
});

const { token } = await tokenResponse.json();
const socket = new WebSocket(
  `wss://realtime.example.com/api/ws?token=${encodeURIComponent(token)}`,
);

socket.addEventListener("message", ({ data }) => {
  const message = JSON.parse(data);
  console.log(message.type, message);
});

await new Promise((resolve, reject) => {
  socket.addEventListener("open", resolve, { once: true });
  socket.addEventListener("error", reject, { once: true });
});

const heartbeat = window.setInterval(() => {
  socket.send(JSON.stringify({ type: "heartbeat" }));
}, 15_000);

// Publish
socket.send(JSON.stringify({
  type: "event",
  name: "cursor.moved",
  data: { x: 42, y: 18 },
}));

// Leave
window.clearInterval(heartbeat);
socket.send(JSON.stringify({ type: "leave" }));
socket.close();
Client messageShapePurpose
heartbeat{ type: "heartbeat" }Refresh presence at least every 45 seconds.
event{ type, name, data }Broadcast behaviour to the current room.
leave{ type: "leave" }Remove presence before closing the socket.
10

Security and operating limits

01

Roomly requires ROOMLY_API_KEY in every production runtime and compares Bearer credentials in constant time.

02

The consuming backend derives identity from its authenticated session and checks room permission before calling Roomly.

03

Token issuance is limited to 120 requests per server key per minute. Redis coordinates the limit across Roomly instances.

04

The browser receives only a signed five-minute room token. The signing secret and server API key remain server-side.

05

Keep events below 16 KiB and below 60 inbound messages per 10 seconds for each connection.

06

Treat events as ephemeral. Roomly does not persist history or replay messages missed while a client is offline.