Relay SDK Docs

Getting Started

Introduction

Relay is a realtime messaging SDK for building chat, collaboration, live dashboards, and multiplayer features. It gives you pub/sub channels, presence, and guaranteed message ordering over a single WebSocket connection.

The client SDK ships for browsers, Node.js, and React Native from one package. Under the hood, Relay maintains a multiplexed connection to the nearest edge region, automatically resumes after network drops, and replays any messages you missed while offline — up to a two-minute window on every plan.

When to use Relay

  • Chat and messaging — direct messages, group rooms, typing indicators, read receipts.
  • Live collaboration — cursors, co-editing signals, comment streams.
  • Data fan-out — pushing price ticks, order status, or telemetry to thousands of subscribers.

If you need request/response RPC or file transfer, Relay is not the right tool — pair it with your existing HTTP API instead. Relay messages are capped at 64 KB per publish.

Note These docs cover SDK v3.x. The v2 client still works against the same infrastructure, but v2 reached end-of-support in March 2026. See the migration table in RelayClient for renamed options.

Getting Started

Installation

Install the client package with your package manager of choice. The package includes TypeScript definitions — no separate @types install is needed.

bash
npm install @relay/client
# or
pnpm add @relay/client
# or
yarn add @relay/client

Server SDK

For publishing from trusted backends and verifying webhooks, install the server package. It never opens a WebSocket — it signs REST calls with your secret key.

bash
npm install @relay/server

Browser via CDN

For prototypes without a build step, load the IIFE bundle. It exposes a global Relay object. Pin an exact version in production — the latest tag can introduce breaking changes across majors.

html
<script src="https://cdn.relay.dev/client/3.4.2/relay.min.js"></script>
Warning Never ship your secret key (sk_live_…) to a browser or mobile app. Browsers should only ever see a publishable key (pk_…) combined with a token endpoint — see Authentication.

Getting Started

Quickstart

Send your first realtime message in under five minutes: connect, subscribe to a channel, and publish.

1. Connect a client

Create a RelayClient with your publishable key. The client connects lazily — the socket opens on the first subscribe or publish, not at construction time.

typescript
import { RelayClient } from "@relay/client";

const relay = new RelayClient({
  key: "pk_live_9f3aa01c",
  authUrl: "/api/relay-token",  // your token endpoint
});

2. Subscribe and listen

typescript
const room = relay.channel("orders:eu-west");

room.subscribe("status.changed", (msg) => {
  console.log(msg.data.orderId, msg.data.status);
});

3. Publish from your server

Trusted publishes go through the server SDK with your secret key. The message fans out to every subscriber of orders:eu-west in the same order for all of them.

javascript
import { Relay } from "@relay/server";

const relay = new Relay(process.env.RELAY_SECRET_KEY);

await relay.channels
  .get("orders:eu-west")
  .publish("status.changed", {
    orderId: "ord_1842",
    status: "shipped",
  });
Note Clients can also publish directly if the channel's capability grant allows it. For anything user-generated (chat messages, reactions), that is the normal pattern — see Channels for capability rules.

Getting Started

Authentication

Relay uses short-lived signed tokens. Your backend mints a token scoped to the channels and capabilities a user should have; the client exchanges it transparently.

Token flow

  1. The client calls your authUrl endpoint with the user's session cookie or bearer token.
  2. Your endpoint verifies the user, then calls relay.auth.createToken() with the allowed channel patterns.
  3. The client presents the token when opening the socket. Tokens expire after 15 minutes by default; the SDK refreshes them automatically before expiry.
typescript
// POST /api/relay-token  (Express example)
app.post("/api/relay-token", requireLogin, async (req, res) => {
  const token = await relay.auth.createToken({
    userId: req.user.id,
    capabilities: {
      "chat:*": ["subscribe", "publish", "presence"],
      "orders:*": ["subscribe"],
    },
    ttl: 900, // seconds
  });
  res.json(token);
});

createToken() parameters

ParameterTypeRequiredDescription
userIdstringrequiredStable identifier for the user. Shown to other members through presence and attached to every message the client publishes.
capabilitiesRecord<string, Capability[]>requiredMap of channel patterns (* wildcards allowed) to allowed operations: subscribe, publish, presence, history.
ttlnumberoptionalToken lifetime in seconds. Default 900, maximum 3600.
metadataobjectoptionalUp to 1 KB of JSON attached to the connection, readable by webhook handlers. Not visible to other clients.
Warning Capability patterns are the security boundary. Granting "*": ["publish"] lets a client write to every channel in your app, including ones you add later. Scope patterns as narrowly as your product allows.

Core Concepts

Channels

A channel is a named stream of messages. Anyone with the subscribe capability for its name receives every message published to it, in the same total order.

Naming conventions

