guides

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.ios as 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.