Paywalls

How to Track Paywall Views and Engagement in Mobile Apps

What counts as a paywall view, how to time it, why the entry point matters most, and why client purchase events disagree with the store.

On this page
  1. What counts as a paywall view
  2. Presented isn’t the same as seen
  3. Presentation failures are their own outcome
  4. People who already pay
  5. A shape for the events
  6. Measuring how long the paywall stays on screen
  7. The app going to the background
  8. Very short views are dismissals, not decisions
  9. Where the paywall was opened from
  10. Purchases, restores and free trials
  11. Keep the outcomes separate
  12. Why client-side purchase events disagree with the store
  13. Reading the numbers
  14. A worked example
  15. Comparing paywall versions
  16. Doing this with Riveora
  17. Your own paywall
  18. RevenueCat paywalls
  19. Verified revenue
  20. What the dashboard shows

Most subscription apps track two things about their paywall: that it was shown, and that someone bought. The ratio looks like a conversion rate, so it goes on a chart. Then it moves, and nobody can say why, because the events underneath were never precise enough to explain it.

This article covers what to record instead: what should count as a view, how to time it without counting minutes the app spent in the background, why the place the paywall was opened from is the most useful thing you can record, and why the purchases your app reports will never quite match what the stores say. The ideas apply to any analytics tool. The last section shows how Riveora handles each of them in a React Native or Expo app.

What counts as a paywall view

A paywall view should mean one thing: a person had your offer in front of them. That sounds obvious, but the easiest place to fire the event is usually somewhere else, at the moment your code decides to show the paywall. Those two moments drift apart in ways that quietly inflate your view count.

Presented isn’t the same as seen

If you record the view when you call navigation.navigate('Paywall') or set visible to true, you count paywalls that were still loading prices when the person backed out, and modals that never finished animating in. Three seconds of spinner on a slow connection get counted as three seconds of “looking at your offer”.

Record the view when the paywall is actually on screen with prices filled in. For a React Native Modal, that’s its onShow callback rather than the render where visible flipped. For a screen that loads products first, it’s the moment the products arrive, not the moment the screen mounts.

Presentation failures are their own outcome

Sometimes the paywall can’t be shown at all: the store returned no products, the network was down, or the paywall SDK threw. Record these as a separate outcome, not as dismissals. If paywalls fail on one Android version, that’s a bug costing you sales, and mixed in with dismissals it looks like “people aren’t interested”.

People who already pay

A subscriber who opens “Upgrade” in settings to check their plan is not a prospect. Check the entitlement first and don’t show them the paywall, or tag the view so you can filter it out. Otherwise your most loyal users drag your conversion rate down.

A shape for the events

Two events per view work better than one: one when the paywall opens and one when it closes, sharing an id. The close event carries the duration and the outcome. With a single “viewed” event you can count views, but you can’t tell a two-second dismissal from a two-minute read.

events.jsonjson
{ "name": "paywall_opened", "properties": {
    "paywall_view_id": "pv_8f2c", "paywall_id": "pro_yearly",
    "source": "feature_gate", "trigger": "export_pdf" } }

{ "name": "paywall_closed", "properties": {
    "paywall_view_id": "pv_8f2c", "paywall_id": "pro_yearly",
    "duration_ms": 14250, "reason": "dismissed" } }

Keep the list of outcomes short and fixed: dismissed, purchased, restored, failed to show, and left the app. For the rest of your tracking plan, see Essential Analytics Events Every Mobile App Should Track.

Measuring how long the paywall stays on screen

Time on the paywall is the closest thing you have to “did they read it”. Measure it on the device, as the time between the open and close events, rather than from server receive times, which depend on when the events were batched and sent.

The app going to the background

If someone opens your paywall, switches to their messages and comes back twenty minutes later, a naive timer reports a twenty-minute view. Listen to AppState and end the view when the app goes to the background. If the app is killed while it’s away, the close event never fires at all, so expect some views with no outcome and don’t treat them as dismissals.

