Cross-Platform In-App Subscriptions with RevenueCat
In-app purchases look simple until you handle receipts, restores, and a server-side source of truth. Here's the entitlement model our mobile templates share.
In-app purchases look like a solved problem until you actually ship one. Then you meet receipt validation, purchase restoration, subscription state that lives on Apple's and Google's servers, sandbox testing, and the question that breaks most first attempts: how does my backend know whether this user is actually a paying customer?
All three SoarStarter mobile templates — SwiftUI, Kotlin, and Expo — answer that question the same way, using RevenueCat on top of StoreKit and Google Play Billing. This post explains the model, because it's the part worth copying even if you never touch our code.
Why not just use StoreKit / Play Billing directly?
You can. But then you own:
- Receipt validation — verifying purchases against Apple's and Google's servers, correctly, including edge cases like refunds and billing retries.
- Two completely different APIs — StoreKit 2 on iOS and Play Billing on Android are not the same shape, so a cross-platform app writes the whole purchase layer twice.
- Subscription lifecycle — renewals, grace periods, upgrades/downgrades, and cancellations all arrive as events you have to interpret.
- A server-side source of truth — the hard one, below.
RevenueCat absorbs the first three: one SDK, one entitlement abstraction ("does this user have pro?"), consistent across platforms, with validated receipts and a paywall system. That alone justifies it for a cross-platform product. But the fourth is where our templates add the pattern that matters.
The two-layer entitlement model
The mistake most tutorials make is treating the on-device SDK as the answer to "is this user Pro?" The device is a fast cache — it can be stale, offline, or simply lying (a jailbroken device can spoof it). So our templates split entitlement into two layers:
Layer 1 — the on-device SDK (fast, local). RevenueCat is configured at launch, and a controller reads CustomerInfo to know immediately whether the pro entitlement is active. This drives snappy UI: the paywall dismisses the instant a purchase completes, no network round trip.
// The entitlement id is 'pro' by default, overridable via env.
Purchases.configure(withAPIKey: apiKey)
let info = try await Purchases.shared.customerInfo()
let hasPro = info.entitlements[entitlementId]?.isActive == trueLayer 2 — the subscriptions table (authoritative, server-side). RevenueCat webhooks write purchase and renewal events to a subscriptions row in your database. A second controller reads that row and treats it as the truth. In the Swift template the comment on it is blunt about the division of labor:
the local SDK is the fast cache and purchase mechanism; the server row is the truth.
Why bother with both? Because the server row is the only place that sees the whole customer — including entitlement granted from other platforms (a desktop purchase via Creem, a manual grant, a support comp). Your gate reads one row and gets a consistent answer no matter where the money came from. Every template wraps protected UI in the same primitive — a PremiumGate (Swift/Kotlin) / gate component (Expo) — that consults that authoritative state.
Handling the write-latency gap
There's a subtle race: a purchase completes on-device before RevenueCat's webhook has written your server row. If your gate only trusted the server, the user would pay and briefly still see the paywall. The templates handle this explicitly — the local SDK covers the UI immediately, while a short pollAfterPurchase() re-fetches the server row until the webhook lands. Fast feedback from Layer 1, correctness from Layer 2, no flicker in between.
What you get out of the box
Across all three mobile templates, the same pieces ship configured:
- RevenueCat SDK wired up with a shared
proentitlement (override the id with an env var if your RevenueCat project names it differently). - A paywall screen backed by RevenueCat offerings.
- The two-layer controller pair (local cache + server truth) and the post-purchase polling.
- A reusable gate component so putting a feature behind Pro is one wrapper, not a scattered
ifcheck. - Restore purchases and manage-subscription flows.
The upshot: the interesting, error-prone part of monetization — knowing reliably who paid — is already built, the same way, on all three stacks. Start from the RevenueCat setup docs for your platform, point it at your RevenueCat project, and you're charging money without reinventing receipt validation.