Web on Stripe, mobile on RevenueCat: unifying cross-platform billing into one table

Storyie Engineering Team
8 min read

How Storyie routes payments from two different providers — Stripe Checkout on web, RevenueCat on iOS and Android — through a shared webhook business logic layer and into a single subscriptions table, with one SQL query deciding Pro status everywhere.

Web on Stripe, mobile on RevenueCat: unifying cross-platform billing into one table

Storyie ships a subscription product on both web (Next.js) and mobile (Expo — iOS and Android). The two platforms use different payment providers:

  • Web: Stripe Checkout → Stripe webhook
  • Mobile (iOS/Android): RevenueCat SDK → RevenueCat webhook

The payment entry point differs by platform, but the question "is this user Pro?" has to have one answer. This post covers the design decisions that got us there: a shared webhook business logic layer, a single idempotency table, and one SQL query that settles Pro status across all surfaces.

TL;DR

Two payment providers is not a design choice — App Store and Google Play require it. The architecture challenge is making sure the two entry points converge before they touch the database.

| Layer | Responsibility |
| --------------------------------- | ------------------------------------------------------------------- |
| Provider webhooks (Stripe / RC) | Signature/auth verification, event routing |
| Shared business logic (shared.ts) | Tenant resolution, subscription upsert |
| stripe_events table | Idempotency for both providers (RC events get a revenuecat. prefix) |
| subscriptions table | Single source of truth for Pro status |
| @storyie/subscription package | Plan limit logic — imported by web and mobile, no DB dependency |

Why two providers at all

Stripe is straightforward for web — Stripe Checkout handles the payment UI and keeps card data off our servers entirely.

Mobile is where the constraint kicks in. iOS and Android require in-app purchases to go through App Store and Google Play respectively. Stripe cannot be used directly for native in-app billing on either platform. RevenueCat is an SDK that abstracts the App Store and Google Play billing APIs, handles receipt validation, and manages subscription lifecycle events across both platforms.

The two-provider setup is not a preference. Anyone building a subscription product across web and mobile will land in the same place.

Architecture overview

┌─────────────┐     ┌──────────────────┐
│   Web        │     │   Expo App        │
│  (Next.js)   │     │  (iOS / Android)  │
└──────┬───────┘     └──────┬────────────┘
       │                     │
  Stripe Checkout     RevenueCat SDK
       │               (App Store /
       │                Google Play)
       │                     │
       ▼                     ▼
  POST /api/stripe/    POST /api/revenuecat/
    webhook              webhook
       │                     │
       └──────┬──────────────┘
              │
      ┌───────▼────────┐
      │  shared webhook │
      │  business logic │
      └───────┬────────┘
              │
     ┌────────▼──────────┐
     │  subscriptions     │
     │  table (one)       │
     └────────────────────┘

The key is unifying the output of the webhook handlers. Stripe and RevenueCat use different event schemas and different delivery mechanisms, but after verification and routing, both handlers do exactly one thing: update a tenant's subscription status. That logic lives in one place.

Decision 1: a shared business logic layer

Both webhook handlers are responsible for verifying the incoming request and routing to the right event-specific handler. The actual subscription mutation is delegated to a shared service layer:

services/
├── stripe/webhook/
│   ├── verify.ts         # HMAC signature verification
│   └── handlers/         # per-event-type handlers
├── revenuecat/webhook/
│   ├── verify.ts         # Authorization header verification
│   └── handlers.ts       # per-event-type handlers
└── webhook/
    └── shared.ts         # tenant resolution + subscription upsert

shared.ts exposes two functions:

  • findOrCreateTenantForWebhook — looks up a tenant by user ID, creates one if it does not exist. Used on initial purchase events.
  • upsertSubscriptionForTenant — creates or updates the tenant's subscription row.

Every Stripe handler and every RevenueCat handler ultimately calls one or both of these. No subscription update logic lives in the provider-specific files. Adding a third provider (say, Paddle) would require implementing verification and routing, but the database mutation code stays untouched.

Decision 2: one idempotency table for both providers

Webhook delivery is at-least-once. Both Stripe and RevenueCat can redeliver the same event, and both provider docs recommend handling it. We manage idempotency with a single stripe_events table — the name is historical; RevenueCat events go in there too.

// RevenueCat webhook handler — same table as Stripe
const existingEvent = await stripeEventQueries.getEventByStripeId(eventId);

if (existingEvent?.status === "processed") {
  return { processed: false, reason: "already_processed" };
}

We did not split this into two tables because the logic is identical regardless of provider: look up by event ID, return early if already processed, set to failed on error so the next delivery can retry. Sharing the table also simplifies monitoring — one query shows all webhook activity across both providers.

