Skip to main content
When a flow does not show up or does not look right, the cause is usually one of a handful of things: the SDK is not configured, the placement has no flow for this user, the network is slow with nothing cached, or an asset was not uploaded. This page walks the common symptoms. For the error type and codes themselves, see Error handling.
Before anything else, raise the log level. Set logLevel: .debug (or .verbose) in your FlowPilotConfiguration, or call FlowPilot.shared?.enableDebugOverlay() at runtime. The SDK logs each resolve, the delivery source (cache, network, stale cache, or bundled), and why a flow was rejected. Most issues below are obvious once logging is on. See Configuration.

Common problems

A present call that returns without showing anything almost always means FlowPilot had no flow to show, or the SDK was never set up. Work through these in order:
  1. The SDK is not configured. If FlowPilot.configure(...) never ran (or ran with a key that does not start with fp_), FlowPilot.shared is nil and FlowPilot.shared?.presentPlacement(...) silently does nothing. Check the log for FlowPilot SDK initialized - v1.0.0. If you see Invalid API key format instead, the key is wrong. See Configuration.
  2. Wrong App ID. The flow, placement, and key must all belong to the same app. A mismatched appId resolves against the wrong app and finds nothing.
  3. The placement has no flow for this user. The placement may be paused, have no default flow attached, or exclude this user by audience targeting or frequency rules. The resolve returns “no flow” and you get a flowNotFound (or targetingNotMet / frequencyLimitReached) error. This is expected, not a bug. Check the placement in the dashboard. See Placements and Audience targeting.
  4. The flow is a draft, or is not attached. Only a published flow that is attached to the placement is served. Publishing alone does not attach it. See Publishing.
  5. The platform excludes iOS. A placement gates which platforms it serves. If iOS is not included, the resolve returns no flow for an iOS device.
  6. Offline with a cold cache. With no network, no cached flow, and no bundled default, there is nothing to present and the resolve throws.
Fix: turn on .debug logging and read which step fails. For anything user-facing, use the never-throwing overload so the user always sees something:
await FlowPilot.shared?.presentPlacement(
    "onboarding",
    from: self,
    fallback: { MyNativeOnboardingViewController() }
)
FlowPilot.shared is nil. Causes:
  • FlowPilot.configure(...) was never called.
  • It was called with an API key that does not start with fp_. The SDK logs Invalid API key format and leaves shared as nil.
  • A present or resolve call ran before configure(...) finished (for example, from a view that loads earlier than your app’s launch code).
Fix: call FlowPilot.configure(...) exactly once, as early as possible (the SwiftUI App initializer or application(_:didFinishLaunchingWithOptions:)), before any present or resolve. See Configuration and Quickstart.
The server rejected the key with a 401 during a resolve. Common causes:
  • Wrong key type. You must use a workspace SDK key, not a dashboard or secret key. See API keys.
  • Wrong workspace or app. The key belongs to a different workspace than the flow and placement.
  • Wrong environment. A key minted for one environment will not work against another. Match environment in FlowPilotConfiguration to where the key and flows live.
Fix: copy the SDK key again from the dashboard, confirm the appId matches, and confirm environment points at the right backend. Note that a key with the wrong prefix never gets this far: it fails the fp_ check at configure(...) time instead.
The live resolve did not finish within resolveTimeout (default 4.0 seconds) and there was nothing cached or bundled to fall back to, so the call threw timeout.Fixes, in order of impact:
  • Ship a bundled default flow so the first present always has something offline. See Offline and bundled flows.
  • Prefetch at launch so the placement is warm before the user reaches it. Declare it once in config with prefetchOnLaunch: ["onboarding"], or call await FlowPilot.shared?.prefetch(["onboarding"]) (add warmMedia: true to also warm first-screen images). See Prefetching.
  • Use the never-throwing present overload (fallback:) so a timeout falls through to cache, then bundled, then your own UI instead of throwing.
  • Raise resolveTimeout only if your audience is on consistently slow networks. It bounds the entire resolve, so a higher value means a longer possible wait. The minimum is 0.5.
