Most of the React Native work we get called into for monetisation is not "add a paywall" — it is "our paywall works, but revenue does not reconcile." Purchases succeed on device and never unlock the feature. Subscriptions renew and the app still shows the free tier. Refunds never revoke anything. Almost all of it comes from treating in-app purchases as a client-side concern.
Here is the architecture we recommend, the library choice behind it, and the edge cases that turn into support tickets.
The one rule: the store is the cash register, your server is the source of truth
A purchase on device produces a signed transaction from Apple or Google. That artifact is evidence of a payment — it is not an entitlement. The entitlement ("this account has Pro until date X") belongs in your own database, written by your backend after it has verified the transaction with the store.
Why this matters in practice:
- Renewals, cancellations, billing retries, grace periods, upgrades and refunds mostly happen while the app is closed. Only server-to-server notifications (App Store Server Notifications v2, Google Play Real-time Developer Notifications) see them.
- Users switch devices and platforms. Entitlement tied to a local receipt does not follow them; entitlement tied to your account ID does.
- Any client-only check is trivially bypassable.
So: client initiates the purchase, backend verifies and records it, app reads entitlement state from your API (with a cached copy for offline launches).
Choosing the client library
Three realistic options in 2026:
expo-iap — the actively maintained successor to the community react-native-iap line, built for StoreKit 2 and Google Play Billing 7+, works in Expo prebuild/CNG projects and bare RN. Choose it when you want direct store APIs, no third-party pricing, and you are willing to own receipt validation and notification handling on your backend.
RevenueCat (react-native-purchases) — a hosted entitlement layer. It handles validation, server notifications, cross-platform entitlement, and gives you subscription analytics and paywall experiments out of the box. Choose it when you do not have backend capacity to run billing infrastructure, or when pricing experiments matter more than the vendor fee. You still store your mapping of account to entitlement — do not let the SDK be your only copy.
Rolling your own bridge — only if you have an unusual requirement (custom offer signing, an existing native billing layer in a brownfield app). It is a Nitro/Turbo Module project, not an afternoon.
For most teams the honest answer is: RevenueCat if subscriptions are your business model and your backend team is small; expo-iap plus a hardened backend if you already run payment infrastructure and want no revenue share.
The client flow, minus the footguns
// 1. Connect once, early — before the paywall renders.
await initConnection();
// 2. Fetch products from the store, never from your own config.
// Prices, currencies and localised strings must come from the store.
const subs = await fetchProducts({ skus: ['pro_monthly', 'pro_yearly'], type: 'subs' });
// 3. Purchase, then WAIT for the transaction listener — not for the promise.
purchaseUpdatedListener(async (purchase) => {
const ok = await api.verifyPurchase({
platform: Platform.OS,
// iOS: JWS transaction; Android: purchaseToken + productId
payload: purchase,
});
if (ok) {
await finishTransaction({ purchase, isConsumable: false }); // ack/finish ONLY after your server is happy
await refreshEntitlements();
}
});
purchaseErrorListener((e) => {
if (e.code === 'E_USER_CANCELLED') return; // not an error worth logging loudly
logPurchaseFailure(e);
});
await requestPurchase({ sku: 'pro_yearly', type: 'subs' });
The non-obvious parts:
- Never
finishTransactionbefore your backend has recorded the entitlement. Unfinished transactions are replayed to you on next launch; finished-but-unverified ones are lost revenue and a support ticket. - Prices come from the store, always. Hard-coded prices break in every other currency and get flagged in review.
- Handle the transaction queue on cold start. A purchase can complete after the app is killed (payment sheet interrupted, Ask to Buy approval, deferred Android payment). Attach listeners before the first render, not inside the paywall screen.
- Restore must exist and must be reachable. Apple rejects apps whose non-consumable purchases cannot be restored. "Restore purchases" belongs on the paywall and in settings.
Server-side verification, briefly
iOS (StoreKit 2): the transaction is a JWS. Verify the signature against Apple's root certificates, check bundleId, productId, environment, and the transaction ID for replay. Use the App Store Server API to look up subscription status; subscribe to App Store Server Notifications v2 for DID_RENEW, EXPIRED, DID_CHANGE_RENEWAL_STATUS, REFUND, GRACE_PERIOD_EXPIRED.
Android: send the purchaseToken to the Google Play Developer API (purchases.subscriptionsv2.get), then acknowledge within three days or Google auto-refunds the purchase. Wire Real-time Developer Notifications through Pub/Sub for the same lifecycle events.
Store the raw transaction, the derived entitlement window, and the store's own identifiers. When finance asks why a user is Pro, you want a row you can point at.
Sandbox and test discipline
Sandbox subscriptions run on accelerated clocks (a month can be minutes), which is the only practical way to test renewal and expiry paths. Use Xcode StoreKit configuration files for local unit-testable flows, real sandbox accounts for the end-to-end path, and Google Play license testers on an internal testing track. Then test the ugly cases deliberately: cancel mid-period, expire, refund, upgrade, downgrade, and reinstall on a fresh device with the same account.
What changed recently, and why it matters
External purchase and link-out entitlements are now a real option on both platforms in several jurisdictions, and Apple's ExternalPurchaseLink / external purchase entitlements come with disclosure-sheet and reporting obligations. If your product is considering web checkout to avoid store commission, treat it as a compliance project with legal input, not a client-side tweak — and expect to support both billing paths simultaneously, which doubles your entitlement plumbing. Our default advice for a first release stays: ship store billing, keep entitlements server-owned, and revisit link-outs once the revenue justifies the complexity.
Checklist before you ship a paywall
- Entitlement state served by your API, cached locally, never derived only from the device receipt.
- Transaction listeners attached at app start; queue drained on cold launch.
- Backend verification before
finishTransaction/ acknowledgement (Android within three days). - Server-to-server notifications handled for renew, expire, refund, grace period, and renewal-status changes.
- Restore purchases present and tested on a clean install.
- Prices, currency and duration strings read from the store.
- Cancellation path documented, and manage-subscription deep link surfaced in the app.
- Refund/revocation actually removes access.
- Analytics events on paywall view, purchase start, success and failure — otherwise you cannot debug conversion.
Where we fit
We have wired store billing into React Native apps on both sides of the boundary: the client flow and paywall, and the Node/serverless backend that verifies transactions and owns entitlements. If your subscriptions are live but revenue does not reconcile — or you are about to build a paywall and want it right the first time — get in touch.