To avoid event type collisions, RevenueCat events are prefixed: revenuecat.INITIAL_PURCHASE, revenuecat.RENEWAL, and so on.

Decision 3: mobile payment flow options

Native mobile billing has two shapes.

In-app purchase via RevenueCat SDK

purchasePackage() displays the App Store or Google Play native purchase sheet. The user never leaves the app, which is the best experience. The cost is the Apple/Google commission (15–30% depending on the tier).

Stripe Checkout in an external browser

Apple's 2024 App Store guideline changes permit apps in certain categories to link to external payment flows. Our /api/stripe/mobile-checkout endpoint creates a Checkout session and opens it in the system browser. Commission drops to Stripe's rate, but the user leaves the app to complete payment.

Storyie ships both. RevenueCat in-app purchase is the primary path. The Stripe mobile checkout endpoint exists as a fallback and to preserve optionality if we ever adjust the commission strategy. The webhook handling is already in place for both, so the incremental cost of keeping both live is low.

Decision 4: tying the user ID to RevenueCat

Cross-platform billing's hardest problem is identity — knowing which server-side user made a purchase. Storyie uses Supabase Auth, so every user has a Supabase UUID. The Expo app initializes RevenueCat at startup and calls logIn after authentication:

// App startup
await Purchases.configure({ apiKey });

// After login
await Purchases.logIn(session.user.id);

Once logIn is called, RevenueCat associates the customer record with the Supabase user ID. Any subsequent webhook event for that customer includes the Supabase UUID in app_user_id, and our handler uses it to resolve the tenant.

On the Stripe side, the user ID goes into Checkout session metadata at creation time. Both paths end up at the same identifier.

The timing of logIn matters. If a purchase happens before logIn is called, RevenueCat assigns a random anonymous ID to the purchase, and associating it with the correct Supabase user after the fact is painful. We prevent this by never showing the subscription paywall until the user is authenticated.

Decision 5: one SQL query for Pro status

Because both providers write to the same subscriptions table in the same format, the Pro check is trivial:

SELECT status FROM subscriptions
WHERE tenant_id = :tenantId
  AND status IN ('active', 'trialing')
LIMIT 1;

That query is the same on web and mobile. A Stripe subscription purchased on web shows up as Pro on iOS. A RevenueCat subscription purchased on iOS shows up as Pro on web. There is no per-provider branch in the entitlement check.

Plan limit logic (post count, image count per post, and similar) lives in the @storyie/subscription package — pure TypeScript with no database dependency, importable from both Next.js and Expo.

Operational notes

RevenueCat webhook latency

RevenueCat webhooks arrive later than Stripe webhooks — the provider waits for App Store or Google Play to settle the transaction before firing. Delays of a few seconds to tens of seconds are normal.

To avoid making users wait, Storyie uses a two-layer approach: the RevenueCat SDK's CustomerInfo gives the app an immediate, client-authoritative entitlement view, while the webhook eventually syncs the server-side table. We treat the SDK state as a cache; the server table is the source of truth for anything enforced server-side.

Test environment isolation

Stripe's Test/Live mode separation is clean and well-documented. RevenueCat has a Sandbox environment, but Apple's Sandbox occasionally behaves differently from production — webhook timing and renewal behavior especially. During development we piped RevenueCat webhooks through ngrok and worked through the edge cases by watching the logs directly.

Consistent error handling pattern

Both webhook handlers follow the same five-step pattern:

  1. Receive the event
  2. Idempotency check — return early if already processed
  3. Run business logic
  4. Record success or failure in the events table
  5. Return HTTP 200 — always, even on business logic errors, to prevent the provider from retrying what is actually an application-layer problem

The consistency across handlers means a new engineer reading either file can orient quickly. It also means adding a third provider is a matter of implementing steps 1 and 2 for that provider's verification scheme; steps 3–5 are already written.

Takeaways

Cross-platform billing complexity comes from having two providers. The architecture goal is to make that complexity local to the webhook handlers and invisible everywhere else.

  1. Unify at the output, not the input — let each provider's handler own verification and routing; push everything else into a shared layer that neither handler duplicates.
  2. Share the idempotency table — the deduplication logic is identical across providers; a prefix is enough to prevent collisions.
  3. Bind user identity early — call logIn before the paywall. Anonymous purchases are hard to reconcile after the fact.
  4. Keep Pro status on the server — treat the client SDK's entitlement state as a cache for responsiveness; enforce from the server table for anything that matters.

Related Posts

Try Storyie

The billing architecture described here runs in production at storyie.com. Subscribe on web and open the iOS app — Pro status transfers immediately.