Skip to content

Callbacks for mobile and desktop apps

A Mobile or desktop app receives the member back from STX at a callback URL (redirect URI). STX accepts three styles for this application type. Each works with the same flow, and an app can register more than one.

Style Registered callback What you host If the app is not installed
Universal links (iOS) / App Links (Android) https://example.com/stx/callback apple-app-site-association and assetlinks.json on your domain The link opens your website
Custom scheme com.example.app:/callback Nothing Nothing handles the link
Loopback http://127.0.0.1/callback (or localhost, [::1]) Nothing; the app listens on the device Not applicable: the app itself is listening
  • Authorization code with PKCE, no client secret. A Mobile or desktop app has no client_secret and cannot use client_credentials. It sends its client_id in the token request and proves itself with the PKCE verifier. The requests themselves are on Authorization code flow; read the endpoints from discovery.
  • Exact match. The redirect_uri you send must match a callback registered for your app in STX Apps (apps.stxapp.io), byte for byte, at both the authorize and token steps. The one exception is loopback, below, which matches on any port. A callback has no fragment.
  • The callback carries the result in its query. code and state on Allow, error and state otherwise. Check state before you exchange the code. See Receive the code.

In the examples below, authorizeURL is the authorize request from step 2 of the flow, with redirect_uri set to the registered callback shown beside each example.

An ordinary https URL on a domain you control. You publish a file on that domain naming your app, and the operating system then opens your app for that URL instead of the browser. Without the app installed, the same URL loads in the browser, so put a page there.

Registered callback: https://example.com/stx/callback

Add the associated domains entitlement. applinks lets iOS open the app for the URL; webcredentials is what ASWebAuthenticationSession checks for an https callback.

<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
<string>webcredentials:example.com</string>
</array>

Serve this at https://example.com/.well-known/apple-app-site-association, as application/json, with no redirect. ABCDE12345 is your Apple team ID.

{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.app"],
"components": [{ "/": "/stx/callback" }]
}
]
},
"webcredentials": {
"apps": ["ABCDE12345.com.example.app"]
}
}

On iOS 17.4 and macOS 14.4 and later, ASWebAuthenticationSession takes an https callback and returns the URL to your completion handler:

import AuthenticationServices
import UIKit
final class STXConnect: NSObject, ASWebAuthenticationPresentationContextProviding {
private let window: UIWindow
private var session: ASWebAuthenticationSession?
init(window: UIWindow) { self.window = window }
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { window }
func connect(authorizeURL: URL) {
let session = ASWebAuthenticationSession(
url: authorizeURL,
callback: .https(host: "example.com", path: "/stx/callback")
) { callbackURL, error in
guard let callbackURL,
let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
else { return }
let code = query.first { $0.name == "code" }?.value
let state = query.first { $0.name == "state" }?.value
// Check state, then exchange code with your code_verifier.
}
session.presentationContextProvider = self
self.session = session
session.start()
}
}

When the link opens the app from outside a session, it arrives in SwiftUI’s .onOpenURL (or scene(_:continue:) in a scene delegate):

ContentView()
.onOpenURL { url in
guard url.host == "example.com", url.path == "/stx/callback" else { return }
// Read code and state from url, as above.
}

Declare an autoVerify intent filter for the URL on the activity that handles the callback:

<activity
android:name=".StxCallbackActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" android:path="/stx/callback" />
</intent-filter>
</activity>

Serve this at https://example.com/.well-known/assetlinks.json, with your package name and the SHA-256 fingerprint of your signing certificate:

[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]

Open the authorize request in a Custom Tab (androidx.browser:browser), and read the callback in the activity:

fun connect(context: Context, authorizeUrl: Uri) {
CustomTabsIntent.Builder().build().launchUrl(context, authorizeUrl)
}
class StxCallbackActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handle(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handle(intent)
}
private fun handle(intent: Intent) {
val uri = intent.data ?: return
val code = uri.getQueryParameter("code")
val state = uri.getQueryParameter("state")
// Check state, then exchange code with your code_verifier.
}
}

A scheme only your app claims, in reverse-domain form: it must contain a dot, such as com.example.app. A scheme without a dot (myapp://callback) and web schemes such as http, https, javascript or data are refused. There is nothing to host.

Registered callback: com.example.app:/callback

ASWebAuthenticationSession with callbackURLScheme catches the redirect to the scheme. The app does not need to declare the scheme in Info.plist for this.

import AuthenticationServices
import UIKit
final class STXConnect: NSObject, ASWebAuthenticationPresentationContextProviding {
private let window: UIWindow
private var session: ASWebAuthenticationSession?
init(window: UIWindow) { self.window = window }
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { window }
func connect(authorizeURL: URL) {
let session = ASWebAuthenticationSession(
url: authorizeURL,
callbackURLScheme: "com.example.app"
) { callbackURL, error in
guard let callbackURL,
let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
else { return }
let code = query.first { $0.name == "code" }?.value
let state = query.first { $0.name == "state" }?.value
// Check state, then exchange code with your code_verifier.
}
session.presentationContextProvider = self
self.session = session
session.start()
}
}

Declare the scheme on the activity that handles the callback:

<activity
android:name=".StxCallbackActivity"
android:exported="true"
android:launchMode="singleTask">
<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="com.example.app" />
</intent-filter>
</activity>

Open the authorize request in a Custom Tab and read the callback exactly as in the App Links example above: connect() and StxCallbackActivity are the same, and intent.data is com.example.app:/callback?code=…&state=….

The app listens on the device’s loopback interface and the browser redirects to it. This suits desktop apps and command-line tools, and local development of any app. Register the callback without a port: STX matches a loopback callback on any port, as long as the scheme, host, path and query are the same. Bind whichever port is free when the app runs.

Registered callback: http://127.0.0.1/callback

STX accepts http://localhost, http://127.0.0.1 and http://[::1] for a Mobile or desktop app in production as well as in development.

A one-shot listener on a free port, using the open package to launch the browser:

import http from 'node:http'
import open from 'open'
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1')
if (url.pathname !== '/callback') return res.writeHead(404).end()
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
res.end('Connected. You can close this window.')
server.close()
// Check state, then exchange code with your code_verifier.
})
server.listen(0, '127.0.0.1', () => {
// For example http://127.0.0.1:53121/callback
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`
// buildAuthorizeUrl: your authorize request, with this redirect_uri.
open(buildAuthorizeUrl(redirectUri))
})

Send the same redirect_uri, port included, to the token endpoint.