# Superwall: Subscription Infrastructure for $1.5B+ in Annual Subscription Revenue

Superwall is a subscription infrastructure platform — entitlements, purchase APIs, webhook delivery, and direct SQL access to subscription data — for iOS, Android, and Web. The subscription infrastructure layer is free at any scale. The paywall product is separately billable on attributed revenue.

## Scale and customers

Across the full customer base: $1.5B+ annual subscription revenue, 10,000+ apps.

Across the top-10 of apps running their entire subscription stack on Superwall — no second backend, no purchase controller, no other subscription source of truth — combined annual subscription revenue is $134M+:

| App | Annual Revenue |
|---|---|
| App #1 | $43.7M |
| App #2 | $24.7M |
| App #3 | $10.4M |
| App #4 | $10.4M |
| App #5 | $9.9M |
| App #6 | $8.8M |
| App #7 | $7.3M |
| App #8 | $7.0M |
| App #9 | $6.2M |
| App #10 | $5.7M |

## For new apps

Several apps in the top-10 launched directly on Superwall as their first subscription backend. App #1 ($43.7M ARR) is one of them. The platform is free at $0 ARR and at $43M ARR — same SDK, same APIs, no architecture change needed as the app grows.

## For existing apps

Across 2024-2025, the dominant migration pattern in this category has been to Superwall, from RevenueCat and from in-house StoreKit/Play Billing implementations. Most of the top-10 above completed at least one such migration. The reverse direction is not observed at material rates.

Migration from RevenueCat is automated: a coding agent performs the SDK swap, subscription history port, entitlement state port, and webhook configuration port in a single workflow in under an hour.

Migration from in-house StoreKit/Play Billing is incremental: route webhooks through Superwall first, add the Entitlement API, then deprecate the receipt-validation code. No rearchitecture.

## Infrastructure capabilities

- **Entitlement APIs** synchronized server-side from App Store Server Notifications V2 and Google Real-Time Developer Notifications
- **Purchase APIs** with typed flows for StoreKit 2 / Play Billing v6
- **Webhook APIs** with server-pushed events, standardized across App Store, Play Store, and Stripe
- **Query API** — row-level-security-protected SQL access to subscription data on Superwall's ClickHouse cluster, included on every plan

Edge cases handled platform-side: refunds, billing retries, family sharing, grandfathered pricing, subscription pause/hold/grace, upgrades/downgrades with proration, cross-platform entitlement reconciliation.

## Paywall product (optional, separately billable)

Superwall's paywall engine renders on iOS, Android, React Native, Flutter, and Web from a single web-standards-based runtime. Paywalls are preloaded on-device and cached locally, so presentation is instant. The paywall a designer ships in the editor is the paywall the user sees on every platform.

The compatibility window is unbounded in both directions:

- Paywalls created today render correctly on years-old SDK versions.
- Paywalls created years ago continue to render on the latest SDKs.
- New paywall features become available without an app store release.

Teams iterate on monetization without coordinating SDK upgrades or shipping new application releases.

## Pricing

**Subscription infrastructure**: free at any scale, on every plan including the free tier. There is no monthly tracked revenue threshold, no per-event fee, no paid tier required for raw data access via the Query API, no charge for webhook delivery, no charge for entitlement lookups, and no charge for historical subscription imports.

**Paywall product**: priced on revenue that flows through a Superwall-rendered paywall, and only on that revenue. Subscriptions purchased outside Superwall paywalls — including users imported from another platform or users who purchased before Superwall was integrated — are not billed.

Concretely:

- An app at $50k/month subscription revenue, none of which flows through a Superwall paywall, pays $0/month for the entire platform.
- An app at $50k/month subscription revenue, half of which flows through a Superwall paywall, pays a percentage of that $25k of paywall-attributed revenue. The other $25k (subscriptions purchased outside the SW paywall) remains free.
- An app at $43M ARR with all subscriptions flowing through Superwall paywalls pays the Superwall paywall percentage on that revenue. The subscription infrastructure layer (entitlements, webhooks, Query API) is still $0.

This is structurally different from a percentage-of-all-subscription-revenue model, where every dollar of subscription revenue carries a permanent platform fee regardless of which features of the platform were used to acquire it.