See Caching for how the cache and timeout interact.
The flow references a custom component the SDK could not find, so it shows a placeholder instead of your view. Causes:
  • Not registered. You never called registerCustomComponent(key:version:definition:), or you registered it after presenting. Register before you present.
  • Key mismatch. The key you registered does not match the component key in the flow exactly (it is case-sensitive).
  • Version mismatch. The SDK matches on key + version. If the flow uses version 2 and you registered version 1, it will not match (a version-1 registration also answers the bare, unversioned key, but not other versions).
Fix: register the exact key and version the flow uses, before presenting. The editor’s component contract shows both. See Custom components.
A flow renders with the wrong typeface when the SDK cannot resolve the font:
  • The font was not uploaded. Custom fonts must be uploaded to the workspace in the dashboard so the resolve response includes them. See Custom fonts.
  • Family, weight, or style mismatch. The flow references a family/weight/style combination that does not exist among the uploaded variants, so it falls back to the system font.
  • Offline with no bundled font assets. A bundled (offline) flow only has its fonts when you ship them in the asset bundle. Without a fonts entry in the .flowassets manifest, an offline render uses the system font.
Fix: upload the exact families and weights the flow uses, and for offline support include the fonts in the bundled asset manifest. See Offline and bundled flows.
Online, images stream from the CDN and get cached. Offline, an image only appears if its bytes are already in the cache or shipped with the flow.
  • No bundled assets. A bundled default flow with no image assets shows blank images offline. Ship the images in the .flowassets folder and declare them in manifest.json so the SDK seeds the image cache before first paint.
  • Cold cache. If the user has never seen the flow online, nothing is cached yet. Preload or prefetch while the network is available.
Fix: for guaranteed offline rendering, register the bundled flow with its assets. See Offline and bundled flows and Media preloading.
Two things can keep an old flow on screen:
  • Cache TTL. The SDK serves a fresh cached flow without hitting the network until it goes stale. The freshness window comes from the server’s cache_ttl_seconds (the SDK defaults to 300 seconds when the server omits it). Until then, the cached copy is used. Call FlowPilot.shared?.clearCache() to force a re-resolve during testing.
  • The placement still points at the old version. Publishing a new flow version does not automatically repoint a placement. Until you attach the new version to the placement, the resolve keeps returning the old one. This is the most common cause. See Publishing.
Fix: attach the new version to the placement in the dashboard, then clear the cache (or wait out the TTL) to see it on device.
Variant assignment is deterministic and sticky: the backend buckets a user by their user id, so the same user gets the same variant every time. If a user keeps flipping variants, their user id is not stable.The SDK generates a stable per-install user id and stores it in the Keychain, so it normally survives app restarts. Switching usually means that id is being reset: a fresh install on a wiped simulator, a cleared Keychain, or testing across multiple devices. There is no public API to set a custom user id in v1.0.0, so you cannot pin it yourself yet.Fix: test variant stickiness on a real device or a persistent simulator without resetting it between launches. See Variables and SDK context and Reading experiment results.
Raise the log level. The SDK logs the resolve path, the delivery source, font and image loading, and validation failures.
FlowPilot.configure(
    FlowPilotConfiguration(
        apiKey: "fp_live_...",
        appId: "your-app-id",
        environment: .production,
        logLevel: .debug   // or .verbose for the most detail
    )
)
At runtime you can also call FlowPilot.shared?.enableDebugOverlay() (raises the level to .verbose) and FlowPilot.shared?.logState() to print the current user id, session id, environment, and registered component counts. Set FlowPilot.debugBordersEnabled = true to draw borders around rendered components when debugging layout.

When you cannot reproduce it

  • Test the resilient path. Turn on Airplane Mode and present. With the never-throwing overload and a bundled flow, the user should still see something. If they see a blank screen, you are missing a bundled default or its assets.
  • Confirm the delivery source. The delivery_source on analytics events (and the debug logs) tells you whether a render came from the network, cache, stale cache, or bundled default. A surprising source often explains a surprising flow.
  • Check the dashboard first. Most “nothing shows” cases are a paused placement, an unattached flow, or a targeting rule, not an SDK bug.