Skip to content
← Journal
16 min readAta Mohammadi

Ads and One-Time Purchases in React Native: A Working Guide to RevenueCat, AdMob and the Failures That Cost Money Silently

The free app with a remove-ads purchase is the most common indie model there is, and almost every guide to it stops at the SDK calls. This is the rest: consent ordering, entitlement resolution, interstitial pacing, and the four ways an integration earns nothing while looking perfectly healthy.

The model is the most common one in independent mobile software: the app is free, it shows a banner and an occasional interstitial, and one non-consumable purchase makes the ads go away. Two SDKs, maybe three hundred lines. Every guide covers it.

What the guides do not cover is that this integration has an unusual property — almost every way it breaks is silent. It does not crash. It does not log an error. The app launches, renders, shows ads, accepts purchases, and passes its test suite, while earning nothing, or charging people for something that was never there. I have watched all four of the failures in this article happen in shipped code, and not one of them was found by a test.

So this is a step-by-step guide with the traps left in. Versions are as of 2026-09-16, checked against the registry rather than remembered: [email protected], [email protected], [email protected], [email protected].

The model, stated once

One purchase, one entitlement, everything else derived from it.

store product   com.example.<app>.removeads   (non-consumable, one price)

RevenueCat      offering "default" → package $rc_lifetime → entitlement "remove_ads"

app             useIsPro() === true  →  no banner, no interstitial, no limits

Two decisions in there are worth defending.

One entitlement, not two. It is tempting to sell "remove ads" and "pro features" separately. Do not: you double the store products, the RevenueCat objects, the paywall copy and the test matrix to serve a distinction users do not perceive. Name the product for the ads, because that is what a free user actually feels, and let the feature limits ride along with the same entitlement.

$rc_lifetime is the package identifier. It is what the SDK exposes as offerings.current.lifetime, so client code reads a stable name rather than a product id. Get this right early, because renaming an entitlement means recreating it, and the public SDK keys die with it.

Step 1 — Pin the versions, and know why

This is the least glamorous step and the one that will cost you a day if you skip it.

The ads SDK has a Kotlin cliff. On Expo SDK 57, react-native-google-mobile-ads must be pinned to exactly 16.3.4. The reason is visible in the packages themselves — each release declares the native SDKs it pulls in:

npm view [email protected] sdkVersions.android.googleMobileAds
npm view [email protected] sdkVersions.android.googleMobileAds
25.0.0
25.4.0

play-services-ads 25.4.0 ships Kotlin 2.3.0 metadata, which SDK 57's Kotlin 2.1.0 toolchain refuses to read. The build fails at metadata parsing, not at your code.

The obvious fix — forcing Kotlin upwards — does not work, and understanding why saves you trying it. android.kotlinVersion moves the stdlib; the compiler version comes from the buildscript classpath. Raising the stdlib alone leaves the mismatch, and raising both breaks react-native-purchases and react-native-safe-area-context, which are compiled against the older metadata. Pin the ads module and wait for the platform.

The billing library deadline has passed. As of 31 August 2026, Google Play refuses new apps and updates built against Play Billing Library 7 or earlier; an extension to 1 November 2026 was available only if requested before the original date. So check what your SDK actually pulls in rather than trusting a changelog. The chain is three hops and entirely public:

grep purchases-hybrid-common node_modules/react-native-purchases/android/build.gradle
curl -s https://repo1.maven.org/maven2/com/revenuecat/purchases/purchases-hybrid-common/18.37.0/purchases-hybrid-common-18.37.0.pom | grep -A2 '>purchases<'
curl -s https://repo1.maven.org/maven2/com/revenuecat/purchases/purchases/10.20.0/purchases-10.20.0.pom | grep -A2 billingclient
implementation 'com.revenuecat.purchases:purchases-hybrid-common:18.37.0'
  <artifactId>purchases</artifactId>
  <version>10.20.0</version>
      <artifactId>billing</artifactId>
      <version>8.3.0</version>

