This article was originally published on Jo4 Blog.
I had Stripe working perfectly. Web dashboard payments, team billing, subscription upgrades and downgrades - all handled. Users happy. Revenue flowing.
Then I submitted my React Native app to the App Store.
The Problem
Apple's App Store Review Guideline 3.1.1 is clear: if your app offers subscriptions and the user can subscribe in the app, you must use Apple's In-App Purchase system. No exceptions. No "just link to your website." If you show a paywall, it goes through Apple.
This means:
- Apple takes 15-30% of every transaction
- You can't use your existing Stripe flow on iOS
- You need a second billing system running in parallel
- Both systems must keep the same user's subscription state in sync
I'd heard of RevenueCat as the standard way to handle this. What I hadn't heard was how many moving parts are involved when you already have a working billing system.
The Architecture Decision
The core question: where does the source of truth live?
Option A: RevenueCat is the source of truth, and your backend reads from it.
Option B: Your backend database is the source of truth, and RevenueCat sends webhooks to update it.
I went with Option B. Here's why:
- I already had
subscriptionTier,subscriptionStatus, andsubscriptionExpiryon myUserEntity - All my API authorization logic checked these fields
- Stripe webhooks already wrote to these same fields
- I wasn't about to rewrite every
@PreAuthorizecheck to call RevenueCat's API
So the plan: RevenueCat sends webhook events to my backend, and my backend updates the database. Same pattern as Stripe. How hard could it be?
The Implementation
Step 1: The Webhook Handler (Spring Boot)
RevenueCat sends a POST with a JSON payload containing an event object. The event has a type (like INITIAL_PURCHASE, RENEWAL, CANCELLATION) and an app_user_id that you set when the user logs in on mobile.
The key insight: use your database user ID as RevenueCat's app_user_id. This makes the webhook handler trivial - you get the user ID directly from the event, look up the user, and update their subscription.
// On mobile login, after getting the backend user profile:
await Purchases.logIn(String(profileData.response.id));
// In the webhook handler, the app_user_id IS your database ID:
Long userId = Long.parseLong(event.path("app_user_id").asText());
UserEntity user = userRepository.findById(userId).orElseThrow();
No mapping tables. No secondary lookup. Clean.
Step 2: Product ID Conventions
RevenueCat doesn't tell you the subscription tier directly. You infer it from the product ID you configured in App Store Connect. I used a simple convention:
// Product IDs: "jo4_pro_monthly", "jo4_proplus_yearly", etc.
SubscriptionTier determineTierFromProductId(String productId) {
String lower = productId.toLowerCase();
if (lower.contains("proplus")) return SubscriptionTier.PRO_PLUS;
if (lower.contains("pro")) return SubscriptionTier.PRO;
return SubscriptionTier.FREE;
}
Order matters - check proplus before pro, otherwise every Pro+ user gets classified as Pro. Ask me how I found that one.
Step 3: Event Deduplication
RevenueCat can send the same event multiple times (retries, network issues). Without deduplication, a single purchase could reset a user's URL count twice.
Redis made this straightforward:
private boolean tryMarkEventAsProcessed(String eventId) {
String key = "revenuecat:webhook:event:" + eventId;
Boolean wasSet = redisTemplate.opsForValue()
.setIfAbsent(key, "processed", Duration.ofHours(24));
return Boolean.TRUE.equals(wasSet);
}
Atomic setIfAbsent with a 24-hour TTL. If it returns false, we've already processed this event. Done.
Step 4: The Seven Lifecycle Events
This is where the complexity lives. RevenueCat sends seven different event types, and each one needs different handling:
| Event | What Happens | Tier Change? | Reset URL Count? |
|---|---|---|---|
INITIAL_PURCHASE |
New subscriber | Yes - set tier | Yes |
RENEWAL |
Auto-renewal succeeded | Yes - confirm tier | Yes |
CANCELLATION |
User cancelled (but still has access until expiry) | No - keep tier | No |
EXPIRATION |
Access period ended | Yes - back to FREE | No |
BILLING_ISSUE |
Payment failed, grace period | No - pause status | No |
PRODUCT_CHANGE |
Upgrade or downgrade | Yes - new tier | No |
UNCANCELLATION |
User re-enabled auto-renew | No - restore ACTIVE status | No |
The tricky one is CANCELLATION. Your instinct says "downgrade them." But no - they've already paid for the current period. You mark them as CANCELLED but keep their tier until EXPIRATION fires.
Step 5: The Mobile Side
On the React Native side, RevenueCat's SDK handles the App Store / Play Store payment sheets. You wrap it in a hook:
export function useSubscription(): SubscriptionState {
const [offerings, setOfferings] = useState(null);
const [customerInfo, setCustomerInfo] = useState(null);
// Derived state from entitlements
const isPro = customerInfo?.entitlements.active['pro'] !== undefined;
const isProPlus = customerInfo?.entitlements.active['pro_plus'] !== undefined;
const purchasePackage = async (pkg) => {
const { customerInfo } = await Purchases.purchasePackage(pkg);
setCustomerInfo(customerInfo);
return true;
};
// ...
}
The subscription screen needs three states:
- Web subscriber - they subscribed via Stripe on the web. Show "Managed on Web" with a link to the dashboard.
- Active IAP subscriber - they subscribed through the app. Show "Manage in App Store/Play Store."
- Free user - show the paywall with Pro and Pro+ packages.
Getting state 1 right was the part I almost missed. If a user has a Pro tier in the backend but no active RevenueCat entitlements, they're a web subscriber. Don't show them a paywall. Don't let them accidentally buy a duplicate subscription through Apple.
The Security Gotchas
Webhook Authentication
RevenueCat lets you set an authorization token in their dashboard. Your webhook endpoint verifies it:
if (webhookAuthToken == null || webhookAuthToken.isBlank()) {
log.error("SECURITY: webhook auth token not configured");
throw new AppException(UNAUTHORIZED, "Webhook processing unavailable");
}
Fail closed. If the auth token isn't configured (deployment mistake, missing env var), reject everything. Don't silently accept unverified webhooks.
Return 200 for Unknown Events
RevenueCat retries on non-200 responses. If you throw a 500 for an event type you don't handle, RevenueCat will retry it forever. Return 200 and log it:
default -> log.info("Unhandled event type: {}", eventType);
// ...
return ResponseEntity.ok().build();
The Deployment Lesson
The webhook auth token is a secret. I stored it in GitHub Secrets and injected it at deploy time through the workflow:
# In the deployment workflow .env template
REVENUECAT_WEBHOOK_AUTH_TOKEN=${{ secrets.REVENUECAT_WEBHOOK_AUTH_TOKEN }}
I initially had it in GitHub variables (not secrets) because "it's just a webhook token, not a database password." Nope. Any token that gates financial transactions is a secret. Period.
What I'd Do Differently
Start with the webhook handler first. I built the mobile paywall UI first, which meant I had a "Buy" button that worked but no backend to receive the purchase event. Webhook-first lets you test with RevenueCat's webhook tester before touching mobile code.
Use the same product ID naming convention across platforms. I initially had different Android and iOS product IDs. The
determineTierFromProductIdlogic doesn't care about platform, so matching conventions saves headaches.Test cancellation and expiration separately. These are different events with different behaviors. My first implementation treated
CANCELLATIONasEXPIRATIONand immediately downgraded users. They were not thrilled.
The Result
Two billing systems (Stripe for web, RevenueCat for mobile) writing to the same user record. The user doesn't know or care which one is active. They just see their subscription tier reflected consistently across web, iOS, and Android.
Total implementation: ~250 lines of Java backend, ~120 lines of React Native hooks, ~350 lines of paywall UI. Plus 580 lines of tests, because the one thing worse than two billing systems is two untested billing systems.
Have you dealt with the Apple IAP mandate on an existing SaaS? I'd love to hear how you handled the dual-billing complexity. Drop your war stories in the comments.
Building jo4.io - a URL shortener with analytics for developers who'd rather not give Apple 30% but don't have a choice.
Top comments (0)