Documentation
RevenueCat (in-app purchases)
Sell subscriptions and purchases with one entitlement system across iOS, Android and the web
The RevenueCat add-on lets your website sell subscriptions and one-time purchases inside your app, with App Store billing on iOS and Google Play Billing on Android. Visitors who open your website in a regular browser can pay with RevenueCat Web Billing. You bring your own RevenueCat project, so there is one set of products, one entitlement system and one user id across all three. WebToApp never sees your purchase data, never stores customer info and does not relay RevenueCat webhooks (those go to your own backend).
| Surface | Billing | Where it runs |
|---|---|---|
| iOS app | App Store (StoreKit) | Native, through the bridge |
| Android app | Google Play Billing | Native, through the bridge |
| Browser (your website, outside the app) | RevenueCat Web Billing | Your website, through the hosted helper |
Entitlements are shared because the id you log your user in with on your website is the RevenueCat App User ID on every surface. Use the same id everywhere and a customer's entitlements follow that id from one surface to the others.
Store rules: in the app, digital goods use store billing
App Store guideline 3.1.1 and the Google Play payments policy require digital goods sold insidean app to use the store's billing. WebToApp is built around that rule: the app never opens a web checkout inside the app, and the hosted helper never routes an in-app session to Web Billing. Web Billing is only for visitors in a regular browser.
Prerequisites
- A RevenueCat project of your own
- iOS: the app in App Store Connect with bundle ID
<your-bundle-id>, and your in-app purchases created there - Android: the app in Google Play Console with application ID
com.yourcompany.yourapp, and your products created there - 9 credits to unlock the add-on, once per app
- For payments on your website: RevenueCat Web Billing (or the Stripe integration in RevenueCat)
Step 1: Set up RevenueCat
- Create a project in RevenueCat.
- Add an iOS app with bundle ID
<your-bundle-id>and/or an Android app with package namecom.yourcompany.yourapp. They must be exactly the ids of this app in WebToApp. - Create your products in App Store Connect and Google Play Console, and add them to RevenueCat.
- Create an entitlement (for example
pro) and attach your products to it. - Create an offering with packages (monthly, annual, …) and make it the current offering.
Step 2: Copy your public SDK keys
In RevenueCat, open your project's API keys. You need the public SDK key of each app: it starts with appl_ for iOS and goog_for Android. These keys ship inside every RevenueCat app and are stored in your app's published configuration in clear text.
sk_). Anything that does not start with appl_ or goog_ is rejected. Test Store keys (test_…) are rejected too, because RevenueCat crashes release builds that use one.Step 3: Unlock and enable it in WebToApp
- Open your app's Integrations tab and unlock RevenueCat (9 credits, once per app).
- Turn it on and paste the iOS key and/or the Android key.
- Choose the options below, then save.
| Setting | Default | What it does |
|---|---|---|
appleApiKey | none | Public SDK key for the iOS app. Must start with appl_. |
googleApiKey | none | Public SDK key for the Android app. Must start with goog_. |
paywalls | on | Includes RevenueCat's paywall screens so purchases.presentPaywall and purchases.presentCustomerCenter work. Off gives a smaller build, and those two calls fail with paywalls_disabled. |
autoIdentify | on | When your page posts the legacy userId message, the app logs that id in to RevenueCat (or out), so your login id becomes the RevenueCat App User ID with no extra code. |
- A platform is configured when its key is present and valid. An Android-only setup (no iOS key) keeps working: the iOS app simply reports
purchases: false. - RevenueCat is independent of OneSignal, FCM and AdMob. Use any combination.
Step 4: Rebuild
Changing any RevenueCat setting needs a new build, because the native store billing is built into the app. Request a new Android build and/or iOS build from the dashboard. On iOS there is nothing to enable in the Apple Developer Portal: In-App Purchase is available to every App ID.
Step 5: Prepare the stores and test
- iOS: attach the in-app purchases to the app version you submit for review. Test with a TestFlight build.
- Android: upload the app to a Google Play track once and activate the products before testing. Test with Play internal testing.
- Store purchases can only be tested on real devices (TestFlight, Play internal testing). The iOS simulator needs a StoreKit configuration file, which the generated app does not include.
- Sandbox purchases show
isSandbox: trueon the entitlement.
Use it from your website
The hosted helper
Add one script. It gives your website a single API for both worlds: inside the app it talks to the native store billing through the Native bridge; in a regular browser it loads RevenueCat's browser SDK (@revenuecat/purchases-js) and uses Web Billing. The script is static, needs no sign-in, and loads the browser SDK only when it runs in a browser.
<script src="https://www.webtoapp.app/sdk/webtoapp.js"></script>
<script type="module">
const wta = await WebToAppSDK.init({
webBillingApiKey: 'rcb_xxxxxxxxxxxx', // RevenueCat Web Billing public key (browser only)
appUserId: currentUser.id, // same id on every surface; omit for anonymous
});
const offerings = await wta.purchases.getOfferings();
const pkg = offerings.current.packages.find(p => p.packageType === 'monthly');
try {
const { customerInfo } = await wta.purchases.purchase({ packageId: pkg.identifier });
if (customerInfo.activeEntitlements.includes('pro')) unlockPro();
} catch (e) {
if (e.code !== 'purchase_cancelled') showError(e.message);
}
</script>currentUser, unlockPro and showError stand for your own code. The same code runs in the iOS app, the Android app and the browser, and returns the same data shapes everywhere.
WebToAppSDK.init(options) returns a promise of the client. Options:
| Option | Meaning |
|---|---|
webBillingApiKey | Your RevenueCat Web Billing public key (rcb_…). Only used in a browser. |
appUserId | Your signed-in user's id. Use the same id on every surface. Omit it for an anonymous visitor. In the app, init logs this id in to RevenueCat if it differs from the current one. |
bridgeTimeoutMs | How long a page that has a native shell waits for WebToAppBridgeReady before giving up. Default 1500. A plain browser never waits. |
What the client offers
| Member | In the app | In a browser |
|---|---|---|
isInApp, platform | From the app: true, 'ios' or 'android' | false, 'web' |
purchases.getOfferings() | Store prices through purchases.getOfferings | RevenueCat getOfferings, normalized |
purchases.purchase({ packageId, offeringId?, customerEmail? }) | The store purchase sheet | The Web Billing checkout (a modal). customerEmail is passed to it; in the app it is ignored. |
purchases.restore() | Restores purchases from the store | Reads the customer info (Web Billing has no restore: entitlements follow the user id) |
purchases.getCustomerInfo() | Customer info from the app | RevenueCat getCustomerInfo, normalized |
purchases.hasEntitlement(id) | Reads the customer info and checks the entitlement | Same |
purchases.logIn(userId), purchases.logOut() | purchases.logIn / purchases.logOut; both resolve the customer info | RevenueCat changeUser. They resolve null until the Web Billing SDK has been loaded: the id is remembered and used on first use, so calling logIn on every page view costs nothing. |
purchases.setAttributes({ key: value }) | purchases.setAttributes | RevenueCat setAttributes |
purchases.presentPaywall({ offeringId?, requiredEntitlement?, displayCloseButton?, htmlTarget? }) | The native RevenueCat paywall (displayCloseButton applies here). Resolves { result, customerInfo } with result one of purchased, restored, cancelled, error or notPresented. | RevenueCat's Web Billing paywall, in its own modal or rendered into htmlTarget if you pass one. result is purchased, cancelled or notPresented (the user already has requiredEntitlement). |
purchases.onCustomerInfoUpdated(callback) | The purchases.customerInfoUpdated event. Returns an unsubscribe function. | Fires after browser purchases and after logIn / logOut |
on, off, once, call | Pass through to WebToApp.* | on, off and once use a local emitter; call rejects with not_in_app |
Methods that the client does not wrap, such as purchases.getAppUserId and purchases.presentCustomerCenter, are reachable in the app with wta.call(method, params) (see the Native bridge methods). In a browser call rejects with not_in_app.
if (wta.isInApp) {
// iOS or Android app: store billing. wta.platform is 'ios' or 'android'
} else {
// regular browser: Web Billing. wta.platform is 'web'
}
const isPro = await wta.purchases.hasEntitlement('pro');Data shapes returned in the app and in the browser
The app and the helper's Web Billing path return exactly these shapes, so one code path works everywhere. Dates are ISO-8601 strings or null.
type Store = 'app_store' | 'mac_app_store' | 'play_store' | 'amazon' | 'stripe' | 'rc_billing'
| 'paddle' | 'promotional' | 'test_store' | 'external' | 'galaxy' | 'unknown';
interface Entitlement {
id: string;
isActive: boolean;
willRenew: boolean;
productIdentifier: string;
store: Store;
periodType: 'normal' | 'intro' | 'trial' | 'prepaid';
isSandbox: boolean;
expirationDate: string | null; // null = lifetime
latestPurchaseDate: string | null;
originalPurchaseDate: string | null;
unsubscribeDetectedAt: string | null;
billingIssueDetectedAt: string | null;
}
interface CustomerInfo {
appUserId: string; // current RevenueCat App User ID
originalAppUserId: string;
isAnonymous: boolean; // true when the id starts with "$RCAnonymousID:"
entitlements: { [id: string]: Entitlement }; // all known entitlements, active or not
activeEntitlements: string[]; // ids with isActive === true
activeSubscriptions: string[]; // product ids
managementUrl: string | null;
firstSeen: string | null;
requestDate: string | null;
}
interface Product {
identifier: string;
title: string;
description: string | null;
price: number; // major units, e.g. 4.99
priceString: string; // localised, e.g. "$4.99"
currencyCode: string; // "USD"
type: 'subscription' | 'non_subscription' | 'unknown';
period: string | null; // ISO-8601 duration ("P1M", "P1Y") or null
introductoryOffer: null | {
type: 'free_trial' | 'intro_price';
price: number; priceString: string;
period: string | null; // ISO-8601 per cycle
cycles: number;
};
}
interface Package {
identifier: string; // "$rc_monthly", custom ids, …
packageType: 'lifetime' | 'annual' | 'sixMonth' | 'threeMonth' | 'twoMonth'
| 'monthly' | 'weekly' | 'custom' | 'unknown';
offeringIdentifier: string;
product: Product;
}
interface Offering {
identifier: string;
description: string;
metadata: { [key: string]: unknown };
packages: Package[];
webCheckoutUrl: string | null;
}
interface Offerings {
current: Offering | null;
all: { [offeringId: string]: Offering };
}Identify your users
Log your user in with the same id on every surface. That id becomes the RevenueCat App User ID, and it is what makes the entitlement a customer bought in one place available in the others. Until you identify a user, RevenueCat treats them as anonymous (customerInfo.isAnonymous is true).
In the app, automatically (autoIdentify)
With autoIdentify on (the default), the userId message you may already send for push also logs the user in to RevenueCat, and posting null logs them out. No extra code.
if (window.FlutterWebView) {
// after the user signs in
window.FlutterWebView.postMessage(JSON.stringify({ type: 'userId', userId: 'user_12345' }));
// after the user signs out
window.FlutterWebView.postMessage(JSON.stringify({ type: 'userId', userId: null }));
}Explicitly, with the helper (app and browser)
await wta.purchases.logIn('user_12345'); // app: Purchases.logIn, browser: changeUser
await wta.purchases.logOut();
// In the app you can also read who RevenueCat thinks the user is
const { appUserId, isAnonymous } = await wta.call('purchases.getAppUserId');In a browser, pass the same id as appUserId to WebToAppSDK.init, as in the first example. Optionally add attributes to the customer in the app:
await wta.call('purchases.setAttributes', {
attributes: { plan_hint: 'annual', source: 'landing' },
email: 'user@example.com',
displayName: 'Ada'
});Listen for entitlement changes
The purchases.customerInfoUpdated event carries the latest CustomerInfo. It fires after setup, after every purchase, restore, log in and log out, and whenever RevenueCat reports a change: a renewal, an expiry, a refund, a deferred purchase that was approved. Use it to lock and unlock features.
const stop = wta.purchases.onCustomerInfoUpdated(function (customerInfo) {
setPro(customerInfo.activeEntitlements.includes('pro'));
});
// stop() to unsubscribe
// Come back from the background? Refresh, someone may have subscribed elsewhere
wta.on('app.foreground', async function () {
const customerInfo = await wta.purchases.getCustomerInfo();
setPro(customerInfo.activeEntitlements.includes('pro'));
});setPro stands for your own code. Without the helper, the same event is WebToApp.on('purchases.customerInfoUpdated', handler). Events are not replayed: a page that loads later should call getCustomerInfo(). Deferred Android purchases surface through this event, not through the purchase promise.
Paywalls and Customer Center
With paywalls on, presentPaywall shows your RevenueCat paywall natively. With requiredEntitlement it only appears if the user does not already have that entitlement. In the app it resolves to { result, customerInfo }, where result is purchased, restored, cancelled, error or notPresented.
const { result, customerInfo } = await wta.purchases.presentPaywall({ requiredEntitlement: 'pro' });
if (result === 'purchased' || result === 'restored') {
// the user now has "pro"
} else if (result === 'notPresented') {
// they already had it
}
// Let the user manage or cancel their subscription (app only; resolves when it is closed)
await wta.call('purchases.presentCustomerCenter');With paywalls off, both app calls (presentPaywall and presentCustomerCenter) fail with paywalls_disabled.
Web payments with RevenueCat Web Billing
- In RevenueCat, enable Web Billing (or the Stripe integration).
- Create a Web Billing app and copy its public key, which starts with
rcb_. - Pass it as
webBillingApiKeytoWebToAppSDK.initand callpurchases.purchaseas usual.
const wta = await WebToAppSDK.init({
webBillingApiKey: 'rcb_xxxxxxxxxxxx',
appUserId: currentUser.id
});
// In a browser this opens the Web Billing checkout; in the app it opens the store sheet
await wta.purchases.purchase({ packageId: pkg.identifier, customerEmail: currentUser.email });Remember the store rules above: in the app the helper always uses store billing, never Web Billing.
Handle errors
try {
await wta.purchases.purchase({ packageId: pkg.identifier });
} catch (e) {
switch (e.code) {
case 'purchase_cancelled': // the user closed the sheet: not an error
break;
case 'payment_pending': // Ask to Buy or a slow payment method
showMessage('Waiting for approval…'); // the entitlement arrives on onCustomerInfoUpdated
break;
case 'product_already_purchased':
await wta.purchases.restore();
break;
default:
showError(e.message);
}
}| Code | What it means and what to do |
|---|---|
purchase_cancelled | The user dismissed the purchase sheet. Not a failure: show nothing. |
payment_pending | The purchase is deferred (Ask to Buy, a slow payment method). The entitlement arrives later on purchases.customerInfoUpdated. |
product_already_purchased | The user already owns it. Call purchases.restore. |
network_error | Offline, or RevenueCat could not be reached. Offer a retry. |
store_problem | An App Store or Google Play error. |
not_allowed | Purchases are disabled on the device (parental controls and similar). |
invalid_package | packageId or offeringId is not in the offerings. Use pkg.identifier from getOfferings. |
not_enabled | RevenueCat is off for this app, or this platform has no key. See troubleshooting. |
paywalls_disabled | paywalls is off. Turn it on and rebuild, or build your own purchase screen. |
not_in_app | Browser only: call was used outside the app. |
web_not_configured | Browser only: a purchase was attempted without webBillingApiKey. |
web_sdk_load_failed | Browser only: RevenueCat's browser SDK could not be loaded from jsDelivr. |
Web Billing errors are mapped to the same codes as the native ones (for example a cancelled checkout is purchase_cancelled). The generic codes timeout, unsupported_method, invalid_params and unknown are listed in the Native bridge guide.
Webhooks to your own backend (optional)
WebToApp does not receive or relay RevenueCat webhooks. If your server needs to know who is entitled, for example to protect an API or unlock content on the server, configure RevenueCat webhooks to your backend. The entitlement read in the page is fine to drive the interface, but anything your server enforces should rely on the server-side data.
Let an AI agent do the setup
With an API token, an AI agent can unlock the add-on, save your two public keys and request the builds, then write the website code with the helper. See AI Agents (MCP).
Troubleshooting
not_enabled: RevenueCat is off for this app, or this platform has no valid key. Check the key for that platform (appl_…for iOS,goog_…for Android) in Integrations, make sure the add-on is on, and rebuild. In the page,(await wta.call('app.getInfo')).capabilities.purchasestells you whether the running build has it.- It works on the web but not in the app: the app build does not have the keys yet. Save the keys, request a new build and install it. A build made before you added the keys keeps answering
not_enabled;unsupported_methodmeans the build is older than the method. - Offerings are empty or
offerings.currentisnull: RevenueCat has no current offering, the packages have no products, or the products are not available in the store yet (iOS: the in-app purchases are not attached to the app version; Android: the app was never uploaded to a Play track, or the products are not active). Also check that the bundle ID or package name of the RevenueCat app equals<your-bundle-id>/com.yourcompany.yourapp. - Testing: use a real device with a TestFlight build or Play internal testing. The simulator does not work. Sandbox purchases are marked
isSandbox: true. - The entitlement does not show on another surface: the user id differs. Compare
customerInfo.appUserIdin each place; an anonymous id starts with$RCAnonymousID:. Use the same id forappUserIdon the web and foruserIdorpurchases.logInin the app. invalid_package: passpkg.identifier(for example$rc_monthly) from the offerings, not the store product id.web_not_configured: addwebBillingApiKeytoWebToAppSDK.init.web_sdk_load_failed:the browser could not reach jsDelivr, for example offline or blocked by a content blocker or your site's Content-Security-Policy.- A deferred purchase:
payment_pendingis not an error. The entitlement shows up onpurchases.customerInfoUpdatedonce it is approved. - Not supported in this version: promotional offers, win-back offers and product changes (upgrade or downgrade replacement modes) are not exposed to your page; the paywall handles them.