[email protected] resolves to Billing Library 8.3.0, so it is compliant. That took four commands and is worth running against your own lockfile, because the answer is a property of your resolved tree, not of the documentation.

The rest of the dependency list is short: expo-tracking-transparency for the iOS ATT prompt, and that is genuinely all.

Step 2 — Settle the identifiers before anything consumes them

Three identifiers are effectively permanent, and each one has a blast radius:

Identifier Set by Cost of changing it later
iOS bundle id App Store Connect, at record creation Recreate the RevenueCat app → public SDK keys change
Android package Play, at first bundle upload Same, plus a new upload key negotiation
Entitlement lookup key RevenueCat Recreate the entitlement → keys die with it

The App Store Connect bundle id and the Android package are allowed to differ, and neither is user-visible. If they have already diverged, leave them: aligning either side means deleting a store record, which cascades into RevenueCat and invalidates keys that are by then sitting in CI secrets and EAS environments.

Ten EXPO_PUBLIC_* values come out of this step — two AdMob app ids, six ad unit ids, two RevenueCat public keys. They must reach both the EAS build environment and the repository secrets. Hold that thought until step 8; it is the single most expensive item in this article.

Step 3 — The initialisation order is a compliance requirement

This is the part people get wrong first, and it is not a preference. Google's documented order, on iOS, is: UMP consent, then App Tracking Transparency, then initialise the Mobile Ads SDK.

Initialising the ads SDK before consent has been gathered can put an ad request on the wire without consent, which in the EEA, the UK and Switzerland is a policy breach and a common cause of AdMob account suspension. The order is not about correctness of display; it is about what leaves the device.

// Imports react-native-google-mobile-ads and expo-tracking-transparency,
// whose types are not installed in this article's checker.
export async function initializeAds(): Promise<void> {
  if (initialized) return;
  initialized = true;
  try {
    // Order matters: UMP consent first, then ATT, then the SDK.
    applyConsent(await gatherConsent());
    await requestTrackingIfNeeded();

    if (!consent.canServeAds) return;

    await mobileAds().setRequestConfiguration({
      maxAdContentRating: MaxAdContentRating.G,
      tagForChildDirectedTreatment: false,
      tagForUnderAgeOfConsent: false,
    });
    await mobileAds().initialize();
    preloadInterstitial();
  } catch (error) {
    // Ads are optional; the app works regardless.
    if (IS_DEV) console.log('[ads] initialisation failed', String(error));
  }
}

Three things in that function are deliberate.

ATT denial is a normal outcome, not an error. If the user declines, you fall back to non-personalised ads. Do not branch the app on it.

Every path resolves; none throws. An ad failure must never block the work the app exists to do. A missing fill means the user carries on without an ad.

The consent decision gets logged in development. This matters more than it looks, and it is the subject of the next section.

Consent as pure functions

Keep the compliance rules out of the async service so they can be unit tested. These are the real ones, and they type-check as written:

export type ConsentStatus = 'UNKNOWN' | 'REQUIRED' | 'NOT_REQUIRED' | 'OBTAINED';

export interface ConsentInfoLike {
  status: ConsentStatus;
  canRequestAds: boolean;
  privacyOptionsRequirementStatus: ConsentStatus;
}

/**
 * The UMP SDK owns this decision — it accounts for region, consent status and
 * the TCF string. Anything other than an explicit `true` fails closed.
 */
export function canServeAds(info: ConsentInfoLike | null | undefined): boolean {
  return info?.canRequestAds === true;
}

/** Google requires an in-app entry point wherever it reports REQUIRED. */
export function shouldOfferPrivacyOptions(
  info: ConsentInfoLike | null | undefined,
): boolean {
  return info?.privacyOptionsRequirementStatus === 'REQUIRED';
}

