Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.prelude.so/llms.txt

Use this file to discover all available pages before exploring further.

Request a scope

Start a step-up flow for a given scope. The returned challenge tells you what the server wants next — typically an OTP delivery step (verify_email or verify_sms). Code delivery is caller-driven: you pass the challenge to sendStepUpOTP when your UI is ready (see the next section).
import PreludeSession

do {
    let challenge = try await client.requestStepUp(scope: "transfer:write")

    switch challenge.status {
    case .blocked:
        // Your hook denied this scope.
    case .continue, .underReview:
        // Drive `challenge.currentStep` (typically "verify_sms" or
        // "verify_email") and call submitStepUpOTP below.
        break
    }
} catch PreludeSessionError.forbidden {
    // Scope is not in the allowed_scopes configuration.
}
StepUpChallenge is a value type — each caller holds its own copy, so concurrent step-up flows on a single client don’t share state. The most recent challenge is also available for diagnostics:
let active = await client.activeStepUp
The challenge exposes the following fields:
FieldTypeDescription
statusStepUpStatus.continue, .underReview, or .blocked.
challengeIDStringServer-side identifier for this attempt.
currentStepString?Next server step (e.g. "verify_sms", "verify_email", "completed"). nil when blocked.
requestedScopeStringThe scope passed to requestStepUp.

Send the step-up OTP

When challenge.currentStep is verify_email or verify_sms, fire delivery by passing the challenge to sendStepUpOTP. The SDK posts to /otp and the user receives the code on the corresponding identifier.
if challenge.currentStep == "verify_email"
    || challenge.currentStep == "verify_sms" {
    try await client.sendStepUpOTP(challenge)
}
Throws PreludeSessionError.invalidChallengeToken if challenge is blocked (no token to sign with).
Code delivery is intentionally caller-driven on iOS, Android, and Flutter: the SDK doesn’t fire /otp on its own, so your UI controls timing and can expose a “Resend code” affordance by calling sendStepUpOTP again with the same challenge.

Submit an OTP step

Submit the code the user entered. The SDK signs /otp/check with a challenge-scoped DPoP proof, advances the challenge, and — when the flow reaches completed — automatically refreshes the session so the next access token carries the granted scope. When submitStepUpOTP returns a non-null next challenge whose currentStep is another OTP delivery (for example, verify_email → verify_sms), call sendStepUpOTP(next) again to fire the next code before prompting the user.
do {
    let next = try await client.submitStepUpOTP(challenge, code: "123456")

    if let next {
        // Multi-step flow: drive `next.currentStep` and call
        // submitStepUpOTP again with `next`.
    } else {
        // Flow complete. The session has been refreshed and the
        // new access token includes `transfer:write`.
    }
} catch PreludeSessionError.invalidOTPCode {
    // Wrong code — you can keep retrying with the same `challenge`
    // until the server's retry limit is reached.
} catch PreludeSessionError.invalidChallengeToken {
    // Challenge expired or unusable. Restart with requestStepUp.
}

Automatic completion

When the last step is completed, the SDK:
  1. Refreshes the session with the challenge token, minting an access token that carries the granted scope. Concurrent refresh() callers piggyback on the same in-flight refresh.
  2. Clears the active step-up handle.
  3. Returns no further step from submitStepUpOTP to signal the flow is done.
After that, any protected call you make uses the scoped token automatically.
This example builds on the project from Introduction. Make sure you have a working OTP login first (OTP Login).1. Create a mock hookGo to mockerapi.com and create a mock API that returns the following JSON on POST:
{
  "status": "review",
  "granted_for": 300,
  "grant_mode": "single-use",
  "steps": [
    {
      "order": 1,
      "key": "verify_sms",
      "expiration_duration": 600
    }
  ]
}
Copy the generated mock URL.2. Configure step-up
curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \
  -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "jwks_url": "https://example.com/.well-known/jwks.json",
    "step_keys": [],
    "allowed_scopes": [
      {
        "scope": "transfer:write",
        "mode": "delegated",
        "delegated": { "delegation_hook": "YOUR_MOCK_URL" }
      }
    ]
  }'
