Integrating Relay into an existing app
This is the document for a team that already has an app — Android, iOS, or web — and wants to add chat + calling to it, rather than build a new app around the SDK. It walks the whole path: getting a project, wiring your backend, dropping the client into your existing screens, and matching it to your app's own look. Each platform's package also has its own README with the full API reference (linked at the bottom of each section here); this document is the order to read things in, not a replacement for them.
If you want the fastest possible look at working code instead of reading this first, the
reference integration for each platform is a real, running app: sample-apps/web-demo,
packages/ios/app-demo, packages/android/app-demo.
The mental model
Relay is a separate service your existing backend talks to, plus a client SDK your existing app embeds. It does not replace your login system, your user database, or your backend — it has no idea who your users are until you tell it, on every request, via a short-lived token your own backend mints. Three actors:
- Your backend — already exists, already authenticates your users. Gains one new endpoint: mint a Relay user token for whoever is signed in.
- The Relay service — one shared Communication Service, reachable at a URL you configure (self-hosted or the one your team runs). Owns chat storage, presence, the realtime gateway, and brokers WebRTC calls. You don't write any of this.
- The Relay client SDK — a package/library your existing app depends on. Talks to the Relay service directly (not through your backend) for everything after the initial token exchange.
Nothing here requires a rewrite. The chat/call screens are components/views/composables you mount inside screens you already have; if you already have your own chat UI, skip straight to the headless client and keep your UI.
Step 1 — Create a project and get your keys
Once, against the Relay service (ask whoever runs it for the URL, or run service/ yourself —
see service/README.md):
curl -s -X POST https://relay.example.com/projects \
-H 'content-type: application/json' \
-d '{"name":"My App"}'
# {
# "publicKey": "pk_5f1e…", ← ships in your client apps, safe to embed
# "secretKey": "sk_9c2a…" ← backend only, shown exactly once — store it in your secrets manager now
# }
publicKey identifies your project to Relay and is fine in a mobile app binary or a browser
bundle — it grants nothing by itself. secretKey mints user tokens for anyone in your project;
it must never reach client code, a browser, or git. If you lose it, rotate it
(POST /projects/me/keys) rather than trying to recover it — it's stored as a hash.
Step 2 — Add a token endpoint to your existing backend
This is the one piece of backend work, and the only place Relay touches your existing auth. Add a route your already-signed-in users can call:
POST /api/relay-token (your backend, behind your existing session/auth middleware)
→ looks up the current user from YOUR session (however you already do that)
→ calls Relay: POST /users/token { "externalId": "<your user id>", "displayName": "...", "avatarUrl": "..." }
Authorization: Bearer sk_9c2a…
→ returns { "userToken": "...", "expiresAt": "..." } to your client
externalId is your user id — whatever you already use as a primary key. Relay never invents
its own identity; every userId your app sees on the wire (message senders, call participants,
presence) is that same id, so wiring a user picker or displaying a name is just reading a field
you already have. Tokens expire in ~15 minutes; every client SDK below calls this endpoint again
automatically when one expires, so this route just needs to exist and be fast — nothing to poll or
schedule. sample-apps/web-demo/token-server.mjs is a complete, 60-line reference implementation
of this exact endpoint if you want a working example to copy from. Full details:
service/README.md.
Step 3 — Add the client to your app
Pick your platform. Each shows the drop-in path (their prebuilt chat/call UI, for when you don't already have one) and the headless path (just the data layer, for when you do).
Android
Full standalone guide (everything below, plus attachments, link previews, and more theming
detail): integration-android.md.
Install — until the modules are published, add them as a composite build or
includeBuild("path/to/relay-sdk/packages/android"), then in your existing app module:
implementation(project(":relay-ui")) // chat UI — pulls in relay-core
implementation(project(":relay-call")) // add only if you want calling too
Initialize — once per signed-in user, next to wherever your app already builds its other network clients:
val relay = RelayClient(RelayConfig(
baseUrl = "https://relay.example.com",
publicKey = "pk_5f1e…",
tokenProvider = { myExistingBackendClient.fetchRelayToken() }, // calls Step 2's endpoint
packageId = BuildConfig.APPLICATION_ID, // optional, see Security below
))
Call relay.connect() where your app already handles "became foreground" / sign-in, and
relay.goToBackground() / relay.disconnect() at the matching background/sign-out points — the
store resyncs by itself on every reconnect, so there's no missed-message bookkeeping to add.
Mount the UI — inside whatever screen already hosts your chat/conversation list today:
RelayChat(client = relay) // list → thread → composer, as one composable
Wrap it in your existing MaterialTheme { … } and it already inherits your colors and type scale
— see Theming below for the parts that need an explicit override.
Headless, if you already have chat UI and only want the data layer:
relay.chat.state.collect { snapshot -> /* your own UI reads conversations, threads, typing, presence */ }
relay.chat.sendMessage(convo.id, "hello")
Full reference: packages/android/README.md.
iOS
Full standalone guide (everything below, plus attachments, link previews, and more theming
detail): integration-ios.md.
Install — Xcode → File → Add Package Dependencies… → this repo's URL (or a local path to
packages/ios) → add the RelayUI product (add RelayCall too if you want calling).
Initialize — once per signed-in user:
let relay = RelayClient(config: RelayConfig(
baseURL: URL(string: "https://relay.example.com")!,
publicKey: "pk_5f1e…",
tokenProvider: { try await myExistingBackendClient.fetchRelayToken() } // calls Step 2's endpoint
))
Call try await relay.connect() / await relay.goToBackground() at your existing
foreground/background transitions (scenePhase, or wherever your app already reacts to those).
Mount the UI — inside whatever screen/tab already hosts chat:
RelayChatView(client: relay)
It's a normal View — push it, present it, or embed it in a TabView the same as anything else
already in your app.
Headless, if you already have chat UI:
try await relay.chat.loadConversations()
// relay.chat is @Observable — read relay.chat.conversations / .thread(id).messages directly in SwiftUI
try await relay.chat.sendMessage(convo.id, text: "hello")
Full reference: packages/ios/README.md.
Web
Full standalone guide (everything below, plus more theming detail):
integration-web.md.
Install:
npm install @relay/react @relay/core react react-dom
Initialize + mount — RelayProvider fits around whatever part of your existing component tree
should have chat, the same as any other context provider you already use (a router outlet, a
dashboard panel, a modal):
import { RelayProvider, ChatWindow } from '@relay/react';
import '@relay/react/styles.css';
const fetchToken = () =>
fetch('/api/relay-token', { method: 'POST' }).then((r) => r.json()).then((j) => j.userToken); // Step 2
function MyExistingChatPanel({ userId }: { userId: string }) {
return (
<RelayProvider key={userId} config={{ baseUrl: 'https://relay.example.com', publicKey: 'pk_5f1e…', token: fetchToken }}>
<div style={{ height: '100%' }}><ChatWindow /></div>
</RelayProvider>
);
}
key={userId} matters if your app can switch signed-in users without a full page reload — it
forces a fresh client instead of reusing a stale socket.
Headless, if you already have chat UI — use the hooks over your own markup instead of
<ChatWindow>:
const { messages, send } = useMessages(conversationId);
Full reference: packages/web/react/README.md.
Step 4 — Calling (1:1 and group, one API)
Calling is a separate module on Android/iOS (so a chat-only integration doesn't pull in WebRTC) and a flag on web. One call-start API handles both 1:1 and group — the SDK looks at the conversation you pass it and shows the right layout itself, so there's no separate "group call" method to learn:
// Android — once
val calls = CallCenter(context, relay)
Box { RelayChat(relay); RelayCallOverlay(calls) } // banner + full-screen UI, 1:1 or group automatically
// from a thread header:
calls.start(conversation, CallType.VIDEO)
// iOS — once
let calls = CallCenter(client: relay)
RelayChatView(client: relay).overlay { RelayCallOverlay(center: calls) }
calls.start(conversation: conversation, type: .video)
// Web — CallOverlay + call buttons are already inside <ChatWindow /> by default; pass calls={false} to opt out
import { useCall, CallOverlay } from '@relay/react';
const { start } = useCall();
start(conversation, 'video');
Group calls (up to 6 participants) render as a grid — 2 stacked, 3 as 2-over-1, up to 6 as three rows of 2 — with per-tile name badges, mute indicators, tap-to-fullscreen, and a per-participant "mute for me" control that only affects what you hear locally. Ringing while your app is fully closed needs one more step per platform — FCM data messages on Android, PushKit VoIP pushes on iOS — covered in each platform's README under "Ringing when the app is closed"; it needs APNs/FCM credentials added to your project from the admin panel first.
Step 5 — Wire your own "add participants" picker
Group chats need a way to pick who to add. Relay does not ship a directory of your users — it has no idea who they are beyond the ids that show up in tokens — so this is always a callback into your own app. Two ways to hook it in, from simplest to most integrated:
Your own picker UI entirely (fastest to wire, if you already have a contacts/user list screen): return the ids yourself, in whatever UI you want.
MessageThread(client = relay, conversationId = id, onPickGroupMembers = { showMyOwnContactPicker() })
Relay's built-in picker, backed by your directory — you supply a search function, Relay renders the list/search/selection UI itself:
MessageThread(client = relay, conversationId = id, onSearchPeople = { query -> myUserDirectory.search(query) })
onSearchPeople is called once with an empty query when the screen opens (a starting list) and
again as the user types; return RelayUsers (id, display name, avatar, online status) from
whatever directory your existing app already has — an internal API, a cached employee list,
whatever you'd use to build a picker yourself. If you supply both, onSearchPeople wins; if you
supply neither, "Add people" is simply hidden. Same shape on iOS
(onSearchPeople: (String) async -> [RelayUser]) and web (onSearchPeople prop on ChatWindow).
Step 6 — Make it look like your app
Every color, most icons, and text sizing are overridable, with defaults equal to Relay's own look — so this step is optional, and doing nothing is a supported choice.
// Android — wrap anywhere above your Relay composables; every property you don't override keeps
// today's look, either via .copy() on the Material-derived default or by leaving the whole
// parameter null (colors/icons/typography are each independently optional)
RelayTheme(
colors = RelayColors.fromMaterialTheme(MaterialTheme.colorScheme).copy(accent = MyBrandBlue, danger = MyBrandRed),
icons = RelayIcons(micOn = myMicIcon),
typography = RelayTypography(fontFamily = myBrandFont, fontScale = 1.1f),
) { RelayChat(relay); RelayCallOverlay(calls) }
// iOS
var theme = RelayTheme()
theme.accent = .myBrandBlue
theme.fontScale = 1.1
RelayChatView(client: relay).relayTheme(theme).relayTypography(theme)
/* Web — plain CSS variables, no component API needed */
:root {
--relay-accent: #7c3aed;
--relay-bubble-me: #7c3aed;
--relay-font: Inter, system-ui, sans-serif;
--relay-font-scale: 1.1;
}
// Web icons — a prop, since swapping a glyph is a render concern CSS can't reach
<RelayProvider config={config} icons={{ micOn: <MyMicIcon />, callEnd: <MyHangUpIcon /> }}>
This covers chat and call screens uniformly — the call screen used to be the one place still hardcoded to Relay's own dark-navy look on every platform; it now reads from the same colors/icons/typography as the rest. Every property is independently optional — override one color without supplying the rest, and everything else keeps Relay's default.
Security checklist before you ship
secretKeylives only on your backend (Step 2), never in client code or git.- Pass
packageId(Android, yourapplicationId) — iOS sends its bundle id automatically — if you've configured a package/bundle allowlist for your project in the admin panel; harmless to omit otherwise. - Set a CORS origin allowlist for your project if you're on web (
PATCH /projects/me/settings); native apps aren't affected by this. - Decide who can message whom in your own backend, before minting a token or before your UI offers a conversation — Relay has no friend graph or block list of its own, so any two users in the same project can otherwise open a chat with each other.
What you still own
Relay doesn't do: authentication (you already have this — it just needs a way to mint tokens for whoever is signed in), a user directory or search (Step 5 is how your existing one plugs in), push certificate/key management beyond what you configure once in the admin panel, or app icons/branding on iOS/Android (those come from your app target automatically — there's nothing to override there).
Where to go next
- Full API reference per platform:
packages/android/README.md,packages/ios/README.md,packages/web/react/README.md. - Backend/token details and every security note:
service/README.md. - Push notification credentials (FCM/APNs) setup:
docs/push-credentials.md. - Protocol reference (if you're building a client this repo doesn't have yet):
protocol/openapi.yaml,protocol/events.md.