guides

Link a platform account from a mobile app

Linking a platform account means a human consenting on that platform's own consent screen. No server-to-server call can stand in for it, so every client — web, iOS, Android — does the same three things: ask TRaX for a URL, put that URL in front of the user, then find out what happened.

On a phone the middle step is where it goes wrong, and it goes wrong in ways that are invisible until you test on a real device. This page is the shape that works.

Which platforms this covers. Production has OAuth applications configured for Twitch, YouTube, Kick, Facebook and LinkedIn. trovo is accepted by the schema but has no credentials provisioned — asking to link it is a 400, not an empty URL. Facebook has an adapter but is handled as a manual stream-key integration in the product while its platform app review is outstanding.

Instagram, Rumble, X/Twitter, TikTok and anything custom have no OAuth link at all and take a pasted stream key instead — see Manual stream-key connections. None of the browser handling below applies to those.

1. Ask for the URL

POST /v1/connections/link
Authorization: Bearer sk_live_…
Content-Type: application/json

{
  "platform": "youtube",
  "returnTo": "https://account.traxstreaming.live/settings/integrations"
}

Requires connections:write. You get back an authorizationUrl to open and a state string.

Two things to know about the response:

state is how you tell attempts apart. If a user taps Connect twice, or links two platforms in a row, you will have more than one flow in flight. Keep the state you were handed alongside whatever your UI is waiting on.

returnTo must be a TRaX-owned https host. Anything else is rejected, not followed — this value is a redirect target reached carrying the user's session, so an app cannot point it at itself or at a third-party host. returnTo is optional; leaving it out is the normal choice for a phone, and section 3 explains where the user lands then.

Leave scopes out. Omitting it requests the platform's default set, which is what TRaX's own features need. Requesting less links successfully and then silently fails later at whichever feature needed the missing scope — chat that never attaches, a title that won't save. If you do narrow it, read grantedScopes on the resulting connection to see what the platform actually handed over, which is not always what you asked for.

To repair a broken connection, send reconnectConnectionId. That refreshes the grant in place and keeps the connection id. Without it you get a second connection row, and every destination bound to the old one is orphaned — still listed, still failing at go-live. This is the single most common way a re-link makes things worse.

2. Open it in a system browser session — not a webview

iOS

let session = ASWebAuthenticationSession(
    url: authorizationUrl,
    callbackURLScheme: nil        // see §3 — set this to auto-dismiss the sheet
) { _, _ in
    // The user dismissed the sheet. This tells you the flow ENDED,
    // not that it SUCCEEDED. Go to step 4.
    Task { await self.refreshConnections() }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false   // share Safari's cookies
session.start()

ASWebAuthenticationSession presents a browser sheet over your app, shares Safari's cookie jar — so a user already signed into YouTube is often one tap from done — and shows the real domain in the chrome, which is the property that makes it phishing-resistant.

Leave prefersEphemeralWebBrowserSession at false unless you have a reason: turning it on throws away the shared cookie jar and forces a full sign-in every time.

Android

The Android equivalent is a Chrome Custom Tab. Same properties — real browser engine, shared cookie jar, visible origin — presented over your app:

CustomTabsIntent.Builder()
    .setShowTitle(true)
    .build()
    .launchUrl(context, Uri.parse(authorizationUrl))

There is no completion callback. A Custom Tab is a browser, not a modal you await: control comes back to you when your activity resumes, either because the user pressed Back or because something navigated into your app (§3). Do the step-4 refresh from onResume(), and make it idempotent — it will fire on ordinary app switches too:

override fun onResume() {
    super.onResume()
    viewModel.refreshConnections()   // cheap, idempotent; see step 4
}

Add androidx.browser:browser for CustomTabsIntent. If no Custom Tabs–capable browser is installed, launchUrl falls back to a plain ACTION_VIEW — the user leaves your app for a full browser. That still completes the link; they just have to come back by hand. Worth handling gracefully, not worth blocking on.

Both platforms

Never an embedded WKWebView/WebView. Google rejects them outright with disallowed_useragent, so YouTube linking simply cannot work that way. The others mostly tolerate it, which is worse — you ship, and only the YouTube path is broken. On Android this means not WebView.loadUrl(authorizationUrl), no matter how much more controllable it looks.

Never a hand-off to an external browser either. That's the version that prompts "which browser do you want to use?", drops the user out of your app, and leaves them holding a tab they have to clean up themselves.

3. Where the user lands

After the platform hands the user back, trax-connect finishes the exchange and sends the browser somewhere. Which somewhere depends on what you sent:

You sent The browser ends on
A safe returnTo That URL, with ?connected=<platform> appended
No returnTo https://connect.traxstreaming.live/oauth/return

The /oauth/return page is a small self-contained confirmation — "YouTube connected", or the error state naming what went wrong — with no scripts and no network calls, so it paints instantly on a bad connection. It is the last frame of a flow the user is already waiting on.

This page is why you can omit returnTo. Before it existed the no-returnTo path fell through to a raw JSON body, and a user who linked from a phone watched a wall of {"status":"ok",…} in a browser they then had to dismiss by hand. Live in production since 1 September 2026.

The JSON is still there for non-browsers. To be precise about what is gated: /oauth/return is a page and always renders HTML — fetch it yourself and you get markup. What the Accept header governs is the callback (/oauth/{platform}/callback), which redirects the caller here only when Accept contains text/html. curl's default Accept: */*, an empty Accept, and every programmatic client get the exact JSON body from the callback they got before this page existed — the polarity opts in, so scripted callers are unchanged by construction.

The page never links to the TRaX website, by design. An earlier build offered a "Back to TRaX" button. Inside ASWebAuthenticationSession there is no address bar, so a user who tapped it was left browsing the site in a modal browser they could not navigate — the stranded-in-a-tab problem the sheet exists to prevent, moved one step later. The page's job is to end the flow, so the exit instruction is the primary content, and it names the control the sheet actually shows: Cancel, with the reassurance that Cancel closes the window without undoing the link. (Found on a real device, 1 September 2026; fixed the same day.)

Getting the user back into your app

By default nothing closes the browser for you. On iOS the user taps Cancel, which fires your completion handler with ASWebAuthenticationSessionError.canceledLogin — an outcome that means "the sheet is gone", never "the link failed". Treat it as the cue to go to step 4, not as an error to surface. On Android the user presses Back and your activity resumes. Build this path first on both platforms: it is the one that always works, on every OS version, with no configuration on our side.

Everything below is an improvement on top of it, not a replacement.

Custom-scheme return (iOS live in production, Android off). When the operator sets the per-platform scheme for the requesting device (see the note below), the page renders its return control as <scheme>://oauth/linked?connected=<platform> (or ?error=…&error_provider=… on the failure leg). The user taps it — the page never navigates on its own. That is deliberate: a scripted navigation to a scheme the device cannot open produces an OS error dialog and strands the user, whereas an unopenable link simply does nothing when tapped, with the Cancel instruction still beside it. So treat this as a shortcut for the manual dismissal above, not as a mechanism you can wait on. Both platforms can act on the tap — by different mechanisms.

iOS — pass the same value as callbackURLScheme and the session intercepts the navigation and closes its own sheet. No association file, no entitlement:

let session = ASWebAuthenticationSession(
    url: authorizationUrl,
    callbackURLScheme: "live.traxstreaming.ios"   // == OAUTH_RETURN_APP_SCHEME_IOS
) { callbackURL, error in
    // callbackURL is live.traxstreaming.ios://oauth/linked?connected=youtube
    // when the interception fired; nil + canceledLogin when the user tapped
    // Cancel. Either way the flow ENDED — go to step 4.
    Task { await self.refreshConnections() }
}

Android — there is no callbackURLScheme equivalent; a Custom Tab does not hand navigations back to its opener. Instead you register the scheme as a deep link, and the navigation launches your activity, which brings your app back to the front over the tab:

<!-- AndroidManifest.xml — on the activity that handles the return -->
<activity
    android:name=".OAuthReturnActivity"
    android:launchMode="singleTask"
    android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="live.traxstreaming.android" android:host="oauth" />
  </intent-filter>
</activity>
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    // intent.data == live.traxstreaming.android://oauth/linked?connected=youtube
    viewModel.refreshConnections()   // step 4 — the params are a hint, not proof
}

