One RevenueCat module, two platforms - and the purchase that unlocked nothing
DEV Community

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.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.