Skip to content

Paywall integration

RevenueCat and Wire AI on React Native

RevenueCat owns the entitlement, Wire AI owns the flow that runs before the paywall. The join is three moves: wait for CustomerInfo before you mount the onboarding, pass the entitlement in as context so a paying user never gets asked to pick a plan again, and report the paywall moment with wire.track so the experiment engine can score what the flow did. Ordering is the whole job here. CustomerInfo arrives over the network, so a component that branches on mount reads an empty entitlement map and files every subscriber as free.

Install

terminal
$
npxexpo install react-native-purchases
$
npminstall @wireai/activation

API surface on this page read against @wireai/activation 0.13.5 (npm latest, read 2026-08-04) and react-native-purchases 10.6.0 (npm latest, read 2026-08-02). These are the versions the calls below were checked against, not a claim that this exact file is running in production somewhere.

Gate the mount on CustomerInfo

Purchases.getCustomerInfo() is a promise. Until it resolves you do not know whether this person is paying, and the useful default in that window is not "free", it is "do not decide yet". Hold three states, not two: pending, resolved, resolved-but-the-call-failed.

The kit ships LoadingScreen for exactly this beat, so the waiting frame already matches the theme the flow is about to render in.

components/OnboardingScreen.tsx
1import { useEffect, useState } from "react";
2import Purchases, { type CustomerInfo } from "react-native-purchases";
3import { WireOnboarding, LoadingScreen } from "@wireai/activation";
4
5import { wireConfig } from "@/lib/wire";
6import { StaticOnboarding } from "@/components/StaticOnboarding";
7
8export function OnboardingScreen({ onDone }: { onDone: () => void }) {
9  // undefined = still asking RevenueCat. null = asked, and the call failed.
10  const [info, setInfo] = useState<CustomerInfo | null | undefined>(undefined);
11
12  useEffect(() => {
13    let cancelled = false;
14    Purchases.getCustomerInfo()
15      .then((next) => { if (!cancelled) setInfo(next); })
16      .catch(() => { if (!cancelled) setInfo(null); });
17    return () => { cancelled = true; };
18  }, []);
19
20  if (info === undefined) return <LoadingScreen />;
21
22  const isPro =
23    !!info && typeof info.entitlements.active.pro !== "undefined";
24
25  return (
26    <WireOnboarding
27      config={wireConfig}
28      userContext={{ entitlement: isPro ? "pro" : "free" }}
29      onComplete={onDone}
30      fallbackFlow={<StaticOnboarding onDone={onDone} />}
31    />
32  );
33}

Report the paywall moment

createWireActivation gives you an awaitable POST that resolves once the server has actually stored the event, and only then bumps decision revalidation. That ordering matters when a review prompt or a questionnaire is gated on the same action: a bump before the write lands makes the gate re-fetch a decision the server has not seen yet.

useWireActivation is the React glue over the same factory. The instance is built once per mount and held in a ref, so re-renders never re-resolve the device key.

components/Paywall.tsx
1import { useEffect } from "react";
2import Purchases from "react-native-purchases";
3import { useWireActivation } from "@wireai/activation";
4
5import { wireConfig } from "@/lib/wire";
6import { PaywallView } from "@/components/PaywallView";
7
8export function Paywall() {
9  const { track } = useWireActivation(wireConfig);
10
11  useEffect(() => {
12    void track("paywall_seen", { source: "post_onboarding" });
13  }, [track]);
14
15  const buy = async () => {
16    const offerings = await Purchases.getOfferings();
17    const pkg = offerings.current?.availablePackages[0];
18    if (!pkg) return;
19
20    const { customerInfo } = await Purchases.purchasePackage(pkg);
21    if (typeof customerInfo.entitlements.active.pro !== "undefined") {
22      await track("purchase_completed", { product: pkg.identifier });
23    }
24  };
25
26  return <PaywallView onBuy={buy} />;
27}

What breaks

The failure modes when RevenueCat and Wire AI share an app. None of them throws, which is why they survive code review.

track() returns false when no app-open has been registered

wire.track reads the current session id and bails without posting when there is not one. No session, no POST, no revalidation bump, and no error either: it resolves false and returns. Mount useLifecycleEvents once at the app root before anything calls track, or your paywall events silently do nothing and the funnel looks like nobody ever reached the paywall.

CustomerInfo resolves async, so a mount-time branch reads everyone as free

This is the one that costs money. If the onboarding mounts before RevenueCat answers, entitlements.active is empty, the AI treats a paying subscriber as a new free user, and the flow asks them to choose a plan they already bought. Gate the mount, do not default the branch.

addCustomerInfoUpdateListener fires on restores and renewals too

The listener is not a purchase callback. It fires whenever CustomerInfo changes, which includes a restore on a reinstall and a silent renewal. If you call track from inside it, you double-count. Track on the transition into an entitlement, not on every callback.

userContext takes scalars only, and never PII

The prop is typed Record<string, string | number | boolean>, the server caps key count and size, and deep nesting is dropped. Do not pass the RevenueCat app user id straight through if yours is an email. Pass a hash, or pass the entitlement name and nothing else.

Questions people actually ask

Does Wire AI replace RevenueCat?

No. RevenueCat is the entitlement system of record and the store plumbing. Wire AI runs the experience up to the paywall and reads which version of it converts. They sit on opposite sides of the same moment.

Where should the paywall go, inside the onboarding or after it?

That is the thing worth running an experiment on rather than guessing, which is why the paywall moment is worth reporting as an event. Wire runs versions of the surrounding steps and reads which ordering keeps people. RevenueCat still charges the card either way.

Do I need a second API key?

No. useWireActivation takes the same serverUrl and apiKey as the onboarding config. One key covers the flow, the events, and the experiment assignment.

Sources

Wire it into your app

Analytics is free to 299k events a month, forever, on any app. 15 founder seats get the whole engine free, 4 filled.

Written by Malik Chohra. 9 years React Native. Shipped an app at 9M monthly users, and took a consumer app from a 4.3 to a 4.9 App Store rating.

Next: Superwall · PostHog