R Relay ← Back to relay docs

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:

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

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