Showing a flow depends on the network, your configuration, and a valid flow schema, so it can fail. FlowPilot is built so that a failure never crashes your app or leaves a blank screen, but you still decide what the user sees when there is no flow to show. This page covers the error type, the error codes, and the resilient presentation path you should use for anything user-facing.
The error type
Every failure the SDK surfaces is a FlowPilotError:
public struct FlowPilotError: Error, Sendable, CustomStringConvertible {
public let code: FlowPilotErrorCode
public let message: String
public let underlyingError: Error?
public let context: [String: String]?
public var isClientError: Bool // true for a 4xx, do not retry
public var isServerError: Bool // true for a 5xx, may be worth retrying
}
isClientError and isServerError read a status_code from context when present. Use them to decide whether a retry could help: a client error (bad config, bad request) will not fix itself, a server error might.
Error codes
FlowPilotErrorCode is a string enum. The full set:
| Code | Raw value | Category |
|---|
invalidApiKey | invalid_api_key | Configuration |
invalidAppId | invalid_app_id | Configuration |
sdkNotInitialized | sdk_not_initialized | Configuration |
networkError | network_error | Network |
apiError | api_error | Network |
timeout | timeout | Network |
rateLimited | rate_limited | Network |
placementNotFound | placement_not_found | Resolution |
flowNotFound | flow_not_found | Resolution |
targetingNotMet | targeting_not_met | Resolution |
frequencyLimitReached | frequency_limit_reached | Resolution |
unsupportedSchemaVersion | unsupported_schema_version | Schema |
invalidFlowSchema | invalid_flow_schema | Schema |
componentRenderError | component_render_error | Rendering |
customComponentNotFound | custom_component_not_found | Rendering |
customScreenNotFound | custom_screen_not_found | Rendering |
navigationError | navigation_error | Runtime |
variableError | variable_error | Runtime |
actionError | action_error | Runtime |
fatalActionError | fatal_action_error | Runtime |
actionChainTimeout | action_chain_timeout | Runtime |
internalError | internal_error | Internal |
A few worth calling out:
flowNotFound means the placement resolved but no flow matched (no published flow, or targeting and frequency rules excluded the user). This is a normal, expected outcome, not a bug. Show your own UI or nothing.
placementNotFound means the placement id does not exist. That is a configuration mistake, so check the id against the dashboard.
timeout means the resolve did not finish within resolveTimeout. The SDK falls back to cache or bundled flows first; you only see a thrown timeout if nothing was cached.
Where errors actually surface
There are three reliable ways to observe errors today:
- Thrown from the throwing APIs.
presentPlacement(_:from:options:) and createSession(...) are throws. Wrap them in do/catch and inspect the FlowPilotError.
- As a
.error outcome on FlowResult. When a flow ends in error, result.outcome == .error and result.error holds the FlowPilotError. See Results and outcomes.
- Never thrown, by design. The fallback overloads below never throw. They return a
FlowResult (with .error if they had to fall back to your own UI) or nil.
setErrorCallback(_:) exists and is public, but in the current SDK build the stored closure is never invoked anywhere in the SDK (it is assigned and then not read). Do not rely on it to receive errors yet. Handle errors through the thrown FlowPilotError and FlowResult.error instead.The same applies to the FlowPilotDelegate protocol: it is declared public but has no setter and is never called. Use the closure-based APIs and the resilient overloads, not the delegate.TODO: re-check setErrorCallback / FlowPilotDelegate wiring on each SDK update and document them as live once the SDK invokes them.
The resilient path
For anything a user sees, prefer the presentation overloads that never throw. They walk the full fallback chain (fresh cache, then a live resolve bounded by resolveTimeout, then stale cache, then a bundled default flow) before giving up:
// UIKit: never throws. Presents your own view controller if FlowPilot has nothing.
@discardableResult
public func presentPlacement(
_ placementKey: String,
from viewController: UIViewController,
fallback: @escaping @MainActor () -> UIViewController,
options: PresentationOptions? = nil
) async -> FlowResult
// SwiftUI: returns nil when FlowPilot has nothing presentable.
public func resolveSession(
placementKey: String,
additionalContext: SDKContext? = nil,
preloadMedia: Bool? = nil
) async -> FlowSession?
With these, a network failure cannot block onboarding: the user gets a cached flow, a bundled default, or your own native screen. See Offline and bundled flows, Caching, and Prefetching.
Example
If you use the throwing API, catch the error and branch on its code. Treat configuration errors differently from transient ones:
do {
let result = try await FlowPilot.shared?.presentPlacement(
"onboarding",
from: self
)
// handle result.outcome here
} catch let error as FlowPilotError {
switch error.code {
case .flowNotFound, .targetingNotMet, .frequencyLimitReached:
// Expected: no flow for this user. Show your own UI or nothing.
showNativeOnboarding()
case .placementNotFound, .invalidApiKey, .sdkNotInitialized:
// Configuration problem. Log it; this needs a code or dashboard fix.
logger.error("FlowPilot config error: \(error)")
showNativeOnboarding()
case .timeout, .networkError, .rateLimited:
// Transient. Fall back now; the next launch may succeed.
showNativeOnboarding()
default:
showNativeOnboarding()
}
} catch {
showNativeOnboarding()
}
The cleaner option is to let the SDK do this for you with the never-throwing overload:
await FlowPilot.shared?.presentPlacement(
"onboarding",
from: self,
fallback: { MyNativeOnboardingViewController() }
)
Custom actions
The editor lets builders define a custom action, and the runtime can dispatch it, but there is no public API on the SDK to register a native handler for a custom action (the registration method exists but is internal). If you need native behavior from a flow, use a custom component that emits an event and wire the action to that event. An unregistered custom action is skipped with a warning, not an error.
Common mistakes
- Not handling the no-flow path.
flowNotFound, targetingNotMet, and frequencyLimitReached are normal. Always have something to show (your own UI or nothing), not a broken state.
- Treating every error the same. A
placementNotFound (your config is wrong) is not a networkError (try again later). Branch on the code, or use isClientError / isServerError.
- Surfacing raw errors to users.
FlowPilotError.message is for your logs, not your UI. Map codes to user-facing behavior.
- Relying on
setErrorCallback. It does not fire in the current build. Use thrown errors and FlowResult.error.
- Using the throwing API on a critical path with no fallback. For onboarding and paywalls, use the never-throwing overload so a network blip cannot strand the user.
Troubleshooting
| Code | Likely cause | Fix |
|---|
invalidApiKey | Key does not start with fp_, or is for the wrong environment | Check the key and environment in Configuration. |
sdkNotInitialized | A call before FlowPilot.configure(...) | Configure once at launch, before any present or resolve. |
placementNotFound | Wrong placement id | Match the id to the one in the dashboard exactly. |
flowNotFound | No published flow, or targeting / frequency excluded the user | Expected. Publish and attach a flow, or show your own UI. |
timeout / networkError | Slow or no network and nothing cached | Use the resilient overload, prefetch at launch, and ship a bundled default. |
unsupportedSchemaVersion | Flow uses a newer schema than the SDK supports | Update the app’s SDK version. |
customComponentNotFound | A custom component was not registered, or key/version mismatch | Register the exact key and version before presenting. See Custom components. |
Related pages