Soul33's tiered subscription — Free, Seeker, Mastery — was the revenue engine of the app. Built it twice: once with react-native-iap raw, once with RevenueCat after the second App Store rejection over receipt-validation behaviour. Both work. This post is the version of "how IAP actually fits together" I wish someone had handed me on day one.
Why client-side entitlement is always wrong
Every IAP library will give you a quick path to "the user has purchased X" right in the app. Do not trust it. Jailbroken iOS devices return forged receipts. Android devices can be patched to return fake purchase tokens. Both platforms' SDKs hand you transaction data you must verify server-side against Apple's verifyReceipt endpoint or Google's purchases.subscriptions.get API. If your backend grants entitlement based on a client-side claim, you'll discover a healthy free-rider community within weeks of going live.
Architecture: client buys, server entitles
The flow that survives audits and abuse: client initiates purchase → store returns a receipt / purchaseToken → client posts that to your backend → backend calls Apple/Google verification API → backend records the subscription state in your DB → backend returns the user's entitlement → client unlocks features based on the SERVER response, not the SDK. Receipt validation is also where you record auto-renewal status, grace periods, and billing retry windows.
Product setup the dashboards make easy to mess up
On App Store Connect, every subscription belongs to a Subscription Group — you can only own ONE subscription per group at a time. Most apps want a single group containing all tiers (Free isn't a product; the others are). If you put each tier in its own group, users can subscribe to two tiers simultaneously and Apple will let them. On Google Play Console, subscriptions live under Monetize → Subscriptions, with each tier having a base plan and offers. The product IDs you pick are immutable — pick clear ones (seeker_monthly, seeker_yearly), never reuse them across environments.
react-native-iap — the raw path
import {
initConnection,
endConnection,
getSubscriptions,
requestSubscription,
purchaseUpdatedListener,
finishTransaction,
type Subscription,
type SubscriptionPurchase,
} from 'react-native-iap';
const SKUS = Platform.select({
ios: ['seeker_monthly', 'mastery_monthly'],
android: ['seeker_monthly', 'mastery_monthly'],
}) ?? [];
export async function startBilling() {
await initConnection();
const subs: Subscription[] = await getSubscriptions({ skus: SKUS });
const sub = purchaseUpdatedListener(async (purchase: SubscriptionPurchase) => {
const receipt = purchase.transactionReceipt;
// Send receipt + productId to backend; await entitlement decision
const { entitled } = await api.post('/iap/validate', {
platform: Platform.OS,
productId: purchase.productId,
receipt,
purchaseToken: purchase.purchaseToken, // Android only
});
if (entitled) {
await finishTransaction({ purchase, isConsumable: false });
}
});
return () => {
sub.remove();
endConnection();
};
}RevenueCat — the pragmatic path
If your business is selling subscriptions, RevenueCat is worth its 1% take. It handles platform receipt validation, subscription state caching, cross-platform entitlement (a user who buys on iOS keeps their entitlement on Android), introductory offer eligibility, and webhook delivery on renewals/refunds/grace-period entries. We rebuilt Soul33's IAP on RevenueCat in two days and deleted ~600 lines of backend validation code. The trade-off: another vendor in your stack and a recurring cost — but for most teams the time saved pays for itself within the first quarter.
import Purchases, { PurchasesOffering } from 'react-native-purchases';
export async function configureBilling(userId: string) {
Purchases.configure({
apiKey: Platform.OS === 'ios'
? 'appl_xxx_revcat_public_key'
: 'goog_xxx_revcat_public_key',
appUserID: userId, // your backend's user ID — never the email
});
const offerings = await Purchases.getOfferings();
return offerings.current; // contains all available packages
}
export async function purchase(pkg: PurchasesOffering['availablePackages'][number]) {
const { customerInfo } = await Purchases.purchasePackage(pkg);
return customerInfo.entitlements.active['premium'] != null;
}
export async function restorePurchases() {
const customerInfo = await Purchases.restorePurchases();
return customerInfo.entitlements.active;
}Restore Purchases — required by both stores
Apple and Google require a visible Restore Purchases button on any screen where you ask the user to subscribe. App Store reviewers test this — if it's missing or non-functional, your app is rejected within hours. Restore is also how a user moves to a new device while keeping their subscription. With react-native-iap, call getAvailablePurchases() and re-validate each receipt with your backend. With RevenueCat, restorePurchases() handles it.
Introductory offers — the trap
On iOS, a user is eligible for an introductory offer (free trial, intro pricing) once per Subscription Group. If they already used the trial on Seeker and then upgrade to Mastery in the same group, they get standard pricing, not another trial. Your UI must reflect this — querying is_eligible_for_intro_offer via StoreKit's getIntroductoryOfferEligibility and only showing the trial copy when true. Showing "Free 7-day trial" to someone Apple will charge full price is a guaranteed support ticket.
Sandbox testing — and why it always feels broken
iOS sandbox subscriptions auto-renew aggressively — a monthly subscription renews every 5 minutes, and after 5 renewals it cancels and you start over. Use a Sandbox tester account from App Store Connect, sign out of your real Apple ID in Settings → Media & Purchases. On Android, add license testers in Play Console → License Testing for fake purchases to work in unpublished apps. Sandbox doesn't perfectly mirror production — promotional offers and family sharing behave differently in sandbox. Always do a final test with TestFlight Internal + real card.
Quick decision: if subscriptions are core to your business and you have <2 senior engineers, use RevenueCat. If you have specific compliance requirements (data residency, custom receipt logic) or you've already invested in receipt-validation infra, use react-native-iap. Either way: server-side entitlement only, visible Restore button, accurate introductory-offer eligibility, and webhook your renewal events into your CRM from day one — retention work without renewal data is guesswork.