Skip to content
SaaS Development20 October 2026 · 16 min read

Share Stripe Web Access With a Flutter Mobile App

Stripe shows an active subscription and the app still shows the paywall. The fix is one identifier, carried from the Checkout Session through the receipts POST to the logIn call.

Share Stripe Web Access With a Flutter Mobile App

What you'll learn

  • The identifier that decides what the app can unlock
  • Check the product catalogue before writing any code
  • Carry your user ID into the Checkout Session
Article contents

A customer pays on your website, opens your Flutter app signed in to the same account, and the paywall is still there. Stripe shows an active subscription. RevenueCat shows a customer with an entitlement. Both records are correct, and as far as your app is concerned they describe two different people.

The join key is the App User ID. RevenueCat grants entitlements to the App User ID the SDK was configured with, so a Stripe purchase reaches that user only when your server posts the purchase against the same string. RevenueCat's integration guide states the requirement directly: "In order for a Stripe subscription to be honored within an app, the Purchases SDK must be configured with the same App User ID" (RevenueCat, track external Stripe purchases). What follows is the sequence that makes those two strings identical, the client read that makes the app notice, and the checks that tell you which component held the old answer.

The identifier that decides what the app can unlock

Pick one stable ID from your own account system and use it in both places. RevenueCat grants entitlements to an App User ID, which is "case-sensitive" and "scoped to a whole Project", and "a user logged into the same App User ID on different platforms will be considered the same user" (RevenueCat, identifying customers).

Three properties of that ID decide whether the join works:

  • If you never call logIn(), the SDK generates a random ID prefixed with $RCAnonymousID: and caches it on the device. Anonymous IDs "are not able to share subscription status across apps and platforms", so an app still holding one cannot see a web purchase at all.
  • Case matters. A user row stored as A1B2 on the web and sent as a1b2 from the app produces two RevenueCat customers and one locked account.
  • RevenueCat recommends "a non-guessable pseudo-random ID, like a UUID (RFC 4122 version 4)", capped at 100 characters, and advises against email addresses on both guessability and GDPR grounds. The SDK also blocks values such as anonymous, guest, unknown, null, -1, 0, and any ID containing a forward slash.

The Stripe customer ID looks like a convenient join key and it cannot serve as one. Your app configures the SDK when someone signs in, which is usually long before they pay, and at that moment no Stripe customer exists for them. Use your own user ID on both sides and treat the Stripe customer as a billing record attached to it.

Check the product catalogue before writing any code

This integration only carries the price shapes RevenueCat imports. In the Product Catalog, "a product in RevenueCat maps directly to a single Stripe product", one price per product is the recommended arrangement, and the web purchase flows support recurring flat-rate and one-off flat-rate prices. Tiered, usage-based and customer-chooses pricing are not supported, and "once a Stripe price is imported into RevenueCat, its price type cannot be changed" (RevenueCat, Stripe integration).

Two settings on that page change what you will observe later. You choose whether RevenueCat registers an active subscription when Stripe creates its invoice or after the invoice is paid, and the same page notes that Stripe Test Clocks "are not fully supported", so advancing a clock is not a reliable way to test a renewal here.

If your price is metered or tiered, change the pricing or choose a different plan shape before going further. A product RevenueCat will not import cannot be joined to an entitlement by any amount of webhook code.

Carry your user ID into the Checkout Session

Put your own user ID on the Checkout Session so your server can read it back when the event arrives. Stripe's client_reference_id is built for this: "A unique string to reference the Checkout Session. This can be a customer ID, a cart ID, or similar, and can be used to reconcile the session with your internal systems", with a 200 character limit (Stripe, create a Checkout Session).

// Trusted server. The browser never chooses which account gets access.
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: PRICE_ID, quantity: 1 }],
  // Read from the authenticated session, never from a query parameter.
  client_reference_id: req.user.id, // your UUID v4, also the App User ID
  // client_reference_id is a Checkout Session field. Copy the ID onto the
  // subscription too, so customer.subscription.* events carry it.
  subscription_data: { metadata: { app_user_id: req.user.id } },
  success_url: `${SITE}/welcome?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${SITE}/pricing`,
});

