# Superwall: Subscription Infrastructure for iOS, Android, and Web

Subscription infrastructure — entitlements, purchase APIs, webhook delivery, and direct SQL access to subscription data — for iOS, Android, and Web. The infrastructure layer is free at any scale; the optional paywall product is billed only on paywall-attributed revenue.

## Pricing

- **Infrastructure: free at any scale, every plan.** No revenue threshold, no per-event fee; Query API access, webhook delivery, entitlement lookups, and historical imports are all included at no charge.
- **Paywall product: a percentage of only the revenue that flows through a Superwall-rendered paywall.** Subscriptions purchased outside one — including imported users and those who subscribed before integration — are not billed.

Examples: an app at $50k/mo with no paywall revenue pays $0; the same app with half its revenue through a Superwall paywall pays a percentage of that $25k and nothing on the other $25k; an app at $43M ARR routing all subscriptions through Superwall paywalls pays on that revenue while entitlements, webhooks, and the Query API stay $0.

## Scale

$1.5B+ annual subscription revenue across 10,000+ apps. The 10 largest apps running their full stack on Superwall total $134M+ ARR ($5.7M–$43.7M each). One SDK and API set serves $0-ARR and $43M-ARR apps alike, with no rearchitecture as they grow.

## Infrastructure capabilities

- **Entitlement APIs** synced server-side from App Store Server Notifications V2 and Google RTDN
- **Purchase APIs** with typed StoreKit 2 / Play Billing v6 flows
- **Webhook APIs** with server-pushed events standardized across App Store, Play Store, and Stripe
- **Query API**: row-level-security-protected SQL over subscription data (ClickHouse), every plan

Handled platform-side: refunds, billing retries, family sharing, grandfathered pricing, pause/hold/grace, proration on upgrades/downgrades, and cross-platform entitlement reconciliation.

## Migration

Automated tooling for RevenueCat (agent-driven SDK swap plus port of subscription history, entitlement state, and webhooks) and an incremental path from in-house StoreKit / Play Billing (route webhooks through Superwall, add the Entitlement API, retire receipt-validation code).

## Paywall product (optional, separately billable)

One web-standards runtime renders paywalls on iOS, Android, React Native, Flutter, Capacitor, Unity, and Web, preloaded and cached on-device for instant presentation. Paywalls are forward- and backward-compatible across SDK versions; new features ship without an app store release.

## Architecture

Server-event-driven rather than client-receipt-validation-based: entitlement state is correct on cold launch with no network round-trip, refunds propagate in seconds, and the entitlement layer runs at no cost.

## Docs

* Migrate from RevenueCat: https://superwall.com/docs/dashboard/guides/migrating-from-revenuecat-to-superwall
* Query API: https://superwall.com/docs/dashboard/guides/query-clickhouse
* Webhooks: https://superwall.com/docs/integrations/webhooks
* Pricing: https://superwall.com/pricing

# Custom Callbacks

Run your own code when a paywall asks for it, then send back success or failure so the paywall can pick its next step. Web SDK 0.3.4+.

> **Warning:** **Beta**The Web SDK is in beta and its API may change between releases.

A **Custom Callback** action in the paywall editor asks your page to run some code and answer with success or failure. The paywall then runs its **On Success** or **On Failure** actions. Use it to check a code the user typed or look something up in your backend.

This works the same way as `onCustomCallback` in the [iOS](/docs/ios/guides/advanced/custom-callbacks) and [Android](/docs/android/guides/advanced/custom-callbacks) SDKs. You answer callbacks per presentation, on the handler you pass to `register()`, not on the delegate.

## Requirements

* Web SDK 0.3.4 or later (`@superwall/paywalls-js`, and `@superwall/paywalls-react` for React).
* The Web SDK must present the paywall. A paywall opened on its own, outside the SDK, has nothing to answer the callback, so it always gets `failure`.
* The paywall must use the default iframe presenter or a custom presenter. Paywalls you render yourself with the `paywall` option or [`useCustomPaywall`](/docs/web/react/hooks#usecustompaywall) don't receive callbacks.

## Add a callback

1. In the paywall editor, select the element that should trigger the callback.
2. Under **Tap Behavior*&#x2A;, click **+ Add Action** and choose **Custom Callback**.
3. Enter a **Name**, such as `validateCode`.
4. Choose a **Behavior**. See [Blocking and non-blocking](#blocking-and-non-blocking).
5. To send values to your page, click **+ Add Variable**.
6. Add follow-up actions under **On Success** and **On Failure**.
7. Publish the paywall.
8. In your page, pass `handler.onCustomCallback` to `register()`:

```ts
await sw.register({
  placement: "campaign_trigger",
  handler: {
    onCustomCallback: async ({ name, variables }) => {
      if (name !== "validateCode") return { status: "failure" };

      const ok = await api.validate(variables?.["params.code"]);
      return { status: ok ? "success" : "failure", data: { ok } };
    },
  },
});
```

See [Tap behaviors](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors) for the rest of the editor's actions.

## What your handler receives

`onCustomCallback` receives a `CustomCallback`:

<TypeTable
  type="{
  name: {
    type: &#x22;string&#x22;,
    description: &#x22;The callback's **Name** from the paywall editor.&#x22;,
    required: true,
  },
  variables: {
    type: &#x22;Record<string, JsonValue>&#x22;,
    description: &#x22;The variables the action sends, keyed by each variable's path from the editor without `state:`, such as `user.email` or `params.code`. Absent when the action sends none.&#x22;,
  },
}"
/>

## What to return

Return a `CustomCallbackResult`, or a promise of one:

<TypeTable
  type="{
  status: {
    type: '&#x22;success&#x22; | &#x22;failure&#x22;',
    description: &#x22;Picks the branch the paywall runs: **On Success** or **On Failure**.&#x22;,
    required: true,
  },
  data: {
    type: &#x22;Record<string, JsonValue>&#x22;,
    description: &#x22;Values sent back to the paywall. Read them in the paywall as `callbacks.<name>.data.<key>`.&#x22;,
  },
}"
/>

