Subscriptions
The RevenueCat paywall and the account-bound entitlement read-model.
The template ships a complete in-app purchase stack: a RevenueCat-backed paywall, restore + code redemption, and — the differentiator — an account-bound entitlement that follows the user across platforms instead of living inside the RevenueCat SDK. This page explains how the pieces fit so you can gate your own features on it.
The subscriptions table is the source of truth, not the RevenueCat SDK.
RevenueCat only knows about Apple purchases. A desktop/Creem purchase, a promo,
or a manual grant reaches only the table. Every gate reads the table; the SDK
is just the purchase mechanism plus a fast local cache. Never document (or
code) CustomerInfo as the gate.
What it provides
| Surface | Label | Notes |
|---|---|---|
Paywall (PaywallView) | Integrated | Monthly / yearly / lifetime packages from the RevenueCat offering |
Manage subscription (ManageSubscriptionView) | Integrated | Status summary + deep link to Apple's manage-subscription screen |
Redeem code (RedeemCodeView) | Integrated | Opens StoreKit's offer-code sheet |
Premium gate (PremiumGate) | Integrated | Wraps any UI behind the entitlement; used by the bundled guides |
All of it no-ops gracefully without a key. With no REVENUECAT_IOS_KEY the
PurchasesController stays isConfigured == false, the paywall renders an
"unavailable" card, and nothing crashes.
Key files
Two controllers, two jobs
The design splits "what did the store tell us" from "what is this account
entitled to." Both are @MainActor @Observable and live in AppEnvironment.
Prop
Type
PurchasesController subscribes to the auth session stream and calls
Purchases.logIn(userId.uuidString) on sign-in, so RevenueCat's app_user_id
always equals the Supabase user UUID. That identity binding is what lets a
purchase made on Apple land in the same subscriptions row a desktop purchase
would.
The account-bound flow
Apple (App Store) Creem (desktop/web)
│ │
▼ ▼
revenuecat-webhook creem-webhook ← service-role Edge Functions
│ │ (the ONLY writers)
└──────────────┬─────────────┘
▼
public.subscriptions (one row per user, RLS select-own)
│
▼
EntitlementController.isEntitled ← the cross-platform gate
│
▼
Home card · Me row · PremiumGate · Paywall already-owned stateA purchase confirms locally through CustomerInfo immediately (so the UI
feels instant), then PaywallViewModel kicks off
EntitlementController.pollAfterPurchase() — a short retry loop (0.8s → 1.6s →
3.0s) that absorbs the webhook's write latency until the authoritative row
appears.
The isEntitled rule
Subscription.isEntitled is the single boolean gate, shared verbatim with the
desktop template so both clients agree on what "owned" means:
| status | entitled while… |
|---|---|
active / trialing | one_time → always; otherwise period_end > now |
canceled | period_end > now (auto-renew off, still paid) |
unpaid / expired | never |
unknown (newer schema) | never (lenient decode → not entitled) |
Signed-out, loading, and error states are never entitled — gates don't unlock on an indeterminate state.
One-row-per-user conflict rule
Because a user could buy on both Apple and Creem, every webhook upsert converges
on the single user_id row deterministically:
- Same provider → always apply (the provider is authoritative for its own line, so renewals / cancellations / expirations pass through).
- Different provider → apply only if the incoming
period_endis later. An Apple purchase never shortens an active Creem plan, and vice-versa.
The rule is encoded identically in revenuecat-webhook (this template) and
creem-webhook (the desktop sibling).
Gating your own features
Wrap any view in PremiumGate — it reads EntitlementController (not the SDK)
and renders a loading spinner, your content, or a locked upsell card:
PremiumGate(featureName: "Advanced export", onUpgradeTap: { openPaywall() }) {
AdvancedExportView()
}For imperative checks, read env.entitlements.isEntitled directly. Do not
reach for purchases.isPro / CustomerInfo as a gate — that only sees Apple
purchases and misses cross-platform + manual grants.
Products & entitlement
The paywall renders whatever packages the RevenueCat offering contains — the app has no hard-coded product list. The template's reference catalog is three tiers, all attached to the same entitlement:
soar_starter_swift_pro_monthly (auto-renewable, 1 month)
soar_starter_swift_pro_yearly (auto-renewable, 1 year)
soar_starter_swift_lifetime (non-consumable, one-time)Any of them flips isEntitled — this is a single-tier model (one license,
gated by the boolean), not a per-feature matrix. Packages sort Monthly → Yearly
→ Lifetime, and the paywall pre-selects Yearly. Purchases are tagged with a
PlanTier (pro / lifetime) derived from the RevenueCat PackageType purely
for the purchase completed analytics event — no app-side product-ID config
is needed.
The entitlement identifier comes from REVENUECAT_PRO_ENTITLEMENT_ID (default
pro). It must match the identifier in RevenueCat exactly (not the display
name) or purchases won't unlock — see Configuration.
No RevenueCat key? PurchasesController.isConfigured is false: the
paywall shows an "unavailable" card, PremiumGate stays locked, and every
purchase/restore call returns a friendly failure. The rest of the app runs
normally — subscriptions are entirely optional to boot the template.
Verify
With REVENUECAT_IOS_KEY set and offerings live (see
RevenueCat Setup):
- Signed in, open the paywall — packages load and Yearly is pre-selected.
- Without a key, the paywall shows the "unavailable" card and doesn't crash.
- After a sandbox purchase, the Home license card / Me row flips to owned
within a few seconds (the
pollAfterPurchaseloop catching the webhook write).
Purchases require a real StoreKit environment — the simulator can't complete Apple's purchase sheet against live products. See Purchase Testing.