singleTask is what makes this reuse your existing activity instead of stacking a second copy. The Custom Tab is left behind in the task stack rather than destroyed — acceptable, and the reason the page still tells the user how to close it themselves.

The query parameters are a hint, not a receipt, on both platforms: they say what the page displayed, and the page displays what the callback told it. Confirm against GET /v1/connections regardless — step 4 is not optional because the browser closed politely.

The two platforms are independent. OAUTH_RETURN_APP_SCHEME_IOS and OAUTH_RETURN_APP_SCHEME_ANDROID are separate values, and the page picks between them from the request's User-Agent — so you do not have to agree on a shared string with the other platform's team, and whichever app ships first can be switched on immediately without lighting up a dead control on the other. OAUTH_RETURN_APP_SCHEME_IOS is set in production as of 2026-09-01 (live.traxstreaming.ios); Android is still unset. Tell us your scheme when the release that registers it is out; turning it on is one environment variable, no redeploy, reversible if a release slips. (A single legacy OAUTH_RETURN_APP_SCHEME still works and seeds both — only correct if both apps really did register the same string.)

Universal Links / App Links (the eventual fix). Claiming connect.traxstreaming.live/oauth/return as a Universal Link (iOS apple-app-site-association + associated-domains entitlement) or an App Link (Android assetlinks.json + android:autoVerify="true") returns the user to your app without any custom scheme, works when the link is opened from outside your app, and — because it is an https:// URL — is per-app rather than a first-come-first-served global namespace. It needs an association file on our side for each app, and is not shipped yet; the custom-scheme route above makes it a nice-to-have rather than a blocker.

4. Find out what actually happened

There is no completion callback and no push. Your completion handler firing means the sheet closed — the user may have consented, cancelled, or given up.

So read the state instead:

GET /v1/connections
Authorization: Bearer sk_live_…

A successful link shows up as a new row for that platform, or — on a repair with reconnectConnectionId — as the existing row's status flipping back to active. Poll on your completion handler, and again when the app returns to the foreground; those two together cover the user who backgrounds the app mid-consent.

Match on state if you had more than one attempt open.

What to check before you call it done

  • A browser never sees JSON — success or failure. Cancel at the consent screen and confirm you land on the error page, not on a raw error body.
  • curl still gets the JSON it always got.
  • The flow works on a device with no platform app installed and on one where the user is already signed in — those take different paths through the consent screen.
  • Kick specifically, because it is the one platform using PKCE. The verifier never leaves the server, so nothing changes for your app, but it is the flow most likely to expose a wrong assumption about who completes the exchange.
  • Re-linking an existing connection keeps the same connection id and does not orphan its destinations.