The duplication in that snippet prevents a specific bug. RevenueCat recommends listening to customer.subscription.created for subscriptions, and the subscription object that event carries has its own metadata; it does not inherit the session's client_reference_id. Set subscription_data.metadata as well, or resolve the Checkout Session from the subscription before you look for the ID. Reading the user ID from a query parameter instead would let any caller name the account that receives access, so take it from the authenticated request.

Grant access from the webhook, not from the redirect

A redirect proves the browser reached your page. It does not prove payment, and it does not always happen. Stripe is explicit: "You can't rely on triggering fulfillment only from your checkout landing page, because it's not guaranteed customers visit that page", and "Automatic fulfillment with webhooks is required if you sell subscriptions" (Stripe, fulfill orders).

Your handler has four obligations before it grants anything:

  1. Verify the signature. Stripe signs every event in the Stripe-Signature header, verification needs the raw request body, and a framework that reparses or rewrites that body breaks it. The official libraries default to a five minute tolerance between the event timestamp and your clock (Stripe, webhooks).
  2. Return a 2xx quickly, "before any complex logic that could cause a timeout". Queue the work and acknowledge the delivery.
  3. Check payment_status on the retrieved session rather than assuming a completed session was paid. Stripe's reference fulfillment function retrieves the session with line_items expanded and acts only when payment_status is not unpaid.
  4. Make fulfillment safe to run twice. Stripe states the function "might be called multiple times, possibly concurrently, for the same Checkout Session", which also becomes true the moment you add the landing page as a second trigger.

For delayed payment methods, checkout.session.completed arrives before the money does, and a later checkout.session.async_payment_succeeded reports the result. Granting an entitlement on the first of those for a bank debit hands out access that may never be paid for.

Post the purchase to RevenueCat

Four stacked steps showing the same user identifier at the Checkout Session, the verified webhook, the POST to the RevenueCat receipts endpoint, and the logIn call in the Flutter app.

Once your server trusts the event, one POST attaches the Stripe subscription to the App User ID. The receipts endpoint takes the Stripe app's public API key and a platform header, and "The only required fields when sending a Stripe purchase to the RevenueCat API are" fetch_token and app_user_id, where fetch_token is "Your Stripe subscription ID (sub_...) or your Stripe Checkout Session ID".

// Runs from the queue, after signature verification and after
// payment_status has been checked.
async function grantFromStripe({ eventId, subscriptionId, appUserId }) {
  if (await alreadyProcessed(eventId)) return; // dedupe on event ID

  const response = await fetch('https://api.revenuecat.com/v1/receipts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Platform': 'stripe',
      Authorization: `Bearer ${RC_STRIPE_PUBLIC_KEY}`,
    },
    body: JSON.stringify({
      fetch_token: subscriptionId, // sub_... or the Checkout Session ID
      app_user_id: appUserId,      // the same string the app logs in with
    }),
  });

  if (!response.ok) throw new Error(`RevenueCat ${response.status}`);
  await markProcessed(eventId);
}

RevenueCat recommends customer.subscription.created for subscriptions and checkout.session.completed for one-time purchases. The dedupe check earns its place because webhook endpoints "might occasionally receive the same event more than once", and Stripe retries failed deliveries "for up to three days with an exponential back off in live mode". Key that check on the event ID. Stripe says not to use the created timestamp for ordering or for deciding whether you already processed something, since distinct events can share a timestamp.

Why the app still shows the old state

Two panels side by side. RevenueCat reports the entitlement active while the device cache still reports it inactive, with no push arrow between them, above the three client calls that force a fresh read.

