SDK quickstart: Swift (iOS / macOS)
Generate a typed Swift client for the /v1 Developer API from its OpenAPI spec
and make your first call.
No hand-written Swift SDK is published yet — you generate one from the live spec. An official SDK is a roadmap item.
Which credential? A shared API key (
sk_live_…) is right for your own automation and server-side jobs. A first-party iOS app a user logs into should authenticate as that user with OAuth 2.0 + PKCE and send the user's token — see Build a client. The steps below use an API key; swap in the user's bearer token for a logged-in app.
1. Get the spec
https://api-dev.traxstreaming.live/v1/openapi.json
2. Generate a typed client
Use openapi-generator:
brew install openapi-generator # or: npm i -g @openapitools/openapi-generator-cli
openapi-generator generate \
-i https://api-dev.traxstreaming.live/v1/openapi.json \
-g swift5 \
--additional-properties=responseAs=AsyncAwait,projectName=TraxAPI \
-o ./TraxAPI
Add the generated TraxAPI package to your Xcode project (drag it in, or add it
as a local Swift Package).
Prefer Apple's toolchain? The Swift OpenAPI Generator is a first-party alternative (add it as an SPM plugin and point it at the same spec).
3. Authenticate
Building an app a user logs into? Sign them in with OAuth 2.0 + PKCE and send their token — jump to Sign the user in (OIDC + PKCE) below.
Automating your own account? Create an API key from your account's
Developer / API Keys screen and copy the sk_live_… secret once. Store it
in the Keychain, never in source or Info.plist.
Sign the user in (OIDC + PKCE)
A first-party iOS app authenticates the user against the TRaX identity provider
with the Authorization-Code flow + PKCE, then calls /v1 with the user's
access token. The user types their password (and MFA / passkey, if enabled) on
the IdP's hosted login page shown in an ASWebAuthenticationSession — never
in your UI. See Authentication → User login for mobile & native
apps
for the concept; this is the concrete Swift.
Config (dev shown; swap the issuer + clientId for production):
enum TraxAuth {
// Dev IdP. Production: https://auth.traxstreaming.live
static let issuer = "https://auth-dev.traxstreaming.live"
static let authorize = "\(issuer)/oauth/v2/authorize"
static let token = "\(issuer)/oauth/v2/token"
static let endSession = "\(issuer)/oidc/v1/end_session"
// Public native client (no secret). Dev value; production has its own id.
static let clientId = "385862192278274086"
// Must match a redirect URI registered on the client, exactly.
static let redirectURI = "live.traxstreaming.ios://oauth/callback"
static let callbackScheme = "live.traxstreaming.ios"
static let scope = "openid profile email offline_access"
static let apiBase = "https://api-dev.traxstreaming.live"
}
Register
live.traxstreaming.iosas a URL scheme in your target (Info → URL Types), or use a Universal Link. The value above is a placeholder — use the scheme that matches your registered redirect URI.
PKCE helper — a random verifier and its S256 challenge:
import CryptoKit
import Foundation
struct PKCE {
let verifier: String
let challenge: String
init() {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
verifier = Data(bytes).base64URLEncodedString()
let digest = SHA256.hash(data: Data(verifier.utf8))
challenge = Data(digest).base64URLEncodedString()
}
}
extension Data {
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
Token models:
struct TokenResponse: Codable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int
let tokenType: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
case tokenType = "token_type"
}
}
The login round-trip — open the hosted page, capture the code, exchange it:
import AuthenticationServices
final class TraxLogin: NSObject, ASWebAuthenticationPresentationContextProviding {
func signIn() async throws -> TokenResponse {
let pkce = PKCE()
let state = UUID().uuidString
var comps = URLComponents(string: TraxAuth.authorize)!
comps.queryItems = [
.init(name: "response_type", value: "code"),
.init(name: "client_id", value: TraxAuth.clientId),
.init(name: "redirect_uri", value: TraxAuth.redirectURI),
.init(name: "scope", value: TraxAuth.scope),
.init(name: "code_challenge", value: pkce.challenge),
.init(name: "code_challenge_method", value: "S256"),
.init(name: "state", value: state),
]
// Open the IdP's hosted login; the browser redirects to our custom
// scheme with ?code=…&state=… once the user authenticates.
let callbackURL: URL = try await withCheckedThrowingContinuation { cont in
let session = ASWebAuthenticationSession(
url: comps.url!,
callbackURLScheme: TraxAuth.callbackScheme
) { url, error in
if let url { cont.resume(returning: url) }
else { cont.resume(throwing: error ?? URLError(.badServerResponse)) }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // keep IdP SSO cookie
session.start()
}
let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
guard items?.first(where: { $0.name == "state" })?.value == state,
let code = items?.first(where: { $0.name == "code" })?.value else {
throw URLError(.userAuthenticationRequired)
}
return try await exchange(code: code, verifier: pkce.verifier)
}
private func exchange(code: String, verifier: String) async throws -> TokenResponse {
var req = URLRequest(url: URL(string: TraxAuth.token)!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
req.httpBody = form([
"grant_type": "authorization_code",
"client_id": TraxAuth.clientId,
"code": code,
"redirect_uri": TraxAuth.redirectURI,
"code_verifier": verifier, // PKCE — stands in for a secret
])
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
private func form(_ params: [String: String]) -> Data {
params.map { k, v in
"\(k)=\(v.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? v)"
}.joined(separator: "&").data(using: .utf8)!
}
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor() // return your key window's anchor in a real app
}
}
Silent refresh — before the access token expires, mint a new one with the refresh token (no browser):
func refresh(refreshToken: String) async throws -> TokenResponse {
var req = URLRequest(url: URL(string: TraxAuth.token)!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let body = [
"grant_type": "refresh_token",
"client_id": TraxAuth.clientId,
"refresh_token": refreshToken,
].map { "\($0)=\($1)" }.joined(separator: "&")
req.httpBody = body.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
Store tokens in the Keychain (never UserDefaults / source):
import Security
enum Keychain {
static func set(_ value: String, for key: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
SecItemDelete(query as CFDictionary)
var add = query; add[kSecValueData as String] = data
SecItemAdd(add as CFDictionary, nil)
}
static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
]
var out: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
Log out — clear stored tokens and, to end the IdP session, open the
end-session endpoint (\(TraxAuth.endSession)?client_id=…&post_logout_redirect_uri=…).
The access_token you get back is a JWT — send it as
Authorization: Bearer <access_token> on your /v1 calls (this is the
credential /v1 accepts as the user; the media plane's SRT/WHIP/WHEP surfaces
use a separate session token — see
the two credential systems). Read the
note on token type and audience
before you swap clients: /v1 validates the JWT against Zitadel and does not yet
check the audience claim.
4. First call
import TraxAPI
import Foundation
// The generated client exposes the base path; set the auth header globally.
TraxAPIAPI.basePath = "https://api-dev.traxstreaming.live"
TraxAPIAPI.customHeaders = [
"Authorization": "Bearer \(apiKeyFromKeychain)" // or the user's OAuth token
]
// List your studios (requires the studios:read scope)
do {
let page = try await StudiosAPI.listStudios(limit: 50)
for studio in page.data {
print(studio.id, studio.name)
}
if let cursor = page.nextCursor {
// pass `cursor:` to fetch the next page — see /reference/pagination
_ = cursor
}
} catch {
// Errors share one envelope: { error: { code, message, requestId } }.
// Decode the response body to read error.code and branch on it.
print("request failed:", error)
}
Generated method and model names (listStudios, StudiosAPI, nextCursor)
come straight from the spec's operationIds and schemas, so they'll match what
you see in the API reference.
Next
- Errors — decode
error.code; retry only transient codes. - Pagination · Rate limits.
- Build a client — OAuth (PKCE) login, plus WebSocket control and WebRTC (WHIP/WHEP) media for a full producing app on iOS.