Your app is giving its in-app purchase away for free, and the source code looks correct
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:
- The source is right. The dependency is imported, the key is passed, the
paywall is wired. Code review finds nothing, because there is nothing.
- RevenueCat's dashboard is right. The app exists, the entitlement exists,
the offering exists. RevenueCat cannot tell you that a binary you never ran failed to link a framework.
- App Store Connect is right. The in-app purchase is there, priced,
approved.
- The simulator "works". Of course it does — it takes the fallback branch
by design, and the fallback branch is the bug. It unlocks, and you tick it off as tested.
- TestFlight looks fine too, unless the tester notices they were never
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:
pod installnever ran after adding the dependency, so the Podfile.lock
and the workspace disagree.
expo prebuild --cleanregeneratedios/and you archived before pods
were reinstalled.
- A stale
ios/directory from before the dependency existed, with an
Xcode build cache old enough to hide it.
- Monorepo hoisting put the package somewhere React Native's autolinking
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
- A missing native module makes
requirethrow, and a defensivetry/catch
turns that into null.
nullflips yourliveflag, and the development fallback grants the unlock
free, with no payment sheet.
- Source review, RevenueCat, App Store Connect, the simulator and TestFlight
all report success. None of them inspect the binary.
strings <binary> | grep -c RevenueCatis the whole test. Zero is a
giveaway.
- Fail closed: in a release build, a configured key with no module should crash
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.
The apps on both sides of this bug
Mortgage Payoff VisualizerThe app that found it — one archive away from shipping as a giveaway On the App Store → Tiny HabitsThe one that always declared the dependency, which is why it never broke On the App Store →Comments
No account needed — pick any name and say your piece.
Nothing here yet. Be the first.