3. Register the scope
curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \
  -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{ "scope": "transfer:write" }'
4. Replace your entry file
Replace ContentView.swift:
ContentView.swift
import SwiftUI
import PreludeSession

private let appID = "YOUR_APP_ID"

enum Stage { case login, code, logged, stepUpOTP, done }

@MainActor
final class StepUpModel: ObservableObject {
    let client = try! PreludeSessionClient(
        endpoint: .custom("https://\(appID).session.prelude.dev")
    )

    @Published var view: Stage = .login
    @Published var error: String?
    @Published var profile: PreludeProfile?

    var challenge: StepUpChallenge?

    func sendLoginOTP(to phone: String) async {
        error = nil
        do {
            try await client.startOTPLogin(
                StartOTPLoginOptions(
                    identifier: PreludeIdentifier(type: .phoneNumber, value: phone)
                )
            )
            view = .code
        } catch { self.error = error.localizedDescription }
    }

    func checkLoginOTP(_ code: String) async {
        error = nil
        do {
            let user = try await client.checkOTP(code)
            profile = user.profile
            view = .logged
        } catch PreludeSessionError.invalidOTPCode {
            error = "Wrong code."
        } catch { self.error = error.localizedDescription }
    }

    func requestStepUp() async {
        error = nil
        do {
            let c = try await client.requestStepUp(scope: "transfer:write")
            if c.status == .blocked {
                error = "Your hook denied this scope."
                return
            }
            if c.currentStep == "verify_email" || c.currentStep == "verify_sms" {
                try await client.sendStepUpOTP(c)
            }
            challenge = c
            view = .stepUpOTP
        } catch { self.error = error.localizedDescription }
    }

    func submitStepUpOTP(_ code: String) async {
        guard let c = challenge else { return }
        error = nil
        do {
            if let next = try await client.submitStepUpOTP(c, code: code) {
                if next.currentStep == "verify_email" || next.currentStep == "verify_sms" {
                    try await client.sendStepUpOTP(next)
                }
                challenge = next
            } else {
                // Completed — refresh to pick up the scoped token.
                let user = try await client.refresh()
                profile = user.profile
                view = .done
            }
        } catch PreludeSessionError.invalidOTPCode {
            error = "Wrong code."
        } catch { self.error = error.localizedDescription }
    }
}

struct ContentView: View {
    @StateObject private var model = StepUpModel()
    @State private var phone = ""
    @State private var loginCode = ""
    @State private var stepUpCode = ""

    var body: some View {
        VStack(spacing: 12) {
            Text("Step-Up Demo").font(.title3)
            if let error = model.error {
                Text(error).foregroundStyle(.red).font(.caption)
            }

            switch model.view {
            case .login:
                TextField("+14155551234", text: $phone).keyboardType(.phonePad)
                Button("Send OTP") {
                    Task { await model.sendLoginOTP(to: phone) }
                }.buttonStyle(.borderedProminent)
            case .code:
                TextField("Login code", text: $loginCode).keyboardType(.numberPad)
                Button("Verify") {
                    Task { await model.checkLoginOTP(loginCode) }
                }.buttonStyle(.borderedProminent)
            case .logged:
                Text("Logged in.").font(.headline)
                Button("Request transfer:write") {
                    Task { await model.requestStepUp() }
                }.buttonStyle(.borderedProminent)
            case .stepUpOTP:
                Text("Enter the step-up code.")
                TextField("Code", text: $stepUpCode).keyboardType(.numberPad)
                Button("Verify") {
                    Task { await model.submitStepUpOTP(stepUpCode) }
                }.buttonStyle(.borderedProminent)
            case .done:
                Text("Scope granted.").font(.headline)
                if let p = model.profile {
                    Text("session: \(p.sessionID ?? "-")")
                        .font(.caption.monospaced())
                }
            }
        }
        .textFieldStyle(.roundedBorder)
        .padding()
    }
}
Build and run, log in with your phone number, then tap Request transfer:write. You’ll receive a second OTP to complete the challenge. After verification, the access token will include the transfer:write scope.