canRequestAds === true and nothing looser. Failing closed is correct, but it produces the first silent failure: an unanswered consent form and a completely broken integration look identical from the outside — an app with no banner. You will spend an afternoon debugging ad unit ids when the answer is that consent was never granted.

That is what the development-only log line is for:

[ads] consent {"canServeAds":false,"offerPrivacyOptions":true}

One line, release builds silent, and it turns a two-hour mystery into a glance. Force the geography in development so both paths are reachable on a simulator — EEA makes the form appear every launch, OTHER skips it — and pass no options at all in release so the SDK uses the real location.

One more requirement that is easy to miss: where UMP reports privacyOptionsRequirementStatus === 'REQUIRED', Google requires a persistent in-app entry point to reopen the consent form. A settings row calling AdsConsent.showPrivacyOptionsForm(). Consent revocation is a GDPR requirement, not a nicety.

Step 4 — Entitlement is a tagged union, not a boolean

Here is a bug that shipped, and it is worth staring at.

A boolean cannot distinguish "the store said no" from "we never reached the store". An earlier version of our purchase function resolved that ambiguity the convenient way — by granting Pro. Every offline launch was a free upgrade.

The fix is to make the ambiguity unrepresentable:

export type PurchaseOutcome =
  | { status: 'unlocked' }
  | { status: 'cancelled' }
  | { status: 'no_entitlement' }
  | { status: 'store_unavailable' }
  | { status: 'failed'; message?: string };

Callers must be able to tell a cancellation (say nothing) from an outage (say "try again") from a genuine absence of entitlement (say "nothing to restore"). Three different pieces of UI, and a boolean collapses them into one wrong one.

The read path has the mirror-image rule:

// Imports react-native-purchases; types not installed in the checker.
export const checkIsPro = async (): Promise<boolean> => {
  if (!(await initPurchases())) return false;
  try {
    const customerInfo = await Purchases.getCustomerInfo();
    return customerInfo.entitlements.active[ENTITLEMENT_ID] !== undefined;
  } catch {
    return false;
  }
};

Resolve to false whenever entitlement cannot be confirmed. That sounds hostile to paying customers until you know that RevenueCat serves a cached CustomerInfo offline, so a paying user who has launched the app once before stays unlocked on a flight. The cache protects them; the false protects you.

And resolve the API key from the environment with no baked-in fallback. A hardcoded key keeps working after the RevenueCat app is recreated, so the app looks healthy while every purchase fails against a dead project.

The trap that turns off all revenue

Now the third silent failure, and my favourite, because it is so reasonable.

One app modelled entitlement with an isReady flag that stayed false whenever RevenueCat was unconfigured or unreachable. The banner component read that as "still loading" and waited. Forever. The result: no ads at all, on exactly the devices where billing is unavailable — which is a meaningful slice of Android.

Resolve to "free" rather than to "unknown". Unknown is not a state the UI can render, so every component invents its own interpretation of it, and at least one of them will be "show nothing".

Step 5 — One adapter, one line

Every app keeps entitlement in its own domain store under its own name. The shared ad components must not know that. One adapter:

declare const useCaptionStore: <T>(selector: (state: { isPro: boolean }) => T) => T;

/**
 * Whether the one-time upgrade is owned. The only file of the ads
 * integration that differs between apps.
 */
export const useIsPro = (): boolean => useCaptionStore((state) => state.isPro);

That is the entire per-app surface. Everything else — banner, interstitial, consent, pacing — is identical across a portfolio and should be copied rather than re-derived. Pacing that differs per app is pacing nobody can reason about.

Step 6 — Pace the interstitial like you respect the user

The banner is easy: render it when canServeAds && !isPro. The interstitial is where apps become unpleasant, and where the rules deserve to be explicit and tested.

export const COMPLETIONS_BETWEEN_INTERSTITIALS = 2;
export const MIN_COMPLETIONS_BEFORE_FIRST_INTERSTITIAL = 1;
export const MIN_MS_BETWEEN_INTERSTITIALS = 90_000;