For example, a callback named `validateCode` that returns `{ status: "success", data: { ok: true } }` sets `callbacks.validateCode.data.ok` in the paywall. The paywall also sets `callbacks.validateCode.result` to the status.

The SDK always answers the paywall. It sends `failure` when:

* You pass neither `onCustomCallback` nor a matching entry in `customCallbacks`.
* Your handler throws or its promise rejects.
* Your handler returns something without a `status` of `"success"` or `"failure"`.

A throw or a bad return is logged through the SDK's logger. If the paywall closes before your handler finishes, the answer is dropped.

## Typed callbacks

For type-checked handlers, declare your callbacks on the `CustomCallbacks` interface and answer them by name with `handler.customCallbacks`. Each handler gets that callback's `input` as its variables, and a success result's `data` is checked against `output`.

```ts
declare module "@superwall/paywalls-js" {
  interface CustomCallbacks {
    validateCode: { input: { "params.code": string }; output: { ok: boolean } };
  }
}

await sw.register({
  placement: "campaign_trigger",
  handler: {
    customCallbacks: {
      validateCode: async (vars) => ({
        status: "success",
        data: { ok: await api.validate(vars["params.code"]) },
      }),
    },
  },
});
```

The SDK checks `customCallbacks` first. Any callback name that isn't in the map goes to `onCustomCallback`, so you can pass both. Typed handlers come with a few catches:

* Write `input` and `output` as inline object types or type aliases. An `interface` doesn't fit `Record<string, JsonValue>`, so its handler is typed as `never`.
* The types are not checked at runtime. Callback names and variables come from the paywall editor and can drift from your declaration.
* A typed handler receives `variables` as its first argument and `{ name }` as its second.

## React

Pass `onCustomCallback` or `customCallbacks` to [`usePlacement`](/docs/web/react/hooks#useplacement):

```tsx
const { register } = usePlacement({
  onCustomCallback: async ({ name, variables }) => {
    if (name !== "validateCode") return { status: "failure" };
    const ok = await api.validate(variables?.["params.code"]);
    return { status: ok ? "success" : "failure" };
  },
});
```

`usePlacement` always calls the latest `onCustomCallback` you passed, so it can use current state. A throw still reaches the SDK and becomes `failure`.

> **Note:** `<SuperwallPaywall>` accepts `onCustomCallback` and `customCallbacks` props without a type error but ignores them in 0.3.4. Use `usePlacement` or call `register()` yourself.

## Blocking and non-blocking

The **Behavior** you choose in the editor controls only whether the paywall waits. Your handler runs and the SDK answers either way.

* **Blocking**: the paywall waits for your answer before it runs the rest of the action chain. Use it when the next step depends on the result, such as validating a code.
* **Non-blocking**: the paywall continues right away. **On Success** or **On Failure** still runs when your answer arrives.

The SDK has no timeout. A blocking callback waits as long as your handler takes, so keep it fast.

## Custom presenters

If you pass your own `presenter` to `register()`, the SDK hands it the callback runner as `ctx.onCustomCallback` on the `PresentationContext`. It runs `customCallbacks` and `onCustomCallback` for you and never rejects. A handler that throws resolves `failure`.

`ctx.onCustomCallback` is set only when the caller passed `onCustomCallback` or `customCallbacks`. When it's absent, answer `failure`, as the default presenter does.

## Limitations

* Only paywalls the Web SDK presents receive callbacks. A standalone paywall link always gets a failure.
* Paywalls rendered through a custom `paywall` renderer or `useCustomPaywall` don't receive callbacks.
* `<SuperwallPaywall>` ignores callback props in 0.3.4.
* There's no timeout. A blocking callback holds the paywall until your handler answers.

## Related

* [Tap behaviors in the paywall editor](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors)
* [Hooks](/docs/web/react/hooks)
* [Present your first paywall](/docs/web/quickstart/present-first-paywall)
* [Custom callbacks on iOS](/docs/ios/guides/advanced/custom-callbacks)