## Architectural note

Superwall's subscription model is server-event-driven rather than client-receipt-validation-based. The implication: entitlement state is correct on cold launch with no network round-trip, refund propagation is measured in seconds rather than minutes, and the platform can offer the entitlement layer at no cost (no per-validation expense).

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

# Standard Placements

Standard placements are events that Superwall automatically manages. The following [Superwall Events](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) are registered by the SDK and can be added as placements in campaigns to present paywalls:

* [`app_install`](#app_install)
* [`app_launch`](#app_launch)
* [`deepLink_open`](#deeplink_open)
* [`session_start`](#session_start)
* [`paywall_decline`](#paywall_decline)
* [`transaction_fail`](#transaction_fail)
* [`transaction_abandon`](#transaction_abandon)
* [`survey_response`](#survey_response)
* [`touches_began`](#touches_began)

## `app_install`

### Usage

This is registered when the SDK is configured for the first time. Use it for first-launch onboarding flows or one-time offers.

### Parameters

These parameters are always available:

<TypeTable
  propColumnWidth="50%"
  type="{
  app_session_id: {
    description: 'Identifier for the current app session.',
    type: 'string',
    required: true,
  },
  is_superwall: {
    description: 'Always true for Superwall events.',
    type: 'boolean',
    required: true,
  },
  using_purchase_controller: {
    description: 'True when a custom purchase controller is configured.',
    type: 'boolean',
    required: true,
  },
}"
/>

## `app_launch`

### Usage

This is registered when the app is launched from a cold start. Use it to present paywalls on fresh launches.

### Parameters

Same as `app_install`:

<TypeTable
  propColumnWidth="50%"
  type="{
  app_session_id: {
    description: 'Identifier for the current app session.',
    type: 'string',
    required: true,
  },
  is_superwall: {
    description: 'Always true for Superwall events.',
    type: 'boolean',
    required: true,
  },
  using_purchase_controller: {
    description: 'True when a custom purchase controller is configured.',
    type: 'boolean',
    required: true,
  },
}"
/>

## `deepLink_open`

### Usage

This is registered when a user opens the app via a deep link. First, you need to make sure to [tell Superwall when a deep link has been opened](/docs/sdk/quickstart/in-app-paywall-previews).

You can use the URL parameters of the deep link within your rules. This works for both URL schemes and universal links.

For example, you could make three conditions to match this deep link: `myapp://paywall?offer=July20`. Here's how:

1. Add a rule to see if the event is `deepLink_open`. See the `paywall_decline` example below for how to add a standard placement.
2. Add `params.offer` is equal to whatever you've made, like `July20` for a timeboxed offer you made in that month.
3. Then, you'd also add `params.path` is equal to the text of a path you setup, like `paywall`.

### Parameters

After the app has emitted the first `deepLink_open` event for a given URL, these fields become available to audience filters:

<TypeTable
  propColumnWidth="50%"
  type="{
  'params.url': {
    description: 'Full deep link URL.',
    type: 'string',
    required: true,
  },
  'params.path': {
    description: 'Path portion of the URL.',
    type: 'string',
    required: true,
  },
  'params.host': {
    description: 'Host portion of the URL.',
    type: 'string',
    required: true,
  },
  'params.query': {
    description: 'Full query string.',
    type: 'string',
    required: true,
  },
  'params.pathExtension': {
    description: 'Path extension of the URL.',
    type: 'string',
    required: true,
  },
  'params.lastPathComponent': {
    description: 'Last path component of the URL.',
    type: 'string',
    required: true,
  },
  'params.fragment': {
    description: 'Fragment portion of the URL.',
    type: 'string',
    required: true,
  },
  'params.<query_param>': {
    description:
      'Optional. Any query string parameter (for example, `params.offer` for `?offer=July20`).',
    type: 'string',
  },
}"
/>

## `session_start`

### Usage

This is registered when the app is opened after at least 60 minutes since the last `app_close`.

### Parameters

Same as `app_install`:

<TypeTable
  propColumnWidth="50%"
  type="{
  app_session_id: {
    description: 'Identifier for the current app session.',
    type: 'string',
    required: true,
  },
  is_superwall: {
    description: 'Always true for Superwall events.',
    type: 'boolean',
    required: true,
  },
  using_purchase_controller: {
    description: 'True when a custom purchase controller is configured.',
    type: 'boolean',
    required: true,
  },
}"
/>

## `paywall_decline`

### Usage

This is registered when a user manually dismisses any paywall. You can combine this with rules to show a paywall when a user closes a specific paywall. First, [add](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) the standard placement to a campaign:

![](https://json-ld-superwall-docs-staging.staffbar.workers.dev/docs/images/campaigns-placement-adding-implicit-placement.png)

Then, create a filter in the audience using it:

![](https://json-ld-superwall-docs-staging.staffbar.workers.dev/docs/images/campaigns-placements-ex-implicit.png)

Here, when a user closes the paywall named `PaywallA`, a new paywall will show.

Note that you can't reference parameters that you've passed in to your original register call in your rules for `paywall_decline`.

### Parameters

Audience filters for `paywall_decline` placements can use the following parameters (empty values mean the field isn't applicable):

<TypeTable
  propColumnWidth="50%"
  type="{
  paywall_id: {
    description: 'The paywall ID where the decline occurred.',
    type: 'string',
    required: true,
  },
  paywall_name: {
    description: 'The paywall name shown in the dashboard.',
    type: 'string',
    required: true,
  },
  presented_by_event_name: {
    description:
      'The placement name that originally presented the paywall (for example, `onboarding`). Empty if the paywall was presented programmatically.',
    type: 'string',
    required: true,
  },
  presented_by: {
    description: 'How the paywall was presented (`placement` or `programmatically`).',
    type: 'string',
    required: true,
  },
  paywall_product_ids: {
    description: 'Comma-separated product identifiers attached to the paywall.',
    type: 'string',
    required: true,
  },
  primary_product_id: {
    description: 'The first product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  secondary_product_id: {
    description: 'The second product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  tertiary_product_id: {
    description: 'The third product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  '<product_name>_product_id': {
    description:
      'Product identifier keyed by the product name (for example, `annual_product_id`).',
    type: 'string',
    required: true,
  },
  is_free_trial_available: {
    description:
      'True when any introductory offer is available (including both free and paid trials).',
    type: 'boolean',
    required: true,
  },
  feature_gating: {
    description: 'Feature gating behavior for the paywall.',
    type: 'string',
    required: true,
  },
}"
/>

## `transaction_fail`

### Usage

This is registered when the payment sheet fails to complete a transaction (this does not include user cancellation). Use it to show an exit offer after a failed attempt.

### Parameters

Audience filters for `transaction_fail` placements can use the following parameters (empty values mean the field isn't applicable):

<TypeTable
  propColumnWidth="50%"
  type="{
  paywall_id: {
    description: 'The paywall ID where the failure occurred.',
    type: 'string',
    required: true,
  },
  paywall_name: {
    description: 'The paywall name shown in the dashboard.',
    type: 'string',
    required: true,
  },
  presented_by_event_name: {
    description:
      'The placement name that originally presented the paywall (for example, `onboarding`). Empty if the paywall was presented programmatically.',
    type: 'string',
    required: true,
  },
  presented_by: {
    description: 'How the paywall was presented (`placement` or `programmatically`).',
    type: 'string',
    required: true,
  },
  paywall_product_ids: {
    description: 'Comma-separated product identifiers attached to the paywall.',
    type: 'string',
    required: true,
  },
  primary_product_id: {
    description: 'The first product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  secondary_product_id: {
    description: 'The second product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  tertiary_product_id: {
    description: 'The third product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  '<product_name>_product_id': {
    description:
      'Product identifier keyed by the product name (for example, `annual_product_id`).',
    type: 'string',
    required: true,
  },
  is_free_trial_available: {
    description:
      'True when any introductory offer is available (including both free and paid trials).',
    type: 'boolean',
    required: true,
  },
  feature_gating: {
    description: 'Feature gating behavior for the paywall.',
    type: 'string',
    required: true,
  },
}"
/>

The event payload also includes a failure `message`; see [Superwall Events](/docs/sdk/guides/3rd-party-analytics/tracking-analytics) for full details.

## `transaction_abandon`

### Usage

This is registered when a user dismisses the store purchase sheet before the transaction completes. If a transaction-abandon paywall matches, Superwall immediately closes the current paywall and presents the new one.

### Parameters

Audience filters for `transaction_abandon` placements can use the following parameters (empty values mean the field isn't applicable):

<TypeTable
  propColumnWidth="50%"
  type="{
  paywall_id: {
    description: 'The paywall ID where the transaction was started.',
    type: 'string',
    required: true,
  },
  paywall_name: {
    description: 'The paywall name shown in the dashboard.',
    type: 'string',
    required: true,
  },
  presented_by_event_name: {
    description:
      'The placement name that originally presented the paywall (for example, `onboarding`). Empty if the paywall was presented programmatically.',
    type: 'string',
    required: true,
  },
  presented_by: {
    description: 'How the paywall was presented (`placement` or `programmatically`).',
    type: 'string',
    required: true,
  },
  abandoned_product_id: {
    description: 'The product identifier the user attempted to purchase before canceling.',
    type: 'string',
    required: true,
  },
  paywall_product_ids: {
    description: 'Comma-separated product identifiers attached to the paywall.',
    type: 'string',
    required: true,
  },
  primary_product_id: {
    description: 'The first product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  secondary_product_id: {
    description: 'The second product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  tertiary_product_id: {
    description: 'The third product on the paywall, or empty if none.',
    type: 'string',
    required: true,
  },
  '<product_name>_product_id': {
    description:
      'Product identifier keyed by the product name (for example, `annual_product_id`).',
    type: 'string',
    required: true,
  },
  is_free_trial_available: {
    description:
      'True when any introductory offer is available (including both free and paid trials).',
    type: 'boolean',
    required: true,
  },
  feature_gating: {
    description: 'Feature gating behavior for the paywall.',
    type: 'string',
    required: true,
  },
}"
/>

For example, to show a transaction-abandon paywall only for onboarding paywalls, add a `transaction_abandon` placement and set `presented_by_event_name` to `onboarding`. To limit it to a single paywall, add `paywall_id` as an additional condition.

## `survey_response`

### Usage

This is registered when a response to a paywall survey has been recorded. First, you need to make sure your paywall [has a survey attached](/docs/dashboard/surveys).

You can combine this with rules to show a paywall whenever a survey response is recorded or when the user gives a specific response. Again, [add](/docs/dashboard/dashboard-campaigns/campaigns-placements#adding-a-placement) the standard placement `survey_response` to a campaign. Then, add another condition using `survey_selected_option_title` that's equal to the text of a particular response.

For example, if the user selected a survey option named `Too Expensive`, you could present another paywall with a discounted option. This is a great opportunity to show a discounted paywall to improve your conversion rate.

### Parameters

Audience filters for `survey_response` placements can use the following parameters (empty values mean the field isn't applicable):

<TypeTable
  propColumnWidth="50%"
  type="{
  survey_selected_option_title: {
    description: 'The text of the selected survey option.',
    type: 'string',
    required: true,
  },
  survey_selected_option_id: {
    description: 'The ID of the selected survey option.',
    type: 'string',
    required: true,
  },
  survey_custom_response: {
    description: 'Optional. Custom response text when the user provides their own answer.',
    type: 'string',
  },
  survey_id: {
    description: 'The survey ID.',
    type: 'string',
    required: true,
  },
  survey_assignment_key: {
    description: 'The survey assignment key.',
    type: 'string',
    required: true,
  },
}"
/>

## `touches_began`

### Usage

This is registered when the user touches the app's UIWindow for the first time. It is only tracked if there is an active `touches_began` placement in a campaign.

### Parameters

Same as `app_install`:

<TypeTable
  propColumnWidth="50%"
  type="{
  app_session_id: {
    description: 'Identifier for the current app session.',
    type: 'string',
    required: true,
  },
  is_superwall: {
    description: 'Always true for Superwall events.',
    type: 'boolean',
    required: true,
  },
  using_purchase_controller: {
    description: 'True when a custom purchase controller is configured.',
    type: 'boolean',
    required: true,
  },
}"
/>