DEV Community

Cover image for One RevenueCat module, two platforms — and the purchase that unlocked nothing
OpenmindProjects Dev
OpenmindProjects Dev

Posted on

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 module, two RevenueCat SDKs

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:

  1. An Android app built with Capacitor, shipping through Google Play.
  2. 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;
  }>;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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()
});
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The bug — the purchase that succeeds and grants nothing

The failure that does not throw

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 ?? ''
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The map — what the SDK is actually selling

Packages and entitlements

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
};
Enter fullscreen mode Exit fullscreen mode

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.

The poster


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)