export interface InterstitialContext {
  /** Successful completions, including the one that just finished. */
  completions: number;
  lastInterstitialAt: number;
  now: number;
  isPro: boolean;
}

export function shouldShowInterstitial({
  completions,
  lastInterstitialAt,
  now,
  isPro,
}: InterstitialContext): boolean {
  if (isPro) return false;
  if (!Number.isFinite(completions) || completions <= MIN_COMPLETIONS_BEFORE_FIRST_INTERSTITIAL) {
    return false;
  }
  if (completions % COMPLETIONS_BETWEEN_INTERSTITIALS !== 0) return false;

  const elapsed = now - lastInterstitialAt;
  // A negative elapsed time means the device clock moved backwards — stay quiet
  // rather than showing an ad the cadence did not earn.
  if (elapsed < MIN_MS_BETWEEN_INTERSTITIALS) return false;

  return true;
}

Run that function against the cases that matter and the behaviour is easy to confirm:

false  completions:1 (the shipped call site)
true   completions:2, cold
false  completions:3 (odd)
false  completions:4, 30s since last
true   completions:4, 120s since last
false  completions:4, paid user
false  clock moved back

Note the first line. Hold on to it.

The load-bearing idea is the definition of a completion: whatever the app exists to produce, counted once it has actually succeeded. A captioned video written to the library, a redacted PDF saved, a signed document exported. Counting attempts instead shows an ad to someone whose export just failed, which is the worst possible moment.

From that follow three placement rules:

  1. The first completion is always clean. It is where someone decides whether the app is worth keeping.
  2. The ad comes after the work is finished and saved — never during it, never over the result.
  3. It goes behind whatever confirmation the action already shows, so it never covers the thing the user just made.

There is one honest exception, and it is instructive: an app whose results are the whole screen has no "after" that is not the result. There, the ad runs at the start of the next run instead. The rule is about not interrupting the payoff, not about literal ordering.

Persist the counters, and parse them defensively — everything fails towards fewer ads. Unreadable state is treated as a fresh install, which costs at most one impression, whereas trusting a bad value shows an ad on someone's first export. A timestamp in the future means the device clock moved backwards after an ad was shown; clamp it to now, or a clock set years ahead suppresses ads permanently.

Step 7 — The paywall must show the real, store-localised price

Render the price from the offering, never from a constant:

const offerings = await Purchases.getOfferings();
const pkg = offerings.current?.lifetime
  ?? offerings.current?.availablePackages?.[0]
  ?? null;
// pkg.product.priceString — already localised and currency-correct

Two reasons, one commercial and one procedural. The commercial one is that a hardcoded $3.99 is wrong in most of the world. The procedural one is that App Review rejects paywalls whose displayed price is baked into the bundle, and the rejection arrives late.

This also produces the fourth silent failure. If the offerings call never resolves, the paywall sits on Loading price… forever — and a paywall that never renders a price is a purchase that can never happen. Give it a resolved/unresolved state and a fallback, and make sure a device pass actually opens the paywall. That last point is not hypothetical: a fix for exactly this bug sat in our template while seventeen of eighteen apps carried the defect, and three of them had passed a full device pass, because none of those passes happened to open the paywall.

Step 8 — Gate the release, in the pipeline

Now the most expensive item in the article.

A missing ad identifier does not fail anything. The SDK falls back to Google's test ad units. The app builds, launches, renders, serves ads, and earns nothing. There is no crash, no warning, and no visible difference — test ads look like ads.

declare const IS_DEV: boolean;
declare const ADMOB: { interstitialUnitId?: string };
declare const TestIds: { INTERSTITIAL: string };

const interstitialUnitId =
  IS_DEV || !ADMOB.interstitialUnitId ? TestIds.INTERSTITIAL : ADMOB.interstitialUnitId;

That fallback is correct — you want test units in development — and it is exactly why production needs a hard gate. The check must reject absent, blank, and still-a-test-unit values. Three conditions; the third is the one people forget.

