One subscription across web and mobile (Stripe + RevenueCat + Supabase)
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.
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, whether this user is subscribed right now. Stripe knows about the web payments, RevenueCat knows about the mobile ones, and neither knows about the other.
What was 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 whether Stripe thinks they’re plus and, if not, whether RevenueCat does, 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 gets deeper every time you add a platform.
Underneath that is the mistake of treating a billing provider as your source of truth. Stripe and RevenueCat are payment processors. Whether your user has access is your domain concept.
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 through 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 whether a user is 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, since 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 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 and your column handles the access.
Discussion
Powered by GitHub. Sign in to leave a comment.