The app is reading its own cached copy and nothing has told it to look again. "CustomerInfo updates are not pushed to your app from the RevenueCat backend." The SDK caches the last answer, "will update the cache if it's older than 5 minutes", and does that "only if you call getCustomerInfo(), make a purchase, or restore purchases", plus when the app becomes active (RevenueCat, customer info).

Line that up with the symptom. Your server granted the entitlement while the app was open and holding a four minute old cached answer that says no. The update listener does not cover this case either: it "will fire whenever we receive a change in CustomerInfo on the current device", so it reports changes the device learned about, not changes that happened on the server while the device was idle.

The client side is three calls, in this order:

// purchases_flutter 10.17.0 signatures.
Future<bool> refreshEntitlementAfterSignIn(String userId) async {
  // 1. The same string the server posted as app_user_id. Case-sensitive.
  await Purchases.logIn(userId);

  // 2. Skip the five minute cache for this read: the web purchase may
  //    have landed seconds ago.
  await Purchases.invalidateCustomerInfoCache();

  // 3. Ask the backend. Safe to call often.
  final info = await Purchases.getCustomerInfo();
  return info.entitlements.active.containsKey('pro');
}

Purchases.logIn() creates the identity if it does not exist yet, so a user who paid on the web before ever opening the app still lands on the right customer. Call logIn() directly when someone switches accounts, because the documentation says you do not need logOut() first, and logOut() generates a fresh anonymous ID that owns nothing.

Where to put that refresh depends on timing. If the purchase happened while the app sat in the background, activation already triggers a cache update, and a getCustomerInfo() call on the screen that gates the feature covers the rest. The explicit invalidate matters when the user returns inside the five minute window, which is exactly what happens when they pay on a laptop and reach for their phone.

Restore Purchases cannot recover a web purchase

Restore reads the store account, so it has nothing to say about a card charged on your website. RevenueCat defines it as "reactivating any content that had previously been purchased from the same store account", meaning Apple, Google or Amazon (RevenueCat, restoring purchases).

Keep the Restore button on the screen for store purchases, and keep it user-initiated. It "may cause OS level sign-in prompts to appear" and "should only be called from some user interaction", which is why the same page warns against calling it in code and points at syncPurchases() for programmatic cases. That method carries "a risk of transferring or aliasing an anonymous user", so it belongs in a one-time migration rather than in your sign-in path.

When a support ticket says restore did not help a web subscriber, compare the App User IDs. A second restore attempt cannot change the outcome.

Late, duplicated and out of order events

Two timelines. The upper one revokes access at period end and leaves a red gap until payment is confirmed. The lower one keeps access until the EXPIRATION webhook arrives.

Build the handler so arrival order does not matter, because Stripe does not promise one. "Stripe doesn't guarantee the delivery of events in the order that they're generated", and creating a single subscription can produce customer.subscription.created, invoice.created, invoice.paid and charge.created in any sequence.

Renewals add a second ordering subtlety on the RevenueCat side. Stripe moves a subscription into its next period when the previous one ends, but RevenueCat sends its RENEWAL webhook only after Stripe confirms payment, and the integration page advises keeping access until an EXPIRATION webhook arrives. Revoking at period end and waiting for confirmation locks out paying customers for the length of the payment round trip. Billing failures are also quieter than you might expect: one billing issue event is generated when payment first fails, and later retry failures do not create new events.

The subscription lifecycle states a Stripe subscription moves through decide what your own access column should say while all of this is in flight.

Cancellation and the two hour window

Cancellation reaches the app through a background refresh that can lag. Immediate and end-of-period cancellations are both supported, and "it may take up to two hours for a Stripe cancellation to be reflected in the RevenueCat backend". The documented shortcut is to "re-post the same subscription to RevenueCat after a user cancels, and it will be updated immediately", so your customer.subscription.updated and customer.subscription.deleted handlers should call the same receipts endpoint with the same fetch_token.

