Appopolis

Your app is giving its in-app purchase away for free, and the source code looks correct

23 August 2026react nativein-app purchaserevenuecatiosshipping

Your React Native app has a one-time unlock. A user taps Unlock forever, and instead of Apple's payment sheet they get the full app, immediately, for nothing.

Not a crash. Not an error. The paywall renders, the price is right, the button responds, the app unlocks and stays unlocked across restarts. It behaves exactly like a successful purchase, because as far as your code is concerned it was one.

We nearly shipped three apps like this. Mortgage Free, Water Tracker and Dawn Alarm each reached a signed, uploadable archive in that state. The source was correct in all three. So was App Store Connect. So was RevenueCat.

Why the payment sheet never appears

Almost every React Native purchase wrapper — ours included — loads the native module defensively:

function getPurchases() {
  try {
    return require('react-native-purchases').default;
  } catch {
    return null;
  }
}

That try/catch is there for a good reason. It lets the app run in the simulator and in development before the store is wired up, instead of dying at import. Every codebase that has ever had a paywall has something like it.

Then, one line later, comes the decision that costs you money:

const live = !!config.revenueCatKey && !!Purchases;

live is what separates a real purchase from a simulated one. And it depends on two things: your key, and whether the native module actually loaded.

Set the key perfectly and live is still false if Purchases is null. At which point the unlock path does this:

purchaseUnlock: async () => {
  if (live) {
    const pkg = pkgRef.current ?? (await Purchases.getOfferings())?.current?.availablePackages?.[0];
    if (!pkg) throw new Error('store-unavailable');
    await Purchases.purchasePackage(pkg);
    return;
  }
  setUnlockedLocal(true);   // dev / sim fallback — no store key configured
}

setUnlockedLocal(true). Free app, no payment sheet, written to storage so it survives a restart.

The part worth sitting with

Look at that if (live) branch again. It is careful. It refuses to fall through to the local unlock when the offering can't be fetched. It throws store-unavailable rather than quietly giving the app away — someone thought hard about exactly this failure and defended against it.

And it does nothing, because the guard is gated on the very flag that silently flipped. A safety check that lives inside if (live) cannot protect you from live being wrong.

That is the general shape of the bug, and it is not specific to purchases: a fallback for development, a boolean that selects it, and no alarm when a production build picks the development branch. The fallback was designed for a case that ends at your simulator. Nothing stops it boarding a plane to the App Store.

Why nothing else catches it

This is the genuinely nasty part. Every place you would think to look reports success:

paywall is wired. Code review finds nothing, because there is nothing.

the offering exists. RevenueCat cannot tell you that a binary you never ran failed to link a framework.

approved.

by design, and the fallback branch is the bug. It unlocks, and you tick it off as tested.

charged. Most testers do not think a free unlock is worth reporting.

There is no error, no warning and no log line anywhere in that list. The only artefact that knows the truth is the compiled binary.

Check it yourself in one command

You do not need to reproduce a purchase to find this. Ask the archive whether RevenueCat is inside it:

ARCH="$HOME/Library/Developer/Xcode/Archives/<date>/<Name>.xcarchive"
strings "$ARCH/Products/Applications/<App>.app/<App>" | grep -c RevenueCat

Greater than zero means the framework is linked. Zero means every purchase in that build is free, whatever your source says.

It is now a hard step in our build tool, printed on every archive:

out = subprocess.run(["strings", str(binary)], capture_output=True, text=True).stdout
log(f'  RevenueCat linked: {"YES" if "RevenueCat" in out else "NO — the unlock will not work"}')

Two lines. They are the only reason three apps did not ship as giveaways.

The actual cause, and the fix

In our case react-native-purchases was not in package.json at all for those apps. Autolinking is not magic; it links what is installed. No dependency, no pod, no framework in the binary, require throws, catch returns null, live is false.

Check every app in one pass:

for d in apps/*/; do
  printf '%-20s %s\n' "$(basename $d)" \
    "$(node -e "console.log(require('./$d/package.json').dependencies?.['react-native-purchases'] ?? '— MISSING')")"
done

Ours now reads 10.4.3 on every row. It did not before.

The same failure arrives by other routes, all of which end at the same missing framework:

and the workspace disagree.

were reinstalled.

Xcode build cache old enough to hide it.

does not scan.

Notice that every one of these is an environment problem. Not one of them is visible in a diff, which is why reviewing the source can never find them.

Make the failure loud instead

The check above catches it before upload, but the deeper fix is to stop the development fallback from being reachable in a release build at all:

const live = !!config.revenueCatKey && !!Purchases;

if (__DEV__ === false && config.revenueCatKey && !Purchases) {
  throw new Error(
    'react-native-purchases is not linked. This build would give the unlock away for free.'
  );
}

A key configured but no native module is never a legitimate production state. It means someone intended real purchases and the build did not deliver them. Crashing on launch is an unpleasant way to find out, and it is considerably better than finding out from your revenue report.

If a hard crash is too blunt, at minimum make live === false in a release build refuse to grant anything — treat a missing module as locked, not as free. Fail closed. The safe default for "I cannot verify this purchase" is not "give them the app".

Other things only the archive will tell you

Once you are checking the built artefact rather than the source, a few more checks pay for themselves:

main.jsbundle is Hermes bytecode. You cannot grep it for source strings to confirm your fix is in there. Compare an md5 against a known-bad build instead — if the hash is identical, you archived the same JavaScript again and whatever you "fixed" is not in the build.

CFBundleIdentifier in Info.plist is often the literal $(PRODUCT_BUNDLE_IDENTIFIER). Read the real value from ApplicationProperties in the archive's own Info.plist, or resolve it from project.pbxproj. Scripts that trust the template string upload to the wrong app.

Confirm the build number you think you are shipping. One PlistBuddy -c "Print :ApplicationProperties:CFBundleVersion" has saved us more confused debugging than any other single line.

The short version

turns that into null.

free, with no payment sheet.

all report success. None of them inspect the binary.

giveaway.

or lock — never unlock.

The lesson generalises past purchases. Verify the artefact you are shipping, not the source you think it was built from. Every check we run on source code has found nothing; every check we run on the archive has found something.

Comments

No account needed — pick any name and say your piece.

    No account, no email. Be decent — anything vile gets removed.