RevenueCat and Stripe: One Subscription, App and Web

Run RevenueCat in your iOS and Android apps and Stripe on the web for one subscriber: shared user ID, entitlements, webhook handling and a reconciliation job.

A black business credit card with a chip on a white background
Photo by Markus Winkler / Unsplash

To sell one subscription across your app and your website with RevenueCat and Stripe, use RevenueCat for App Store and Google Play purchases, Stripe for web checkout, and the same user ID in both. Grant access by entitlement, not by product. Update your database from webhooks, then run a scheduled job that reconciles both sources.

TL;DR: Log the user in to RevenueCat with your own user ID before any paywall. Map every product, on every store and on Stripe, to one entitlement such as "pro". Treat webhooks as hints, not truth: verify them, dedupe them, and re-fetch current state. A daily reconciliation job catches whatever the webhooks missed.

NoteTube bills this way: Stripe on the web, RevenueCat in the iOS and Android apps, and a scheduled job that reconciles subscriptions between them. GymFast also runs RevenueCat in its apps. This post covers the setup and the edge cases that turn into support tickets. If you're still choosing a web gateway for an Indian company, read Razorpay or Stripe for an Indian SaaS first.

Why not build store billing yourself?

Because you'd own receipt validation, renewals, grace periods, refunds, family sharing, price changes, and two different server notification formats, for two stores, forever. RevenueCat wraps StoreKit and Google Play Billing in one SDK, keeps one customer record with entitlements, and sends one set of webhooks.

For a small team the trade is plain. Pay RevenueCat's fee and spend the time on the product, or build and maintain billing plumbing for two stores. Once there's also a web checkout, the case for one place that answers "is this person a subscriber?" gets stronger, not weaker.

Stripe directly, Stripe through RevenueCat, or RevenueCat Web Billing?

There are three ways to put the web next to the apps. RevenueCat's own billing engine (Web Billing, also called RevenueCat Billing) uses your Stripe account as the gateway. Its Stripe Billing option imports your existing Stripe catalogue. Or you keep Stripe separate and join the two in your own database.

RevenueCat's web overview lays out the difference: with RevenueCat Billing you create products in RevenueCat and it runs the checkout UI, renewals and a customer portal, while with Stripe Billing your Stripe catalogue and subscriptions stay in Stripe and are imported.

Option Who owns the web catalogue Fits when
RevenueCat Web Billing RevenueCat (Stripe as gateway) You're starting web sales fresh and want one dashboard
Stripe Billing imported into RevenueCat Stripe You already sell on Stripe and want RevenueCat to see it
Stripe kept separate, joined in your database Stripe Your backend already owns access rules, as in NoteTube

This post follows the third row, because that's the one we run. Most of it, the shared user ID, entitlements, webhooks and reconciliation, applies to all three.

How do you make one user ID work everywhere?

Use your own database's user ID as the RevenueCat App User ID and as the Stripe reference, and set it before any purchase can happen. If one system knows the person by a different ID, you'll spend support hours merging customers by hand.

System Where your user ID lives
Your database users.id
RevenueCat App User ID, set with logIn() after sign-in
Stripe Customer metadata or your own mapping table
Your analytics The user ID you already send

RevenueCat's identifying customers guide explains why this matters: if you don't pass an ID, the SDK generates an anonymous one, and anonymous IDs can't share subscription status across apps and platforms. Call logIn() with your ID right after sign-in, before showing any paywall. Never hard-code it, or every user shares one subscription.

If you choose to import Stripe purchases into RevenueCat instead, the same rule applies from the other side: RevenueCat's Stripe docs say the app SDK must be configured with the same App User ID that the Stripe subscription was applied to.

Entitlements, not products

Products are what people buy: "Pro monthly on iOS", "Pro yearly on Android", "Pro monthly on the web". Entitlements are what they get: "pro". Map every product to an entitlement and make the app and backend check only the entitlement.

That keeps code small. A screen asks "does this user have pro?" and never cares whether they paid Apple, Google or Stripe. The backend can check a web subscriber against the same pro flag the app reads, so a new price or a promo product doesn't touch app code at all.

What should a paywall always include?

A paywall should show the localized store price, the billing period, any trial length, and that it renews, plus restore purchases and links to terms and privacy. Reviewers look for all of these. Show it when the user reaches for a paid feature, not on first launch.

  1. Show it at the moment of value, when the user tries something that needs the subscription.
  2. Load prices from the store. Prices vary by country and currency, so never hard-code them.
  3. Make the terms clear: price, period, trial, renewal.
  4. Include restore purchases, terms, and privacy links.
  5. Hide it for web subscribers. If the entitlement is already active, don't show the paywall at all.

RevenueCat's Paywalls feature gives remote-configurable templates; a hand-built screen gives more control. The purchase call and the entitlement check are the same either way.