Channel names are UTF-8 strings up to 160 characters. The colon is conventionally used as a namespace separator, and capability patterns match on it, so a consistent scheme pays off:

  • chat:team-42 — one room per team
  • doc:8f1c:cursors — high-frequency sub-stream kept apart from the main doc channel
  • user:u_991:inbox — per-user private channel, granted only to that user's token

Attach and detach

Calling relay.channel(name) returns a local handle immediately. The channel attaches (starts receiving) on the first subscribe() call and detaches automatically when the last listener is removed. You can force these transitions:

typescript
const doc = relay.channel("doc:8f1c");

await doc.attach();   // start buffering before listeners exist
await doc.detach();   // stop receiving, keep the handle
doc.release();        // drop the handle and all listeners
Note Attached channels count toward your plan's concurrent-channel limit per connection (500 on Free, 2,000 on Pro). Detached handles are free. If you page through many rooms, detach the ones that scroll out of view.

Core Concepts

Presence

Presence tracks who is currently in a channel. Members enter with a small state object, can update it, and are removed automatically when their connection dies.

typescript
const room = relay.channel("chat:team-42");

await room.presence.enter({ status: "online", typing: false });

room.presence.onChange((members) => {
  renderAvatars(members); // full, deduplicated member list
});

// cheap partial update — merged server-side
await room.presence.update({ typing: true });

Semantics worth knowing

  • Leave is automatic. If the socket drops without a clean leave(), the member is evicted after a 30-second grace period. A reconnect within that window cancels the eviction, so brief network blips don't cause avatar flicker.
  • One member per userId. A user with three tabs open appears once, with the state from their most recent enter or update.
  • State is capped at 2 KB. Presence is for status, not payloads. Put anything bigger in a regular message.
Warning Presence fan-out is O(members²) on updates — every update reaches every member. Above roughly 200 concurrent members per channel, switch to the sampled mode (presence: { mode: "sampled" } in channel options) which delivers member counts plus a bounded sample instead of the full roster.

Core Concepts

Message Lifecycle

Every message moves through a fixed pipeline: accepted, sequenced, fanned out, and retained. Understanding it explains Relay's delivery guarantees.

Delivery guarantees

  • Order: per-channel total order. Subscriber A and subscriber B always see the same sequence.
  • Delivery: at-least-once while connected or within the replay window. The SDK deduplicates on msg.id, so your handlers see each message exactly once.
  • Replay: after a disconnect, the client resumes from its last acknowledged sequence number. Messages published up to 2 minutes before resume are replayed automatically.

The message envelope

Handlers receive the envelope, not just your payload:

json
{
  "id": "msg_01J9ZK4QW8",
  "seq": 48213,
  "name": "status.changed",
  "channel": "orders:eu-west",
  "publisher": { "type": "server", "userId": null },
  "publishedAt": "2026-07-15T09:41:07.221Z",
  "data": { "orderId": "ord_1842", "status": "shipped" }
}

History

Beyond the 2-minute replay window, channels with the history capability retain messages for 72 hours (Pro) and can be paged backwards:

typescript
const page = await room.history({ limit: 50, direction: "backwards" });
for (const msg of page.items) prepend(msg);
if (page.hasNext) await page.next();

API Reference

RelayClient

The root object. One instance per app is the norm — it owns the socket, the token refresh loop, and the channel registry.

Constructor options

OptionTypeDefaultDescription
keystringrequired  Publishable key (pk_…). Identifies your app; carries no privileges by itself.
authUrlstringEndpoint the SDK POSTs to for tokens. Either this or authCallback is required for private channels.
authCallback(ctx) => Promise<Token>Programmatic alternative to authUrl — return a token object yourself (React Native apps often use this with their own fetch stack).
region"auto" | RegionCode"auto"Pin the edge region. "auto" picks the lowest-latency region via anycast; pin only for data-residency requirements.
reconnectReconnectPolicyexponentialBackoff policy. Default: 250 ms base, ×2 per attempt, 30 s cap, full jitter. See Handling Reconnection.
logLevel"silent" | "error" | "info" | "debug""error"Console verbosity. "debug" logs every frame — never ship it.

Methods

MethodReturnsDescription
channel(name)ChannelGet or create the local handle for a channel. Idempotent — the same name returns the same handle.
connect()Promise<void>Force the socket open eagerly. Optional; usually the lazy default is what you want.
close()Promise<void>Cleanly leave presence on all channels, flush pending publishes, then close the socket.
onConnectionChange(cb)() => voidSubscribe to connection state transitions. Returns an unsubscribe function.
Note Migrating from v2? apiKeykey, tokenEndpointauthUrl, and clusterregion. The v2 autoReconnect: false flag is now reconnect: "off".

API Reference

Channel

Returned by relay.channel(name). All methods are safe to call in any connection state — operations queue while offline and flush on resume.

publish(name, data, options?)

Publishes a message from the client. Resolves once the server has sequenced the message; rejects with RelayError code 40160 if the token lacks the publish capability.

