> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getflowpilot.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Error handling

> Resolving and presenting a flow can fail. Handle errors gracefully, tell config problems from transient ones, and always have a fallback.

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`:

```swift theme={null}
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:

1. **Thrown from the throwing APIs.** `presentPlacement(_:from:options:)` and `createSession(...)` are `throws`. Wrap them in `do/catch` and inspect the `FlowPilotError`.
2. **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](/ios-sdk/results-and-outcomes).
3. **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`.

<Warning>
  `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.
</Warning>

## 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:

```swift theme={null}
// 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
```

```swift theme={null}
// 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](/ios-sdk/offline-bundled-flows), [Caching](/ios-sdk/caching), and [Prefetching](/ios-sdk/prefetching).

## Example

If you use the throwing API, catch the error and branch on its code. Treat configuration errors differently from transient ones:

```swift theme={null}
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:

```swift theme={null}
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](/ios-sdk/custom-components) 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](/ios-sdk/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](/ios-sdk/custom-components). |

## Internal error reporting

When the SDK hits one of its **own** internal failures — a flow that fails to resolve after every fallback, a schema it can't decode, or a render failure — it sends a small scoped report to FlowPilot so we can find and fix it. This is **not** a crash reporter:

* It installs **no** global crash or signal handlers and never intercepts your app's crashes. It only forwards errors the SDK itself caught.
* It does not touch or conflict with your own Sentry / Crashlytics.
* It is fire-and-forget and best-effort: it never throws, blocks, retries aggressively, or changes what the user sees. Reports are de-duplicated and capped per app launch.

**What's sent:** the error code and message, the SDK version, the device / OS version, the anonymous SDK user and session ids, and flow / placement identifiers for context. No end-user PII beyond the anonymous ids the SDK already generates.

**Opt out:** set `disableErrorReporting: true` in your configuration and nothing is sent.

```swift theme={null}
FlowPilot.configure(
    FlowPilotConfiguration(
        apiKey: "fp_...",
        appId: "app_ios_main",
        disableErrorReporting: true // no internal error reports leave the device
    )
)
```

## Related pages

* [Configuration](/ios-sdk/configuration)
* [Presenting placements](/ios-sdk/presenting-placements)
* [Results and outcomes](/ios-sdk/results-and-outcomes)
* [Offline and bundled flows](/ios-sdk/offline-bundled-flows)
* [Caching](/ios-sdk/caching)
