# 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

# Countdown Timers on Paywalls

Add a live countdown timer to a Superwall paywall with Liquid: count down from when the paywall opens, from install, or to a fixed sale end date, and hide the offer when time runs out.

A countdown timer shows how long an offer lasts, such as "Offer ends in 14:59". In Superwall, a timer is a text element whose text uses the `countdown_from` [Liquid](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-liquid) filter. It updates every second while the paywall is on screen, and needs no code in your app.

## Add a timer

1. In the paywall editor, add a **Text** element, or select an existing one.

2. Set its text to a countdown. For a 15-minute timer that starts when the paywall opens:

   ```liquid
   {{ device.localDateTime | date_add: "15m" | countdown_from: state.now }}
   ```

3. Preview the paywall. The text counts down from `0:15:00`.

`countdown_from` takes the date the countdown ends on its left, and the date it counts from after the colon. Use `state.now`, the current time, as the start: referencing it makes the text re-render every second.

## Choose when the timer ends

The end date decides whether the timer restarts:

| Count down to                      | End date                                                                                                                                                                                          | Restarts                           |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| A time after the paywall opens     | `device.localDateTime \| date_add: "15m"`                                                                                                                                                         | Every time the paywall opens       |
| A time after install               | `device.appInstallDate \| date_add: "3d"`                                                                                                                                                         | Never; it's the same for each user |
| A fixed date, such as a sale's end | `"2026-12-01T08:00:00Z"`                                                                                                                                                                          | Never; it's the same for everyone  |
| A date your app sends              | A [user attribute](/docs/sdk/quickstart/setting-user-properties) or [placement parameter](/docs/dashboard/dashboard-campaigns/campaigns-placements#placement-parameters) holding an ISO 8601 date | When your app changes the date     |

For example, a 3-day launch offer that every user gets once, counted from install:

```liquid
Your offer ends in {{ device.appInstallDate | date_add: "3d" | countdown_from: state.now, "long_most_significant", "days" }}
```

This shows `3 days`, then `2 days`, and so on down to the last hours and minutes.

### Adding and subtracting time

`date_add` and `date_subtract` shift a date. Pass a duration such as `"30s"`, `"15m"`, `"2h"`, `"3d"`, `"1w"`, `"1M"` or `"1y"`, or a number and a unit:

```liquid
{{ device.localDateTime | date_add: "90m" }}
{{ device.localDateTime | date_add: 3, "days" }}
{{ "2026-12-01T08:00:00Z" | date_subtract: "1d" }}
```

> **Warning:** Capital `M` is months and lowercase `m` is minutes. `date_add: "1M"` adds a month.

## Format the timer

`countdown_from` takes up to four options, in order:

```liquid
{{ end_date | countdown_from: state.now, style, max_unit, column }}
```

* **style:** how the countdown is written. The default is `digital`.

  | Style                   | Example                          |
  | ----------------------- | -------------------------------- |
  | `digital`               | `2:03:04`                        |
  | `narrow`                | `2h 3m 4s`                       |
  | `short`                 | `2 hr, 3 min, 4 sec`             |
  | `long`                  | `2 hours, 3 minutes, 4 seconds`  |
  | `long_most_significant` | `2 hours`: only the largest unit |

* **max\_unit:** the largest unit to show. The default is `hours`, so 3 days shows as `72:00:00`. Pass `days` to show `3 days, 0:00:00` instead, or `3 days` with the `long` style. The options are `years`, `months`, `weeks`, `days`, `hours`, `minutes` and `seconds`.

* **column:** return just one unit, as a number, for building your own layout. For example, `47:12:03` with the column set to `minutes` returns `12`.

To show each unit in its own box, use one text element per unit:

```liquid
{{ device.localDateTime | date_add: "1h" | countdown_from: state.now, "digital", "hours", "minutes" }}
```

```liquid
{{ device.localDateTime | date_add: "1h" | countdown_from: state.now, "digital", "hours", "seconds" }}
```

A column returns a plain number, so pad it yourself if you want two digits:

```liquid
{% assign s = device.localDateTime | date_add: "1h" | countdown_from: state.now, "digital", "hours", "seconds" %}{% if s < 10 %}0{% endif %}{{ s }}
```

## Show it in the user's language

`countdown_from` writes units in English. Add `localize_countdown` with a language code to write them in another language:

```liquid
{{ device.appInstallDate | date_add: "3d" | countdown_from: state.now, "digital", "days" | localize_countdown: device.deviceLanguageCode }}
```

On a French device, `1 day, 3:30:10` becomes `1 j et 3:30:10`. For the rest of the paywall's text, see [Paywall Localization](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-localization).

## When the timer runs out

`countdown_from` expects the end date to be later than the start. Once the end date passes, the numbers go negative. To show something else instead, read the seconds left as a number and check it:

```liquid
{% assign left = device.appInstallDate | date_add: "3d" | countdown_from: state.now, "digital", "seconds", "seconds" %}{% if left > 0 %}Ends in {{ device.appInstallDate | date_add: "3d" | countdown_from: state.now }}{% else %}This offer has ended{% endif %}
```

To show a different paywall once an offer ends, target the users who should still see it with an [audience filter](/docs/dashboard/dashboard-campaigns/campaigns-audience), such as `device.daysSinceInstall` less than `3`.

## Things to know

* The timer runs only while the paywall is on screen. It doesn't schedule anything on the device.
* `device.localDateTime` is the device's clock, so a user who changes their clock changes the timer. Use a date your server sends when that matters.
* A countdown is display only. It doesn't end a discount in the App Store or Google Play. End the offer with your campaign or product setup too.
* Make sure the countdown is honest. See [Are paywall events and urgency messaging App Store compliant?](/docs/support/faq/app-store-compliance-paywall-events)

## Related

* [Liquid](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-liquid)
* [Variables](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables)
* [Dynamic Values](/docs/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values)