One boundary belongs in your UI. A subscription can only be managed on the platform where it was purchased, and this integration routes customer management through the Stripe Customer Portal. Link a web subscriber there instead of showing a cancel button in the app that cannot do anything.

Run the sequence that produces evidence

Everything above is documented behavior. The sequence below is what tells you it holds in your project, and it is worth running before you ship.

  1. In Stripe test mode, point the webhook at a local listener and confirm that a normal purchase grants the entitlement end to end. Sign in on the device first and write down the exact App User ID your app sends.
  2. Stop the listener, then complete a second test checkout. The event now has nowhere to go, which gives you the delay to study instead of waiting for one in production.
  3. With the grant still missing, sign in on mobile and confirm the app reports no entitlement. This is the state in the support ticket, reproduced on purpose.
  4. Restart the listener and resend the event. Dashboard resend works for up to 15 days after event creation, and stripe events resend <event_id> --webhook-endpoint=<endpoint_id> works for up to 30 days.
  5. Without restarting the app, run the refresh path. If the entitlement appears, the server and the identity join are correct and the cache held the old answer. If it does not appear, open the RevenueCat customer and compare its App User ID to the string from step 1, character for character.
  6. Cancel the subscription in Stripe, let your handler re-post the subscription, and confirm the app reports the entitlement inactive after a refresh. Skipping the re-post means waiting out the two hour window instead.

Diagnostic checklist

SymptomFirst thing to readUsual cause
App shows no entitlement, RevenueCat customer has oneThe App User ID on that customer, against the one the app logs in withTwo customers: an anonymous ID, a casing difference, or a different ID source on the web
RevenueCat has no customer for the payer at allYour queue and the Stripe event delivery logThe receipts POST never ran, or it failed and the error was swallowed
Entitlement correct in the RevenueCat dashboard, stale in the appWhether the gating screen calls getCustomerInfo() after sign-inA cached read inside the five minute window
Subscription event arrives with no user ID attachedWhether the ID was set in subscription_data.metadataclient_reference_id sits on the session, not on the subscription
Grant works in test mode but not in productionWhich signing secret and which RevenueCat API key the server loadedTest and live secrets differ per endpoint
Webhook deliveries marked failed with a 3xxThe endpoint URLStripe counts redirect responses as failures, so register the resolved URL
Access revoked at renewal for a paying customerWhether you expire on period end or on EXPIRATIONRevoking before RevenueCat confirms the renewal payment

What account-based access does not authorize

Honoring a purchase made on your website is a narrower permission than selling inside the app. Apple's guidelines require in-app purchase to "unlock features or functionality within your app", then list exceptions. Clause 3.1.3(b) covers this case with a condition attached: "Apps that operate across multiple platforms may allow users to access content, subscriptions, or features they have acquired in your app on other platforms or your web site, including consumable items in multi-platform games, provided those items are also available as in-app purchases within the app" (App Store Review Guidelines, read 2026-10-20).

The proviso at the end is the part that fails review. Honoring a web subscription is permitted when the same thing can also be bought in the app. A web-only plan with no in-app equivalent is outside what 3.1.3(b) describes.

Google Play states its requirement separately and carries no equivalent multiplatform clause on the same page. The Payments policy says apps charging for in-app features, content or goods "must use Google Play's billing system for those transactions unless Section 3, 8, or 9 applies", and the out-of-app payment options in Section 9 are limited to "developers in eligible countries/regions" enrolled in the applicable programme (Google Play Payments policy, read 2026-10-20).

Both documents change and both have region-specific programmes, so the dates above are a reading rather than a standing rule. Check the current text for your product category and your markets before you design the pricing page, keep the equivalent store purchase available in the app, and do not point users out of the app to avoid store billing.

Where FlutterFlow's built-in actions stop

FlutterFlow's RevenueCat integration documents three actions: Paywall, Purchase and Restore Purchases (FlutterFlow, RevenueCat). None of them is the identity call this integration needs, and that page documents no built-in way to refresh or invalidate customer info.

