DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

RevenueCat Integration for Indie SaaS: The Apple Tax Nobody Prepares You For

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:

  1. Apple takes 15-30% of every transaction
  2. You can't use your existing Stripe flow on iOS
  3. You need a second billing system running in parallel
  4. 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, and subscriptionExpiry on my UserEntity
  • All my API authorization logic checked these fields
  • Stripe webhooks already wrote to these same fields
  • I wasn't about to rewrite every @PreAuthorize check 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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The subscription screen needs three states:

  1. Web subscriber - they subscribed via Stripe on the web. Show "Managed on Web" with a link to the dashboard.
  2. Active IAP subscriber - they subscribed through the app. Show "Manage in App Store/Play Store."
  3. 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");
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. Use the same product ID naming convention across platforms. I initially had different Android and iOS product IDs. The determineTierFromProductId logic doesn't care about platform, so matching conventions saves headaches.

  3. Test cancellation and expiration separately. These are different events with different behaviors. My first implementation treated CANCELLATION as EXPIRATION and 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)