One subscription across web and mobile (Stripe + RevenueCat + Supabase)

3 min read StripeRevenueCatSupabaseSubscriptions

Pay on web or mobile, unlock both, never double-charge, by making one Supabase column the source of truth instead of reconciling two billing systems.

packaged This fix is packaged: shared-subscription-kit

The symptom

I had two products on one subscription: a web app (CollageCraft) that charges through Stripe, and a mobile app (TravelThread, built with Expo) that charges through RevenueCat because the app stores force it. One subscription was meant to cover both, subscribe on either platform, unlock both, never get billed twice. The question with no clean out-of-the-box answer is the simplest one: is this user subscribed right now? Stripe knows about the web payments. RevenueCat knows about the mobile ones. Neither knows about the other.

What was actually happening

The trap is trying to make the two billing systems aware of each other, or checking both at runtime. Every gated screen ends up asking “does Stripe think they’re plus? no? then does RevenueCat think they’re plus?”, with two SDKs, two sets of edge cases (trials, grace periods, refunds, past_due), and race conditions for any user with history on both. It is a swamp, and it gets deeper every time you add a platform.

The mistake underneath it: treating a billing provider as your source of truth. Stripe and RevenueCat are payment processors. Whether your user has access is your domain concept, not theirs.

The fix

Pick one field that you own, that both billing systems WRITE to and both apps READ. A subscription_tier column on the Supabase profiles table:

-- profiles.subscription_tier: 'free' | 'plus'
alter table profiles add column subscription_tier text not null default 'free';

On web, a Stripe webhook (a Supabase Edge Function) projects Stripe’s events onto that column:

// supabase/functions/stripe-webhook/index.ts
switch (event.type) {
  case 'checkout.session.completed':
  case 'customer.subscription.updated': {
    const sub = await stripe.subscriptions.retrieve(subId);
    const active = sub.status === 'active' || sub.status === 'trialing';
    await admin.from('profiles')
      .update({ subscription_tier: active ? 'plus' : 'free' })
      .eq('stripe_customer_id', sub.customer);
    break;
  }
  case 'customer.subscription.deleted':
    await admin.from('profiles')
      .update({ subscription_tier: 'free' })
      .eq('stripe_customer_id', event.data.object.customer);
    break;
}

On mobile, RevenueCat does the same write through its own webhook (or the app syncing its active entitlement to your backend): an INITIAL_PURCHASE or RENEWAL sets 'plus', a CANCELLATION or EXPIRATION sets 'free'. Same column, same two values.

Now the apps never touch a billing SDK to answer “is this user plus”. They read one field:

const { data } = await supabase
  .from('profiles').select('subscription_tier').single();
const isPlus = data?.subscription_tier === 'plus';

Two guardrails make it safe. RLS lets a user READ their own tier but never WRITE it, only the service-role webhook updates it, so a client cannot grant itself access. And the secrets (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, the RevenueCat keys) live in the Edge Function environment and Supabase secrets only, never in the frontend or your hosting provider’s public env.

The single Stripe product behind both apps was one SKU (“TravelThread + CollageCraft”, EUR 4.99/mo or EUR 29.99/yr), so a web subscriber and a mobile subscriber both land on the exact same 'plus' tier.

The lesson

Don’t reconcile two billing systems. Project both of them onto one entitlement field you own, here profiles.subscription_tier, and have every app read only that. The billing providers handle the money; your column handles the access.

Share: X Hacker News Reddit

Related fixes