There’s a trap here. On iOS, the App Store purchase sheet covers your app without sending it to the background (AppState reports inactive). On Android, the Google Play purchase sheet is a separate activity, so your app goes to background for a few seconds in the middle of a purchase. If you close the paywall view on every background event, you end Android views at exactly the moment people are paying, and the purchase lands on a view that has already been recorded as “left the app”. Ignore background changes while a purchase is in progress.

Very short views are dismissals, not decisions

A paywall closed in under about two seconds was not read: nobody takes in a headline, two plans and a price that fast. A large share of very short views says the paywall shows at the wrong moment, usually in the way of something the person was trying to do, and new copy or prices won’t fix that. Views of several seconds that still end in a dismissal mean people read the offer and said no; that’s when the offer itself is worth working on.

Use the median, not the average. A handful of people leaving the paywall up while they cook dinner can double the average without changing anything about how most people use it.

Where the paywall was opened from

Most apps show the same paywall from several places, and each one reaches a different person in a different mood:

  • The end of onboarding. The largest audience, and the least informed: they haven’t used the app yet. Many dismiss on reflex (see How to Track Onboarding Drop-Off in Mobile Apps).
  • A feature gate. Someone tapped “Export” or “Add a fourth habit” and hit the paywall. They want something specific, right now. This often converts best per view.
  • Settings or a “Go Pro” button. Few views, but self-selected people who went looking for it.
  • A promotion. A push notification or a seasonal banner. Judge it on its own.

That’s why the source is the most useful property on a paywall event. An overall conversion rate is a blend of these groups, and it moves whenever the mix moves: ship an onboarding that sends more people to the paywall and the overall rate drops, even if every source converts exactly as before. By source, you can see nothing got worse.

Record two things: the source (where the person was) and the trigger (why it opened, such as export_pdf or habit_limit). Use a small, fixed set of names and pass them explicitly. Values made up at each call site ("settings" in one place, "Settings screen" in another) split one group into two in every report.

Purchases, restores and free trials

The paywall closing with a purchase is the event everyone cares about, and it’s the one your app is least qualified to report on its own.

Keep the outcomes separate

  • Purchased: money changed hands, or will at the end of a paid period that has started.
  • Restored: an existing customer got back what they already own, for example on a new phone. It belongs on the paywall view, but it isn’t a new sale.
  • Free trial started: valuable, but nobody has paid yet, and a large share of trials are cancelled before the first charge. Counting trial starts as sales flatters the paywall that pushes trials hardest.
  • Cancelled at the store sheet: they tapped a plan, saw the system purchase sheet and backed out. That’s a different problem from closing the paywall itself, often a price surprise.

Why client-side purchase events disagree with the store

A purchase event fired from your app can over-count:

  • Sandbox and TestFlight purchases from you and your testers look exactly like real ones to the app.
  • Retry logic or a component that re-renders can fire the event twice for one transaction.
  • Restores and trial starts get reported as purchases because the purchase call returned successfully.
  • Refunds happen days later, outside the app, and the client never hears about them.

And it can under-count:

  • The app crashes or is killed between the store confirming and your event being sent.
  • Some purchases complete later than the paywall: a parent approving a child’s request, or a slow payment method. By then the paywall is long closed.
  • Renewals never pass through your paywall at all.

The store is the source of truth for money. If you use RevenueCat, its webhooks send you purchases, renewals, cancellations and refunds as the stores confirm them, with sandbox transactions marked. Keep client events for what only the client knows: that the paywall was shown, from where, for how long, and how it was closed. Join the two by logging in to RevenueCat with the same user id you send to your analytics.

Reading the numbers

