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

SurfaceLabelNotes
Paywall (PaywallView)IntegratedMonthly / yearly / lifetime packages from the RevenueCat offering
Manage subscription (ManageSubscriptionView)IntegratedStatus summary + deep link to Apple's manage-subscription screen
Redeem code (RedeemCodeView)IntegratedOpens StoreKit's offer-code sheet
Premium gate (PremiumGate)IntegratedWraps 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

PurchasesController.swift
EntitlementController.swift
EntitlementSnapshot.swift
PremiumGate.swift

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 state

A 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:

statusentitled while…
active / trialingone_time → always; otherwise period_end > now
canceledperiod_end > now (auto-renew off, still paid)
unpaid / expirednever
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:

  1. Same provider → always apply (the provider is authoritative for its own line, so renewals / cancellations / expirations pass through).
  2. Different provider → apply only if the incoming period_end is 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 pollAfterPurchase loop 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.

On this page