●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork × RevenueCat + Stripe Web Integration: Managing iOS, Android & Web Subscriptions from a Single Backend
A complete implementation guide for unifying iOS StoreKit, Android Google Play Billing, and Stripe Web payments under RevenueCat for Rork apps. Covers cross-platform entitlement sync, common pitfalls, and production deployment.
At some point in your app journey, you'll almost certainly want to sell your service on the web too — not just through the App Store and Google Play. That's when billing complexity skyrockets. Adding Stripe Web payments alongside iOS StoreKit and Android Google Play Billing means you now have three separate entitlement sources to keep in sync.
The typical failure mode looks like this: a user buys a plan on your website via Stripe, then opens your iOS app, and nothing happens — the app still shows the free tier. This guide walks through the exact implementation needed to prevent that, using Rork's React Native/Expo code generation paired with RevenueCat and a Hono + Cloudflare Workers backend.
Why Cross-Platform Billing Is Hard
RevenueCat handles iOS and Android billing automatically — that's its core value. The challenge is that Stripe lives outside RevenueCat's ecosystem, so you have to bridge them manually.
There are three core friction points.
Entitlement update timing. Stripe Webhooks fire server-side, but getting that data into RevenueCat requires an explicit API call. If that call fails, your app won't know the user paid.
Different user identity systems. RevenueCat identifies users by appUserId, while Stripe uses a customerId. These need to be reliably linked — one wrong mapping and you'll have the same person appearing as two separate customers.
Receipt validation happens in different places. iOS and Android generate receipts on-device and RevenueCat validates them server-side transparently. Stripe is entirely server-side. Guaranteeing real-time consistency requires different strategies for each.
Architecture Overview
Before writing a single line of code, understand the complete data flow:
The key design principle: the app should not care which channel the purchase came through. The backend normalizes all three channels into a single entitlement state, and the app just reads from one place.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Idempotent Stripe webhooks via KV so duplicate deliveries never extend an entitlement by mistake
✦A Cron Triggers reconciliation job that treats Stripe as the source of truth to catch missed webhooks
✦A grace-period design that separates past_due from real cancellations so you do not lock out good users
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Once the backend integration is complete, isPro will return true for purchases made through any of the three channels.
Pitfall #1: Calling Purchases.configure() outside useEffect causes it to re-run on every re-render. Always call it exactly once inside useEffect.
User Identity Design
Consistent user identification across all three channels is the single most critical design decision.
// Call this after successful loginasync function syncRevenueCatUserId(appUserId: string) { try { const { customerInfo, created } = await Purchases.logIn(appUserId); if (created) { // New user: also create a Stripe Customer await createStripeCustomer(appUserId); } return customerInfo; } catch (error) { // Don't block the user — just log the failure console.error('RevenueCat login failed:', error); return null; }}async function createStripeCustomer(appUserId: string) { const response = await fetch('/api/create-stripe-customer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appUserId }), }); if (!response.ok) { throw new Error(`Failed to create Stripe customer: ${response.status}`); } return response.json();}
Use your app's internal user ID (e.g., Supabase uid, Firebase uid) as appUserId. Register this same ID with both RevenueCat and Stripe. This is what lets you match the same person across all three billing systems.
Pitfall #2: Using the Stripe customerId (format: cus_xxx) as the RevenueCat appUserId. This breaks when iOS purchases happen before the Stripe Customer is created. Always decide on your appUserId first, then register it with both services.
The Stripe Backend (Hono + Cloudflare Workers)
This is where the critical bridging work happens.
// src/routes/stripe-webhook.tsimport { Hono } from 'hono';import Stripe from 'stripe';const app = new Hono();app.post('/api/stripe-webhook', async (c) => { const stripe = new Stripe(c.env.STRIPE_SECRET_KEY); const signature = c.req.header('stripe-signature') ?? ''; const rawBody = await c.req.text(); // Always verify the signature — skip this and you have a security hole let event: Stripe.Event; try { event = await stripe.webhooks.constructEventAsync( rawBody, signature, c.env.STRIPE_WEBHOOK_SECRET, undefined, Stripe.createSubtleCryptoProvider() ); } catch (err) { console.error('Webhook signature verification failed:', err); return c.json({ error: 'Invalid signature' }, 400); } switch (event.type) { case 'checkout.session.completed': await handleCheckoutCompleted(event.data.object, c.env); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object, c.env); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object, c.env); break; default: // Return 200 for unhandled events to prevent unnecessary retries break; } return c.json({ received: true }, 200);});async function handleCheckoutCompleted( session: Stripe.Checkout.Session, env: Env) { const appUserId = session.metadata?.appUserId; if (!appUserId) { console.error('Missing appUserId in session metadata:', session.id); return; } await grantRevenueCatEntitlement(appUserId, 'pro', getExpirationDate(session), env); await env.KV.put( `subscription:${appUserId}`, JSON.stringify({ status: 'active', source: 'stripe', stripeCustomerId: session.customer as string, expiresAt: getExpirationDate(session), }), { expirationTtl: 60 * 60 * 24 * 400 } );}async function grantRevenueCatEntitlement( appUserId: string, entitlementId: string, expiresAt: string | null, env: Env) { const url = `https://api.revenuecat.com/v1/subscribers/${appUserId}/entitlements/${entitlementId}/promotional`; const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${env.REVENUECAT_SECRET_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ duration: 'monthly', ...(expiresAt ? { expires_date: expiresAt } : {}), }), }); if (!response.ok) { const body = await response.text(); throw new Error(`RevenueCat grant failed: ${response.status} - ${body}`); } return response.json();}function getExpirationDate(session: Stripe.Checkout.Session): string | null { if (session.subscription) { const thirtyDaysLater = new Date(); thirtyDaysLater.setDate(thirtyDaysLater.getDate() + 30); return thirtyDaysLater.toISOString(); } return null;}
Pitfall #3: Returning 500 when the RevenueCat API call fails. Stripe will retry on 5xx responses, potentially causing duplicate entitlement grants. Either implement idempotent handling or return 200 after logging the failure and handle retries separately.
Embedding appUserId in Stripe Checkout
The Checkout session creation must include appUserId in both the session and subscription metadata.
Critical: Setting subscription_data.metadata.appUserId is mandatory. The checkout.session.completed event carries session.metadata, but subsequent customer.subscription.updated and customer.subscription.deleted events do not. If appUserId is only on the session, you'll lose the ability to identify the user in renewal and cancellation events.
Everything up to here is the part you can plan on paper. What follows is what I only learned by running this in production. When I operated the Stripe membership on Dolice Labs, the thing that made my stomach drop was duplicate webhook delivery.
Stripe delivers with at-least-once semantics, which means the same checkout.session.completed can arrive twice or three times — for instance, when a brief network hiccup keeps your 200 response from reaching Stripe.
Call grantRevenueCatEntitlement twice and the promotional entitlement stacks, quietly extending the expiry. I missed this at first and ended up creating a test user who "still had two extra months of access after canceling."
The fix is simple: record processed event IDs in KV and return early for anything you have already handled.
// Idempotency guard: each event.id is processed only onceasync function alreadyProcessed(eventId: string, env: Env): Promise<boolean> { const key = `evt:${eventId}`; const seen = await env.KV.get(key); if (seen) return true; // Keep for 30 days — comfortably longer than Stripe's retry window await env.KV.put(key, '1', { expirationTtl: 60 * 60 * 24 * 30 }); return false;}
Run the guard at the very top of the handler, right after signature verification.
// Insert right after signature verificationif (await alreadyProcessed(event.id, c.env)) { // Already handled — return 200 to stop retries return c.json({ received: true, deduped: true }, 200);}
One caveat: there is a tiny window between KV.get and KV.put, so two truly simultaneous deliveries can both slip through. It never mattered at my scale, but if you process dozens of payments per second, also make the grant itself idempotent. RevenueCat promotional entitlements granted with an explicit expires_date are overwritten at that timestamp, so a duplicate becomes a re-set to the same expiry rather than an extension. Pass an explicit expires_date instead of duration whenever you can.
Reconciliation: Rescuing the Webhooks You Missed
Webhooks are convenient, but you cannot trust them 100%. A transient Stripe outage, the few dozen seconds during your own deploy, the minutes right after you rotate a signing secret — delivery will drop at some point. I once missed renewal events that arrived in the minutes right after I swapped a signing secret, and a handful of users lost access.
You cannot detect that kind of gap if you rely on webhooks alone. So run a daily job that treats Stripe as the source of truth and reconciles it against KV, using Cloudflare Workers Cron Triggers.
// src/cron/reconcile.ts// Set [triggers] crons = ["0 18 * * *"] in wrangler.toml (around 03:00 JST)export async function reconcileSubscriptions(env: Env) { const stripe = new Stripe(env.STRIPE_SECRET_KEY); let processed = 0; let repaired = 0; // Pull active subscriptions from Stripe and compare against KV for await (const sub of stripe.subscriptions.list({ status: 'active', limit: 100, expand: ['data.customer'], })) { const appUserId = sub.metadata?.appUserId; if (!appUserId) continue; processed++; const expiresAt = new Date(sub.current_period_end * 1000).toISOString(); const cached = await env.KV.get(`subscription:${appUserId}`); const parsed = cached ? JSON.parse(cached) : null; // If KV is missing or the status/expiry has drifted, repair using Stripe as truth const drift = !parsed || parsed.status !== 'active' || parsed.expiresAt !== expiresAt; if (drift) { await grantRevenueCatEntitlement(appUserId, 'pro', expiresAt, env); await env.KV.put( `subscription:${appUserId}`, JSON.stringify({ status: 'active', source: 'stripe', expiresAt }), { expirationTtl: 60 * 60 * 24 * 400 } ); repaired++; } } console.log(`reconcile done: processed=${processed} repaired=${repaired}`);}
On my small service this job repaired maybe one or two records a week, if that. But a single paying user with expired access is a serious dent in trust. Webhook as the primary path, reconciliation as the safety net — that two-layer setup is what lets you sleep at night.
Handling the Payment Grace Period
A surprisingly common blind spot is what happens right after a payment fails. Expired cards and insufficient funds are routine. Revoke access instantly and you lock out well-meaning users who fully intended to keep their subscription once they updated their card.
Stripe moves the subscription into a past_due state and retries the charge over several days according to your Smart Retries schedule. The standard play during past_due is to keep access while nudging the user to update payment.
async function handleSubscriptionUpdated( subscription: Stripe.Subscription, env: Env) { const appUserId = subscription.metadata?.appUserId; if (!appUserId) return; const status = subscription.status; // Treat past_due as "in grace" and keep access, alongside active / trialing const inGrace = status === 'past_due'; const isEntitled = ['active', 'trialing'].includes(status) || inGrace; if (isEntitled) { // Extend access to the end of the current billing period const expiresAt = new Date(subscription.current_period_end * 1000).toISOString(); await grantRevenueCatEntitlement(appUserId, 'pro', expiresAt, env); await env.KV.put( `subscription:${appUserId}`, JSON.stringify({ status, source: 'stripe', expiresAt, grace: inGrace }), { expirationTtl: 60 * 60 * 24 * 400 } ); } else { // canceled / unpaid: let it expire naturally rather than revoking instantly await env.KV.put( `subscription:${appUserId}`, JSON.stringify({ status, source: 'stripe' }) ); }}
Google Play has the same notion of a grace period and account hold (On-hold), which RevenueCat surfaces as billing_issue events. The names differ by platform, but the principle is identical: distinguish a temporary payment failure from a clear intent to cancel. Keep access for the former; withdraw it promptly for the latter.
On the app side, it is kind to quietly let the user know they are in grace. I limited myself to a single understated banner — "Please review your payment method" — and applied no feature restrictions. It is guidance, not a lockout. That small touch noticeably improved the rate at which users recovered from a failed payment.
The Core Design Principle
The fundamental question in cross-platform billing is: where does the single source of truth for subscription state live?
RevenueCat handles iOS and Android automatically. The Stripe gap is closed by treating every Stripe purchase as a promotional entitlement grant via RevenueCat's REST API. This lets the app read from RevenueCat as its sole authority — regardless of which channel the user purchased through.
The Cloudflare KV double-write serves as a resilience layer. If RevenueCat is temporarily unavailable, the backend can still respond to entitlement checks from KV. In production, a billing system that fails open because of a third-party outage is a revenue risk.
Your next step: implement RevenueCat Webhook-driven push notifications for events like "your subscription renews tomorrow" and "your payment failed" — these dramatically reduce involuntary churn.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.