# 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

# Free Trials

Fork your paywall on trial eligibility the store reports, pull trial terms from product variables, and remind users before a trial ends.

The store decides who gets a trial — not you, and not the user's claim. Someone who used their trial two years ago and reinstalled is ineligible, and only the store knows. `useTrialEligibility()` is that signal, and it splits your paywall into two versions that must **both** read as intentional.

## Read eligibility

```tsx
import { useTrialEligibility } from "superwall/hooks";

const { eligible } = useTrialEligibility();   // boolean | undefined
```

`eligible` is `undefined` until the SDK reports, so gate trial-only UI on `eligible === true` — never on "not false."

## Two paywalls in one

Fork every user-facing string, including the CTA. A returning customer sees the ineligible copy, and it cannot read like a mistake:

```tsx
const { eligible } = useTrialEligibility();
const days = annual?.variables.trialPeriodDays;

<h1>{eligible ? "Start free" : "Go Pro"}</h1>
<p>
  {eligible
    ? days
      ? `${days} days free, then ${annual?.variables.price ?? "the annual price"}.`
      : "Your trial is on the house."
    : "You have used your trial. Subscribe to keep going."}
</p>
<button>{eligible ? "Start free trial" : (annual?.variables.price ?? "Subscribe")}</button>
```

Two details in that snippet are deliberate:

* **The fallbacks nest.** Eligible-but-days-unknown gets its own sentence — the data may not have arrived yet, and "undefined days free" is never acceptable copy.
* **The ineligible side is written, not defaulted.** "You have used your trial" tells a returning customer the paywall knows who they are.

## Trial terms come from the product

Trial length, price, and end date are variables on the product — `trialPeriodDays`, `trialPeriodPrice`, `trialPeriodEndDate`, `trialPeriodText` — never values in your files. They follow the same rules as every product read: guard them, and `Number()` before arithmetic. See [Products](/docs/framework/products).

## Test both sides

* **The studio** has a trial-eligibility toggle — flip it and check every string on both sides. See [The studio](/docs/framework/studio).
* **Config can force either side** while you're building:

```ts
introductoryOfferEligibility: "alwaysEligible" | "alwaysIneligible"   // default "automatic"
```

Leave it on `"automatic"` for production — that lets the store decide.

The `trial-eligibility` [example](/docs/framework/examples) is the reference: every string forks, and both states read as designed.

## Trial reminder notifications

Declare a local notification in `config.ts` and the SDK schedules it when a trial **actually starts** — the paywall doesn't need to be open when it fires:

```ts
notifications: {
  trialReminder: {
    title: "Your trial ends tomorrow",
    body: "Keep Pro, or cancel in Settings — no charge either way.",
    beforeTrialEndDays: 1,        // default 1
  },
},
```

`title`, `subtitle`, and `body` accept message keys (resolved through `t()` — see [Localization](/docs/framework/localization)) or literal copy.

For full control, pass a function instead. It receives `{ trialEndDate, product, t, locale }` and returns `{ title, body, delayMs }` — or `null` to skip the notification entirely:

```ts
notifications: {
  trialReminder: ({ trialEndDate, t }) =>
    trialEndDate
      ? { title: t("reminder.title"), body: t("reminder.body"), delayMs: 0 }
      : null,
},
```

The `trial-reminders` [example](/docs/framework/examples) shows both forms.

> **Tip:** Users warned before the charge cancel calmly instead of charging back — and the ones who stay chose to stay.