With views, durations, sources and outcomes in place, a handful of numbers tell you most of what you need:

  • Purchases per view, by source. The core number. Count per view and per person separately: a person who sees the paywall six times before buying is one sale and six views.
  • Dismiss rate. The share of views closed without buying. Leave out failures and views that ended with the app going away, or both will read as lack of interest.
  • Median time on screen, and the share of views under two seconds.

A worked example

Say a habit tracker shows one paywall from three places and records these numbers over 30 days (made up for this example):

SourceViewsPurchasesPurchases per viewMedian time on screen
onboarding_end2,000402.0%1.6 s
feature_gate800364.5%9 s
settings12097.5%14 s
All sources2,920852.9%—

Overall, 2.9% of views end in a purchase. Broken down, the story changes. The onboarding paywall gets the most views and converts worst, and its median view is under two seconds: half the people who see it close it before reading. That points at timing, not price. Moving it later, or showing it only after the first habit has been logged, is the experiment to run. The feature gate converts more than twice as well, with people reading for nine seconds. Settings converts best of all, but on 120 views.

Be careful with that last row. Nine purchases out of 120 is 7.5%, but with numbers this small the uncertainty is large: a rough 95% range runs from about 4% to 14%, and two more or two fewer purchases would move the rate by more than a point and a half. Don’t rank sources, or call a winner between two paywall versions, on a few dozen purchases.

Comparing paywall versions

Give each version its own id, so old and new are never counted in the same row. The fairest comparison runs both at once, with people split randomly between them. If you switch everyone over instead, compare periods of the same length, don’t change prices or ship a release at the same moment, and compare by source.

Doing this with Riveora

Riveora’s React Native SDK records a paywall as a paywall_opened and paywall_closed pair sharing a visit id, with the duration measured on the device. You mark the open and the close; the Paywalls guide has the full setup.

Your own paywall

For a paywall shown as a modal, TrackedPaywall records the open when visible becomes true, and the close when it becomes false. Its optional onCloseReason lets you say how it ended:

UpgradeModal.tsxtsx
import { useEffect, useRef } from 'react';
import { TrackedPaywall } from '@riveora/react-native';

export function UpgradeModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  const reason = useRef<'dismissed' | 'purchased' | 'restored'>('dismissed');
  // Each opening starts as a dismissal until a purchase says otherwise.
  useEffect(() => {
    if (isOpen) reason.current = 'dismissed';
  }, [isOpen]);

  return (
    <TrackedPaywall
      paywallId="pro_yearly"
      source="feature_gate"
      trigger="export_pdf"
      visible={isOpen}
      onCloseReason={() => reason.current}
    >
      <PaywallSheet
        onPurchased={() => {
          reason.current = 'purchased';
          onClose();
        }}
        onClose={onClose}
      />
    </TrackedPaywall>
  );
}

Or call it yourself:

ts
import { Riveora } from '@riveora/react-native';

const visitId = Riveora.paywall.open({ paywallId: 'pro_yearly', source: 'settings' });
// ...later
Riveora.paywall.close(visitId, { reason: 'purchased' });

The close reasons are 'dismissed' (the default), 'purchased' (counts as a sale; not for an uncharged free trial), 'restored' (not a new sale), 'presentation_failed' and 'background'. The SDK doesn’t end a paywall visit by itself when the app goes to the background; that’s up to you. The usePaywallTracking hook gives you open() and close(reason), which makes the AppState handling from earlier straightforward:

PaywallModal.tsxtsx
import { useEffect, useRef } from 'react';
import { AppState, Modal } from 'react-native';
import { usePaywallTracking } from '@riveora/react-native';

type Props = { visible: boolean; source: string; onClose: () => void };

