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-returnTopath 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_IOSandOAUTH_RETURN_APP_SCHEME_ANDROIDare 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_IOSis 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 legacyOAUTH_RETURN_APP_SCHEMEstill 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.
curlstill 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.