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 |
@revenuecat/purchases-js |
| Needs a store | Yes (Google Play) | No — RevenueCat Web Billing serves it |
| Key |
goog_… public SDK key |
rcb_… (or strp_…) |
| Identity | device UUID | $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.




Top comments (0)