export function PaywallModal({ visible, source, onClose }: Props) {
  const { open, close } = usePaywallTracking({ paywallId: 'pro_yearly', source });
  const purchasing = useRef(false);

  useEffect(() => {
    if (!visible) return;
    const sub = AppState.addEventListener('change', (state) => {
      // The Google Play sheet sends the app to the background mid-purchase: ignore that.
      if (purchasing.current) return;
      if (state === 'background') close('background');
      if (state === 'active') open(); // back with the paywall still up: a new view
    });
    return () => sub.remove();
  }, [visible, open, close]);

  const buy = async () => {
    purchasing.current = true;
    try {
      const bought = await purchasePro(); // your purchase call
      if (bought) {
        close('purchased');
        onClose();
      }
    } finally {
      purchasing.current = false;
    }
  };

  const dismiss = () => {
    close('dismissed');
    onClose();
  };

  return (
    <Modal visible={visible} animationType="slide" onShow={() => open()} onRequestClose={dismiss}>
      {/* plans, a buy button calling buy(), a close button calling dismiss() */}
    </Modal>
  );
}

If the component unmounts while the paywall is still open, the hook closes it for you, with the reason 'unmounted'. See the SDK reference for the details.

RevenueCat paywalls

With RevenueCat paywalls, pass the offering instead of a name. The paywall is then named after the offering, so a paywall you change or swap in the RevenueCat dashboard shows up in Riveora under its new name without a new app release:

ts
import Purchases from 'react-native-purchases';
import RevenueCatUI from 'react-native-purchases-ui';
import { trackRevenueCatPaywall } from '@riveora/react-native';

const offering = (await Purchases.getOfferings()).current;
const result = await trackRevenueCatPaywall(
  () => RevenueCatUI.presentPaywall({ offering }),
  { offering, source: 'settings' },
);

It records how the paywall ended and returns RevenueCat’s result unchanged. With presentPaywallIfNeeded(), nothing is recorded when the person already has access. For the <RevenueCatUI.Paywall> component, use the hook and spread what it returns:

tsx
const tracking = useRevenueCatPaywallTracking({ offering, onDismiss: () => setOpen(false) });
<RevenueCatUI.Paywall options={{ offering }} {...tracking} />;

RevenueCat’s paywall result doesn’t say whether a purchase was a trial or a charged plan. That’s one more reason to take revenue from the store side.

Verified revenue

Connect RevenueCat on the dashboard’s Revenue page to use verified store revenue, and give both SDKs the same user id so each purchase joins the right journey (more on ids in Identifying users):

ts
await Purchases.logIn(user.id); // RevenueCat
await Riveora.identify(user.id); // Riveora

Riveora then stores purchases, renewals, cancellations and refunds. Sandbox purchases are kept out of revenue, and free trials count once they’re charged. The Revenue and drop-offs guide explains exactly what counts as a sale.

What the dashboard shows

The Paywalls page lists each paywall’s last 30 days: views, the number of different people, the typical (median) time on screen, the share of views dismissed, and a verdict from that time: “Mostly skipped” under two seconds, “Glanced at” up to six, and “Read” beyond that. The dismiss rate counts only views closed as dismissed, so purchases, failures and background closes don’t inflate it. With RevenueCat connected, each offering also gets its purchases (with trial starts shown separately) and revenue, straight from RevenueCat, including offerings your app doesn’t track yet.

Below that is everyone who saw a paywall, with how many times, their longest look and whether they bought. A view that never got a close shows as “Left the app”. The When to show your paywall section compares purchases per view by which visit it was, time since install, time into the visit, the screen it opened from, and the trigger. If you don’t pass a source, Riveora uses the last other screen viewed before the paywall in the same visit. When more than half of a paywall’s views in 30 days last under two seconds, with at least 50 views, Riveora raises an insight about it.

The Revenue page shows verified revenue after refunds, paying people, new subscriptions and renewals, trials started, revenue per day and per product, and recent transactions linked to the journey that led to them. For the rest of the picture (the screens and taps before the paywall), see How to Track User Behavior in React Native Apps.

Send a source and a trigger on every open, from a short fixed list. They’re the columns you’ll filter by most, and they can’t be added to views that have already happened.

All articles
Paywall Analytics for Mobile Apps · Riveora