How should you handle RevenueCat and Stripe webhooks?

Verify every webhook, store each event ID and ignore repeats, answer fast and do the work in a queue, and never trust arrival order. After any event, fetch the customer's current state from the provider and save that, rather than applying the event's change blindly.

Both providers say as much. RevenueCat's webhooks guide tells you to verify the authorization header on every notification, respond quickly, and call GET /subscribers after receiving a webhook to sync state. It retries a failed delivery up to 5 times with exponential backoff. Stripe's webhook docs say it doesn't guarantee events arrive in the order they were generated, that the same event can arrive more than once, and that live-mode deliveries are retried for up to three days.

So a late retry can land after a newer event. That's the reason we re-fetch state instead of trusting the payload: an old CANCELLATION replayed after a RENEWAL would otherwise switch off a paying user.

What happens when a renewal fails?

RevenueCat sends a BILLING_ISSUE event, which doesn't mean the subscription has ended. If store grace periods are on, the user keeps access while the store retries. Success sends a RENEWAL; failure ends in an EXPIRATION with reason BILLING_ERROR once the grace period runs out.

The event types reference also notes a CANCELLATION with reason BILLING_ERROR fires as soon as the problem is detected, and that BILLING_ISSUE carries a grace_period_expiration_at_ms field, which can be null. Your product should:

  1. On BILLING_ISSUE, show a gentle in-app banner asking the user to update their payment method in the store.
  2. Keep access until the grace period ends.
  3. On EXPIRATION, remove access and show the paywall on the next premium action.
  4. Send one email, not five.

For Stripe subscriptions on the web, the equivalent is Stripe's own retry and dunning settings, with invoice.payment_failed as the event to watch.

Why run a reconciliation job if webhooks work?

Because they don't always. An endpoint is down during a deploy, a provider has an outage, or a bug drops an event after the retries run out. If your database decides who has access, it drifts. A scheduled job that compares your records with both providers turns silent drift into a short report.

NoteTube runs a scheduled job that reconciles subscriptions between Stripe and RevenueCat. The shape of a job like that:

  1. List users whose subscription changed recently or whose expiry is close.
  2. Fetch their current state from RevenueCat and from Stripe.
  3. Compare with your database and correct any difference.
  4. Log every correction, and alert when there are many, because that means webhooks are failing.

Run it at least daily. When it corrects nothing for weeks, that's your evidence the webhook path is healthy.

How do you test purchases before the store does?

Buy, renew, fail, refund and restore in sandbox before you ship, and test the cross-platform case on purpose. Apple's sandbox testers and Google Play's license testers let you buy without being charged, and sandbox renewals run on a shortened schedule, so a full lifecycle fits in an afternoon.

  1. Test every path, not only the purchase: restore on a new device, monthly to yearly, cancel, failed renewal, refund.
  2. Test web to app: subscribe with Stripe in test mode, sign in on the phone, and confirm the paywall stays hidden.
  3. Test app to web: buy in the sandbox app, then log in on the website and confirm access.
  4. Replay webhooks in staging and check your database ends in the right state.
  5. Write the steps down, so the next release runs the same script.

Can the app tell users about the web price?

It depends on the storefront. Apple's App Review Guidelines require in-app purchase for subscriptions sold inside the app (3.1.1). They now allow buttons and external links to other purchase methods in United States storefront apps, while most other storefronts still prohibit them or need an entitlement. Check each storefront before adding a "cheaper on the web" message. Our guide to App Store rejections covers payment rejections and the fix for each.

FAQ

Can a subscriber who paid on our website use the iOS app?

Yes, as long as the website and the app identify the person with the same user ID and both grant the same entitlement. The app checks whether "pro" is active, not where it was bought, so a Stripe subscriber never sees the paywall after signing in on the phone.

What is the difference between RevenueCat Web Billing and Stripe Billing?

With Web Billing you create web products in RevenueCat, which runs checkout, renewals and a customer portal on top of your connected Stripe account. With Stripe Billing your catalogue and subscriptions stay in Stripe and RevenueCat imports them. Pick Web Billing for a fresh start, Stripe Billing if Stripe already runs your web sales.

Do RevenueCat webhooks arrive in order?

Don't rely on it. Deliveries are retried after failures, so an older event can land after a newer one, and Stripe states plainly that it doesn't guarantee order. Treat each webhook as a signal to fetch the customer's current state from the provider, then save that state.

How often should the reconciliation job run?

At least once a day, and more often around renewal peaks if you have many subscribers. Scope it to users whose state changed recently or whose expiry is near, so each run stays cheap, and alert when the number of corrections jumps.

Adding subscriptions to an app, or joining app and web billing for one subscriber? See our in-app subscriptions work and send us your plan structure.