One RevenueCat module, two platforms - and the purchase that unlocked nothing
Disclosure: I'm the co-founder of OpenmindProjects, which builds Globie Earth - the app in this post - and we're entering it in RevenueCat Shipaton 2026. Every snippet below is code we ship; every bug is one we hit.
One façade file, two SDKs that never meet inside the same bundle.
The shape of the problem
Globie Earth lets you adopt a real tree in the Isaan Valley and grow a guardian that mirrors it.
It sells from two places:
- An Android app built with Capacitor, shipping through Google Play.
- A web build, where there is no store at all.
RevenueCat gives you two SDKs for exactly this - and they are not the same SDK with a different key:
| Android (native) | Web |
|---|---|
| Package |
@revenuecat/purchases-capacitor
|
| Needs a store | Yes (Google Play) |
| Key | goog_… public SDK key |
| Identity | device UUID |
| Package |
@revenuecat/purchases-js
|
| Needs a store | No - RevenueCat Web Billing serves it |
| Key | rcb_… (or strp_…) |
| Identity |
$RCAnonymousID:<uuid>
|
Everything the app does with RevenueCat goes through one file, src/lib/revenuecat.ts.
Both branches return a CustomerInfo shaped the same way, so no component ever knows which platform it is on:
export interface CustomerInfo {
readonly entitlements: {
readonly active: Readonly<Record<string, unknown>>;
};
readonly nonSubscriptionTransactions: ReadonlyArray<{
readonly productIdentifier: string;
}>;
}
Technique 1 - keep the web SDK out of the Android bundle
A static import would put purchases-js inside the APK, where it can never run. It loads on demand:
type WebSdk = typeof import('@revenuecat/purchases-js');
let webSdkPromise: Promise<WebSdk> | null = null;
function loadWebSdk(): Promise<WebSdk> {
if (!webSdkPromise) {
webSdkPromise = import('@revenuecat/purchases-js')
.then((sdk) => {
webErrorCode = sdk.ErrorCode;
return sdk;
})
.catch((error) => {
webSdkPromise = null;
throw error;
});
}
return webSdkPromise;
}
The ErrorCode enum is cached on the same load - isUserCancelled() has to compare against it synchronously later, and it is only ever reachable after an earlier call has loaded the SDK.
Technique 2 - one identity per platform, deliberately different
On native, the device UUID is the appUserID - the same identity row-level security already scopes progress to:
configurePromise = Purchases.configure({
apiKey: PUBLIC_SDK_KEY as string,
appUserID: appUserID ?? getDeviceId()
});
On web it must not be getDeviceId(). A device UUID is not anonymous to RevenueCat, so a later identifyUser becomes a plain switch and silently drops any purchase made before sign-in.
So the browser mints a real anonymous id instead:
const minted = WebPurchasesSdk.generateRevenueCatAnonymousAppUserId();
storeWebAppUserId(minted); // localStorage: 'globie.rcAppUserId'
Sign-in then aliases (carrying the pre-sign-in purchase over), and only falls back to changeUser for every other move:
const info = webInstance.isAnonymous()
? (await webInstance.identifyUser(appUserId)).customerInfo
: await webInstance.changeUser(appUserId);
The bug - the purchase that succeeds and grants nothing
RevenueCat has a failure mode that does not throw. purchasePackage does not error for a package that exists but carries no entitlement.
We had guardian_premium attached to guardian_unlock, while the product the app actually sells and requests is forestkeeper_monthly:monthly.
The result: the user pays, the store charges, the SDK resolves successfully, and the app unlocks nothing. No exception. No red text. A happy path that gives the customer nothing.
The fix was one line in the dashboard - move the entitlement onto the product the app actually sells.
The fix in code was to never trust silence:
/** True when the given entitlement is present in the active entitlement map. */
export function hasActiveEntitlement(
customerInfo: CustomerInfo | null,
identifier: string
): boolean {
return customerInfo?.entitlements.active[identifier] !== undefined;
}
When you wire an entitlement, assert on the entitlement - never on the absence of an error.
We moved the four dead one-time products to inactive rather than deleting them, because a Play product id is permanent and a one-time product is only deletable if it was never purchased.
Technique 3 - the web SDK has no customer-info listener
purchases-js does not ship an update listener, so the module fans out its own - which keeps useEntitlements on a single code path for both platforms:
const webListeners = new Map<string, (ci: CustomerInfo) => void>();
function notifyWebListeners(customerInfo: CustomerInfo): void {
for (const listener of webListeners.values()) listener(customerInfo);
}
Every mutation the module performs - purchase, restore, identity switch - calls it.
Technique 4 - "failed" has three different meanings
A cancelled sheet, an already-owned product and a dead network must not read the same to the user:
export function isAlreadyOwnedError(error: unknown): boolean {
if (!Capacitor.isNativePlatform()) return false;
// Play surfaces ITEM_ALREADY_OWNED without the code in some configurations,
// so the store's own wording is the fallback.
return /already (owned|subscribed|purchased)|ITEM_ALREADY_OWNED/i.test(
(error as Partial<PurchasesError>)?.message ?? ''
);
}
The already-owned case is the nasty one: Play reports it when a pass bought under an app user id this device no longer uses is bought again - no money moves, nothing is granted, and without an automatic restore the user is left at a "you already own this" dead end.
And restore is not one thing either - on a store it re-reads receipts; on the web there is nothing to restore, so it is a plain refresh:
export async function restorePurchases(): Promise<CustomerInfo> {
if (!Capacitor.isNativePlatform()) {
const instance = await configureWebInstance(
await resolveWebAppUserId()
);
return instance.getCustomerInfo();
}
await Purchases.restorePurchases();
return (await Purchases.getCustomerInfo()).customerInfo;
}
The map - what the SDK is actually selling
Two entitlements, four products, and one rule: a species has exactly one price, so "Adopt a Kae Na" and "Plant a Mother Tree" resolve to the same package.
| Package id | Entitlement | What it is |
|---|---|---|
forestkeeper_monthly
|
guardian_premium
|
The subscription |
globie.tree.mother
|
- (consumable) | Mother Tree · kae-na |
globie.tree.father
|
- (consumable) | Father Tree · yang-na |
globie.tree.pair
|
- (consumable) | Sacred Pair |
const ADOPTION_PACKAGE_IDS: Record<string, string> = {
'kae-na': PACKAGE_IDS.treeMother,
'yang-na': PACKAGE_IDS.treeFather
};
Why this is the whole article, really
The same question we ask every screen - can this be derived from something that actually happened? - is the purchase rule: don't trust the absence of an exception, ask for the entitlement by name. One threw a charge at a user and no error at us.
Try it
Android / Google Play - live now: Globie Earth (com.globie.earth). The Journey tab is free to explore.
Disclosure (repeated): I'm the co-founder of OpenmindProjects, which builds Globie Earth. The code samples are from our shipped src/lib/revenuecat.ts; the bug is one we shipped and fixed.
Comments
No comments yet. Start the discussion.