Troubleshooting

Common CLI errors and runtime surprises, what each one means and how to fix it.

The most common failures, in two groups: errors the CLI prints, and runtime behavior that surprises people the first time.

CLI errors

Not a superwall project

The CLI couldn't find a project from where you ran it. Run commands from your app root or from inside superwall/, and check that the project's package.json depends on superwall. See Project structure.

…package.json is named "superwall"

Your project's package.json has "name": "superwall", which shadows the framework import. Nothing in the project can import from superwall anymore. Rename the package; superwall create names it after your app for exactly this reason.

No superwall framework found

The project exists but its dependencies aren't installed, or superwall isn't among them. Run bun add superwall (or npm install superwall) inside the project directory.

This product / These N products do not exist on Superwall

A config.ts names a product identifier the dashboard has no product for. The push refuses because every variable on that product would be undefined on device. Either fix the identifier, or create the products, from the dashboard, or with superwall products create from the CLI. See Products and the CLI reference.

Your account has several Superwall projects, and the command can't guess which one you mean. The error lists the first ten with their ids. Add --app <id>: an app id, or the app's dashboard link (https://app.superwall.com/<org>/<app>/…), opens it in any organization your account can open, with no --project needed. superwall apps list shows every app with its id. Project-scoped commands like products take --project <id>.

App <id> already has a paywall … that superwall.lock doesn't bind

The app already has a headless paywall with this surface's id, and superwall.lock doesn't bind it. Apps are shared and ids like onboarding are common, so push never takes one over on a name alone. If it's this project's paywall (the lock was lost, or a teammate pushed without committing it), add the line the error prints under paywalls in superwall.lock, or run superwall push interactively and confirm. If it isn't, rename the directory and push again: that creates a new paywall.

Diagnostics block the push

Publishing is immutable, so a paywall with diagnostics, a stray non-page file in app/, a duplicate route, refuses to push. The message names each file and where it belongs. These are the same warnings superwall dev prints, so you'll usually have seen them before push time.

A directory missing app/index.tsx or config.ts is the exception: it isn't a paywall yet, so push skips it silently instead of failing. If a paywall you expected didn't ship, check that both files exist. superwall dev lists what it found.

Rename ambiguity in CI

A renamed paywall directory can't be resolved interactively in CI, so the push stops rather than creating a duplicate. Add the --rename old=new flag the error prints. See Push, promote & publish.

x doesn't say which platforms it's for

The project pushes to several platforms, and this paywall's config.ts has no platforms field. Add platforms: ["ios", "android"] (or whichever apply). A push never guesses which app a paywall belongs to. See Configuration.

`annual` has no product id for web

The paywall ships to a platform (here web) that its per-platform product slot doesn't name. Add an id for that platform (annual: { ios: "…", android: "…", web: "…" }) or remove the platform from platforms. See Products.

Several iOS apps in your account / no Web app to bind

A config.ts names a platform superwall.lock doesn't bind yet, and your account has either several apps of that platform or none. Run superwall push interactively once to choose, add "web": "<app id>" under apps in superwall.lock yourself (superwall apps list --json shows ids), or create the app (superwall apps create --name "<name>" --platform web). See Several platforms.

This project has no app for "…"

--platform takes ios, android, or web: one the project already binds in superwall.lock (or that app's id); the message lists the ones it has.

paywall x has never been pushed (promote)

Promote only moves the live pointer between pushed versions. There's nothing to point at yet. Push first.

… version N has no source yet — run superwall push to finish it, then promote

A push created that version, but its source didn't reach Superwall (a network error, say), and a version is never promoted without its source. Run superwall push: it finishes that version's source before anything else, then promote works. publish does both.

Someone else published changes to … since this project last synced

A teammate pushed changes to the same files since your last push. Pull their commits into this project and push again. To replace their version with yours, push with --overwrite-source. See Source snapshots.

The source repo differs from this project in …, and superwall.lock records no sync point to merge from

The app's saved source has changes this project never synced with, and superwall.lock doesn't say where the project last synced, usually because it's an old copy of the lock. Pull the latest superwall.lock and push again, or push with --overwrite-source to replace the saved source with yours.

The source in … is N MB, over the 250 MB limit

The push stopped before creating any version. The message names the largest files; add what doesn't belong in the source to superwall/.gitignore and push again.

superwall publish requires git

The source snapshot is part of every push, so push and publish both need git, the message names publish whichever you ran. Install git.

Pushing paywalls needs a Superwall account

Run superwall login once interactively, or set SUPERWALL_API_KEY (an sk_… key) in CI. superwall dev needs no login.

Runtime surprises

Prices are example values in dev

The studio reads products from your dashboard when superwall dev is logged in. A slot the dashboard has no product for gets no variables, so prices render as whatever your own fallback shows — the studio will not stand in an invented price. Run superwall login if you haven't, then create the product on the dashboard or repoint the slot; push refuses until you do. Every read stays guarded and the unpriced state stays designed, because the SDK can still deliver a product before its data. The reading rules are in Products.

A number comparison works in dev but not on device

Numeric-looking variables are strings on a real device ("59.99", "7"), while a dashboard sample in the studio may carry numbers. A typeof x === "number" check silently fails on every phone. Coerce with Number() before arithmetic or comparison (Products).

My entry animation already finished when the paywall appears

The SDK preloads paywalls hidden, so components mount long before anyone is looking, a mount-timed animation plays to an empty room. Gate entry animations on presentation, not mount. See Lifecycle & events.

Dark mode looks right on my machine, wrong on device

The mechanism is the dark class the framework maintains on <html>, not prefers-color-scheme. A media query can't see what the device reports and ignores the studio's theme toggle. Style off the class, as shown in Styling.

My fetch to my own API never arrives

The server never saw it — the browser refused to send it. A published paywall runs under a Content-Security-Policy that permits Superwall and Stripe and nothing else, so a call to your own backend is blocked before any network activity: no DNS lookup, no request, nothing in your access logs. The only sign is a console error, Refused to connect because it violates the document's Content Security Policy, and the fetch promise rejecting with a bare TypeError: Failed to fetch.

Name the host in allowedHosts and push again:

superwall/funnels/onboarding/config.ts
export default definePaywall({
  allowedHosts: ["api.example.com"],
});

Two things make this hard to spot. A rejected fetch looks exactly like a network failure, so a catch written for offline handling swallows it. And if the call only happens on a page shoppers reach near the end of a flow, nothing fails until then. See Allowed hosts.

The paywall is blank on Android, but works on iOS and in Chrome

Something read localStorage or sessionStorage. In the Android SDK's WebView both are null. They aren't undefined, so typeof localStorage !== "undefined" passes and the next call throws Cannot read properties of null (reading 'getItem'). When that happens during a render or an effect, React unmounts the paywall: only its background shows and nothing can be tapped. iOS and Chrome have storage, so the same paywall works there.

Don't depend on browser storage. Keep state that spans pages in React; it lives for the whole presentation. Save what must outlast it with useUserAttribute, which the host persists and the same hook reads back. If storage holds something optional, read it inside a try/catch and treat a failure as unset:

const readFlag = () => {
  try {
    return localStorage.getItem("my_flag") === "true";
  } catch {
    return false;
  }
};

Framework versions 1.3.0 to 1.7.0 had this bug themselves, in the logger every message to the SDK passes through, so every code-built paywall was blank on Android. Upgrade with superwall upgrade, then push and promote again: a pushed version keeps the framework it was built with.

Inside a webview, an <a href> either does nothing or navigates the paywall away from itself. Open links through useActions().openUrl instead. See Actions.

Controls sit in the status bar / under the home indicator

The paywall is inset from the edges by default, so this happens for one of two reasons: insets in config.ts turned that off for the edge in question, or the control is position: fixed and positioned from a bare env(), which is 0 in previews, in Android WebView and in some webview contexts. Fixed chrome positions from var(--sw-safe-area-inset-top) and friends: the safe area where the host reports one, a floor for the device and presentation where it doesn't. Chrome inside the layout should not be fixed at all. See Insets.

The payment sheet doesn't open in dev

By design. A preview has no API key, so a Stripe product resolves through the studio's purchase prompt instead of the payment sheet, which lets you test the flow and copy without creating checkout sessions. Push and open the live URL to verify the checkout itself. See Web checkout.

How is this guide?

On this page