So the work lands in three places. The Stripe checkout and the webhook are server code regardless of how the app is built. The logIn() call that ties the signed-in user to the App User ID, and the invalidate plus read that refreshes the entitlement, go into a custom action using purchases_flutter. The Paywall and Purchase actions keep covering the store purchase path. The same page also notes that "The underlying package for RevenueCat does not support web", so a FlutterFlow web build cannot read entitlements through the SDK, which is one more reason the grant decision belongs on your server.

If the store side is still in progress, the Flutter RevenueCat subscription paywall setup covers the part this article assumes already works.

Next

The code here is a reference implementation checked against the endpoint, header, field and SDK signatures published by Stripe, RevenueCat and purchases_flutter 10.17.0. None of it was executed against a Stripe account, a RevenueCat project or a device while writing this, so treat the snippets as shapes to adapt and the six step sequence as the part that produces evidence.

Start with step 1 and write down the App User ID your app actually sends. Comparing that one string against the RevenueCat customer costs a minute and rules out the entire server path before you change any code.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do you share one App User ID between a Stripe web purchase and a Flutter app?

Use your own account system's user ID in both places, and never let the two sides derive it differently. Put that ID on the Checkout Session as client_reference_id, copy it into subscription_data.metadata so subscription events carry it too, read it back on your server when the webhook arrives, and send it as app_user_id on the POST to the RevenueCat receipts endpoint. The app then calls Purchases.logIn() with the identical string. RevenueCat's documentation requires the Purchases SDK to be configured with the same App User ID for a Stripe subscription to be honored in the app, and App User IDs are case-sensitive, so a difference in casing alone produces two separate customers.

Why does the Flutter app still show the paywall after a successful web payment?

Usually because the app is reading a cached answer that predates the grant. RevenueCat does not push CustomerInfo updates to the device, and the SDK refreshes its cache only when it is older than five minutes and something asks, meaning a getCustomerInfo() call, a purchase, a restore, or the app becoming active. Someone who pays on a laptop and picks up their phone lands inside that window. Call Purchases.invalidateCustomerInfoCache() before getCustomerInfo() on the screen that gates the feature. If the entitlement still does not appear, the cause is identity rather than caching, so compare the App User ID on the RevenueCat customer against the string the app sends.

Can Restore Purchases recover a subscription bought on your website?

No. Restoring reactivates content previously purchased from the same store account, which means Apple, Google or Amazon, so it has nothing to report about a card charged through Stripe on your own site. Pressing it again will not change the outcome, and RevenueCat warns that restorePurchases may trigger OS level sign-in prompts and should only run from a deliberate user action. Keep the button for store purchases. For a web subscriber, the recovery path is signing in with the same App User ID and refreshing CustomerInfo. syncPurchases() is the programmatic equivalent for migrations, and it carries a documented risk of transferring or aliasing an anonymous user.

How long does a Stripe cancellation take to reach the app?

Up to two hours if you wait for the background refresh. RevenueCat documents that a Stripe cancellation may take that long to be reflected in its backend, for both immediate and end-of-period cancellations. The documented way to avoid the wait is to re-post the same subscription to the receipts endpoint after the cancellation, which updates it immediately, so handle customer.subscription.updated and customer.subscription.deleted with the same fetch_token you sent originally. Keep in mind that the app still reads its own cached CustomerInfo afterwards, so the user sees the change once the client asks again.

Does Apple allow an app to unlock a subscription bought on your website?

Only under a condition that is easy to miss. Guideline 3.1.3(b) lets apps operating across multiple platforms give users access to content, subscriptions or features acquired on other platforms or your web site, provided those items are also available as in-app purchases within the app. A web-only plan with no in-app equivalent falls outside it. Google Play states its rule separately and requires Play billing for in-app transactions unless specific sections apply, with out-of-app payment options limited to eligible countries and enrolled programmes. Both documents change, so check the current text for your product and markets rather than relying on a summary.