Integrating Relay into an existing iOS app
This is the complete path for a team that already has an iOS app and wants to add chat + calling
to it — not a new demo app. Everything you need is on this page; the platform's own
packages/ios/README.md has the full API reference if you need more
than what's shown here.
For Android or web, see integration-android.md /
integration-web.md instead. A shorter, cross-platform overview lives at
integration.md.
The mental model
Relay is a separate service your existing backend talks to, plus a client library your iOS 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. Owns chat storage, presence, the realtime gateway, and brokers WebRTC calls. You don't write any of this.
RelayCore/RelayUI/RelayCall— Swift packages your existing app depends on. Talk to the Relay service directly (not through your backend) for everything after the initial token exchange.
Nothing here requires a rewrite. RelayChatView and RelayCallOverlay are ordinary SwiftUI views
you place inside a screen you already have; if you already have your own chat UI, skip to
Headless below and keep it.
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 inside your iOS app, 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 baked into your app — it grants nothing
by itself. secretKey mints user tokens for anyone in your project; it must never reach client
code, an app binary, 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 iOS 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. Tokens expire in ~15 minutes; RelayCore calls this endpoint again
automatically when one expires, so this route just needs to exist and be fast.
sample-apps/web-demo/token-server.mjs is a complete, 60-line reference implementation of this
exact endpoint (framework-agnostic — the shape is identical regardless of which client calls it).
Full details: service/README.md.
Step 3 — Add the SDK to your app
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 — it's a
separate product so a chat-only app skips the WebRTC binary). Requires iOS 17, Xcode 15+.
Initialize — once per signed-in user, next to wherever your app already builds its other network clients:
import RelayUI
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 (e.g. scenePhase, or wherever your app already reacts to
those) — the store resyncs by itself on every reconnect (the server never replays missed events),
so there's no missed-message bookkeeping to add yourself.
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. It connects on appear and shows the conversation list → thread → composer
with presence, typing, read receipts, retries, edits, replies, and media.
Headless, if you already have chat UI:
try await relay.connect()
try await relay.chat.loadConversations()
let convo = try await relay.chat.openConversation(with: "user-42") // your own user id
try await relay.chat.sendMessage(convo.id, text: "hello")
// relay.chat is @Observable — read relay.chat.conversations / .thread(id).messages directly in SwiftUI
Step 4 — Calling (1:1 and group, one API)
Calling is a separate package (RelayCall) so a chat-only integration doesn't pull in WebRTC. One
call-start API handles both 1:1 and group calls — the SDK looks at the conversation you pass it
and shows the right layout itself:
import RelayCall
let calls = CallCenter(client: relay) // once; uses CallKit on iOS
RelayChatView(client: relay)
.overlay { RelayCallOverlay(center: calls) } // full-screen call UI + errors, 1:1 or group automatically
// From a thread header:
calls.start(conversation: conversation, type: .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, without touching what anyone else
hears. Add the voip and audio background modes to your Info.plist. Incoming calls ring through
CallKit while the app is running.
Ringing when the app is fully closed (PushKit)
Once your project has APNs credentials (.p8 key, team id, key id, bundle id — set from the admin panel), the service sends a VoIP push for every incoming call and a cancel when the ring ends:
// AppDelegate.didFinishLaunching / @main App init
let push = RelayPushRegistry(client: relay, calls: calls)
push.start() // registers the VoIP token with the service
// application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
push.registerAlertToken(deviceToken) // chat alerts through your normal APNs token
// after sign-in: push.resync() on sign-out: await push.unregisterAll()
RelayPushRegistry reports the call to CallKit synchronously inside the PushKit callback (Apple's
rule for VoIP pushes), starts the gateway connection so answering is instant, and ignores the
socket's duplicate call_invite. Chat alert pushes carry relay = "chat_message",
conversationId and messageId for deep-linking.
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:
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:
MessageThreadView(conversationId: id, onPickGroupMembers: { await showMyOwnContactPicker() })
Relay's built-in picker, backed by your directory — you supply a search function, Relay renders the search/list/selection UI itself:
MessageThreadView(conversationId: id, onSearchPeople: { query in await myUserDirectory.search(query) })
onSearchPeople: (String) async -> [RelayUser] 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 app already has. If you supply both,
onSearchPeople wins; if you supply neither, "Add people" is simply hidden.
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:
var theme = RelayTheme()
theme.accent = .myBrandBlue
theme.danger = .myBrandRed
theme.fontFamily = "MyBrandFont-Regular"
theme.fontScale = 1.1
RelayChatView(client: relay)
.relayTheme(theme)
.relayTypography(theme)
For icons:
var icons = RelayIcons()
icons.micOn = Image("my_mic_icon")
icons.callEnd = Image("my_hangup_icon")
RelayChatView(client: relay).relayIcons(icons)
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 regardless of your app's theme; it now reads from the same
RelayTheme/RelayIcons as the rest (apply the same modifiers around RelayCallOverlay). Every
property is independently optional — override one color without supplying the rest, and
everything else keeps Relay's default.
Attachments & link previews
MessageComposerView's attach button (Camera / Photo Library / File) needs no permission at
all for Photo Library or File — both are out-of-process pickers. Camera is the one
exception: add NSCameraUsageDescription to your Info.plist (the same key RelayCall already
needs for video calls). A message with an http(s):// link gets an automatic preview card — no
setup needed, fetched server-side.
Security checklist before you ship
secretKeylives only on your backend (Step 2), never in client code or git.- The SDK sends
X-App-Bundle-Idautomatically on every request — no configuration needed. If you've configured an iOS bundle ID allowlist for your project in the admin panel, a request from an app not on that list is rejected; harmless to ignore otherwise. - 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 your app's icon — that comes from your own app target automatically, there's nothing to override there.
Where to go next
- Full API reference:
packages/ios/README.md. - Backend/token details and every security note:
service/README.md. - Push notification credentials (APNs) setup:
push-credentials.md. - There is no sample Xcode project — the snippets on this page and in
packages/ios/README.mdare the integration.