typescript
await room.publish("chat.message", { text: "Shipping Friday." }, {
  idempotencyKey: draftId,  // retries won't duplicate
});
ArgumentTypeRequiredDescription
namestringrequiredEvent name. Dot-separated by convention (chat.message, cursor.move). Subscribers filter on it.
dataJsonValuerequiredPayload, JSON-serialized. 64 KB max after serialization.
options.idempotencyKeystringoptionalPublishes with the same key within 5 minutes are collapsed into one message.
options.ephemeralbooleanoptionalSkip history and replay for this message. Use for cursors and typing indicators — subscribers who are offline simply miss it.

subscribe(nameOrFilter, handler)

Registers a handler and attaches the channel if needed. Returns an unsubscribe function. The filter form takes a glob: room.subscribe("cursor.*", h).

Warning Handlers run on the socket's message loop. A handler that blocks for more than ~50 ms will delay every other channel on the connection. Do heavy work in queueMicrotask or a worker.

API Reference

Events

Connection and channel state changes are exposed as typed events so your UI can show accurate connectivity status.

Connection states

StateMeaningTypical UI
initializedClient constructed, socket not yet opened.Nothing — this is the pre-first-use state.
connectingSocket dialing, or token being fetched.Subtle spinner on first load only.
connectedLive. Publishes flush, subscriptions receive.Nothing.
suspendedRetries exhausted the 30 s cap; now retrying every 30 s."Reconnecting…" banner. Queued publishes are still held.
failedUnrecoverable — bad key, revoked token, or account limit.Error state; check event.reason.
typescript
const off = relay.onConnectionChange((ev) => {
  banner.hidden = ev.current !== "suspended";
  if (ev.current === "failed") {
    console.error("Relay failed:", ev.reason.code, ev.reason.message);
  }
});

Error codes

All SDK errors are RelayError instances with a numeric code. The first two digits mirror HTTP classes: 401xx auth, 403xx capability, 429xx rate limits, 500xx server. Codes are stable across SDK versions — match on them, not on message strings.


Guides

Handling Reconnection

The SDK handles the mechanics of reconnecting — backoff, token refresh, sequence resume. Your job is deciding what the UI does during the gap and what happens when the gap was too long.

Short gaps: do nothing

Drops under the 2-minute replay window are invisible to your data layer. Messages replay in order, presence eviction is cancelled, and pending publishes flush. Showing a banner for a 3-second WiFi blip trains users to ignore your banners — react to suspended, not connecting.

Long gaps: resync

If the client was offline past the replay window, the resume is rejected and the SDK emits channel.stateLost on each affected channel. That is your cue to refetch canonical state from your own API:

typescript
room.on("stateLost", async () => {
  // gap exceeded the replay window — rebuild from source of truth
  const snapshot = await api.fetchMessages(roomId, { limit: 100 });
  store.replaceAll(snapshot);
  // live messages arriving after this point are newer than the snapshot
});
Note stateLost fires before replayed live traffic resumes on the channel, so a snapshot fetched inside the handler can never be overwritten by an older message.

Tuning backoff

The defaults suit interactive apps. For battery-sensitive mobile apps, lengthen the cap; for trading dashboards, shorten the base:

typescript
new RelayClient({
  key: "pk_live_9f3aa01c",
  authUrl: "/api/relay-token",
  reconnect: { baseMs: 100, maxMs: 10_000, jitter: "full" },
});

Guides

Server Webhooks

Webhooks let your backend react to realtime activity — persist chat messages, moderate content, or track who's online — without holding a client connection open.

Available webhook events

EventFires whenBatching
message.publishedAny message is sequenced on a matching channel pattern.Up to 100 messages / 1 s per batch
presence.enteredA member enters presence.Not batched
presence.leftA member leaves or is evicted after grace.Not batched
channel.openedFirst subscriber attaches to a channel.Not batched
channel.closedLast subscriber detaches (after 60 s idle).Not batched

Verifying signatures

Every delivery is signed with HMAC-SHA256 over the raw body. Reject anything that fails verification — the helper also checks the timestamp to block replays older than 5 minutes:

javascript
import { verifyWebhook } from "@relay/server";

app.post("/hooks/relay", express.raw({ type: "*/*" }), (req, res) => {
  const event = verifyWebhook(req.body, {
    signature: req.headers["relay-signature"],
    secret: process.env.RELAY_WEBHOOK_SECRET,
  }); // throws RelayError 40103 on mismatch

  if (event.type === "message.published") {
    for (const msg of event.messages) db.messages.insert(msg);
  }
  res.sendStatus(200);
});
Warning Use the raw request body for verification. Body parsers that re-serialize JSON (different key order, whitespace) will produce a different HMAC and every delivery will look forged.

Delivery and retries

Relay expects a 2xx within 10 seconds. Failed deliveries retry with exponential backoff for up to 6 hours, then land in the dead-letter view in the dashboard, where you can replay them manually. Design handlers to be idempotent — the batch id header is stable across retries.