And the gate has to run inside the build job. Ours did not, once: the store workflows built, signed and uploaded to TestFlight and Play without ever passing the EXPO_PUBLIC_* identifiers to the build and without running the check. Nothing failed. The apps would have shipped with Google's test units, served ads perfectly, and earned nothing.

Two habits that catch this whole class of problem:

  • When you add a gate, add it to CI in the same change, and watch it fail once on a case you know is bad. A gate that has never been seen red has never been shown to work. We had a paywall-copy checker that ran only in the local script and was simply never called by CI — an app whose paywall made four claims about features it did not have went green in CI twice while the local script refused the identical commit. An absent step looks exactly like a passing one.
  • Run the gate only on the production profile. A preview build is supposed to carry test units.

One last thing that is not a bug: expect near-zero revenue for the first few days after launch regardless. Every new AdMob app sits in "Requires review — limited ad serving" until it is linked to a live listing and approved. You will spend an afternoon deciding it is an integration fault.

The uncomfortable part

Everything above is mechanical. This one is not, and it is the reason I wanted to write this article.

Six apps in our portfolio called shouldShowInterstitial with a completion count that could never clear their own MIN_COMPLETIONS_BEFORE_FIRST_INTERSTITIAL — the first line of the transcript above, completions: 1, returning false forever. The policy rejects anything at or below the minimum, so the branch was dead in all six: no interstitial could ever appear. A seventh app had showInterstitial preloaded on every launch and called from zero call sites — loaded always, shown never, 0% impressions.

Every one of those apps had a passing adPolicy.test.ts.

The policy was never the broken part. It was tested against its own inputs, in isolation, and it was correct about every one of them. Nothing tested the call site, so the wiring could be nonsense and the suite stayed green — and a green suite is precisely what stops anyone from looking.

That is an ordinary testing failure, and I would have filed it as one, except for what those six paywalls said. Each of them sold "the banner and the full-screen ad are gone for good" — a claim someone pays real money for, about an ad that did not exist in the build they were paying to remove.

So the lesson is not "write integration tests", though you should. It is narrower and more useful:

When a claim is on the paywall, the test belongs to the claim. "Does this policy return false for these arguments" and "can this feature ever happen in the product" are different questions, and only the second one is what the paywall is selling. Money is the forcing function. If the copy says the ad goes away, something in CI must prove the ad was there.

The check we added fails an app whose completion count is a literal that can never clear its own minimum, or that imports showInterstitial without ever invoking it. And — following the rule from step 8 — we confirmed it went red on the original defect before trusting it, because a gate that passes everywhere may be passing vacuously.

The compressed version

  • One purchase, one entitlement, named for the ads. Settle the bundle id, package name and entitlement key before anything consumes them; changing any of the three invalidates your public SDK keys.
  • Pin [email protected] on Expo SDK 57. Forcing Kotlin up moves the stdlib, not the compiler, and breaks two other native modules.
  • Verify your resolved Billing Library version from the POM chain, not the changelog — Play has refused pre-v8 uploads since 31 August 2026.
  • Initialisation order is a compliance requirement: UMP consent → ATT → Mobile Ads SDK. Initialising first can put an ad request on the wire without consent.
  • Fail closed on consent, and log the decision in development — withheld consent and a broken integration are indistinguishable from the outside.
  • Make the purchase outcome a tagged union. A boolean cannot separate "declined" from "unreachable", and the convenient resolution of that ambiguity is to give the app away.
  • Resolve unknown entitlement to free, never to "loading". An entitlement that never resolves means no ads at all.
  • Show the store-localised price from the offering. A baked-in price is wrong abroad and gets rejected at review.
  • Gate the release on absent, blank and still-a-test-unit identifiers, inside the build job, on the production profile only.
  • Test the call site, not only the unit. If the paywall makes a claim, something in CI must prove the claim was true.

Sources

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.