DEV Community

Cover image for 4 Ways RevenueCat Silently Denies Paying Users Their Entitlements
Diven Rastdus
Diven Rastdus

Posted on • Originally published at astraedus.dev

4 Ways RevenueCat Silently Denies Paying Users Their Entitlements

Your store credentials are valid. RevenueCat accepted the service account. The SDK initialized without throwing. And your paying users are still seeing the free tier.

This is the second layer of the RevenueCat wiring problem. The one that doesn't produce an error. The first layer (invalid credentials, service account propagation delays) has been written about. This one hasn't. These are four bugs I found building Origo, an AI astrology app on Expo + RevenueCat + Supabase. All four were silent. All four passed tests. All four denied real users real value.


1. Purchases.logIn() was never called

This was the most expensive one. Three days of confused billing debugging before I found it.

Every purchase made before you call Purchases.logIn(userId) registers under an anonymous RC identity: $RCAnonymousID:some-uuid. When your RevenueCat webhook fires to update your database, it can't map an anonymous RC id to a real user row. It either skips the event or writes an orphaned record. Your server's entitlement check returns false for that user.

The purchase exists in RevenueCat's dashboard. The user paid. Nothing shows up on your backend.

The fix requires understanding what RC does with identity. You want RC's app_user_id to be your auth system's stable user id, set BEFORE any purchase happens:

const initialize = useCallback(async (userId?: string) => {
  if (!initialized || configuredApiKey !== apiKey) {
    Purchases.configure({ apiKey, appUserID: userId });
    initialized = true;
  } else if (userId && currentAppUserID !== userId) {
    // User signed in after RC was already configured; switch identity
    const loginResult = await Purchases.logIn(userId);
    currentAppUserID = userId;
    info = loginResult.customerInfo;
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

If your auth flow allows anonymous purchases (user buys before creating an account), call logIn() with the stable user id the moment they sign up. RC handles the client-side transfer from anonymous to identified, but your webhook still has to catch the TRANSFER event and re-key the database row. More on that in #4.

In my case, Supabase's anonymous auth preserves the user id across the anon-to-email upgrade (auth.updateUser() keeps the same uuid). So I pass the Supabase uuid to RC from the very first session, before they've ever entered an email. When they eventually link an account, the RC identity is already the final, stable uuid.


2. No customerInfo listener

Here's what happens when you skip a customerInfoUpdateListener:

A user purchases through your native paywall (via Purchases.presentPaywall()). The purchase succeeds. Your paywall dismisses. The user is back on the main screen, still seeing the free tier, because the component that owns isPro state hasn't been told anything changed.

The listener is how RC pushes out-of-band entitlement changes: purchases through the native paywall, server-side grants, trial conversions, grace-period resolutions. Without it, you're only checking entitlements at cold start.

useEffect(() => {
  let active = true;

  const onCustomerInfo = (info: CustomerInfo) => {
    if (active) checkEntitlements(info);
  };

  (async () => {
    await initialize();
    try {
      Purchases.addCustomerInfoUpdateListener(onCustomerInfo);
    } catch {
      // SDK not configured in dev; nothing to listen to
    }
  })();

  return () => {
    active = false;
    try {
      Purchases.removeCustomerInfoUpdateListener(onCustomerInfo);
    } catch {}
  };
}, [initialize, checkEntitlements]);
Enter fullscreen mode Exit fullscreen mode

react-native-purchases v10 gotcha: addCustomerInfoUpdateListener returns void. You can't call .remove() on the return value. Cleanup is removeCustomerInfoUpdateListener(sameRef), and sameRef has to be the exact same function reference. Define the listener inside the effect, close over it, pass the same reference to both add and remove.


3. The RC session leaked between users on the same device

This bug is invisible to unit tests. You'd need an integration test that calls configure(), then logOut(), then signs in as a different user and checks their entitlements. Most test suites don't go there.

RC's SDK uses module-level state. Once Purchases.configure() has run with User A's identity, that configuration persists until you explicitly clear it. If User A signs out and User B signs in, and your initialization check says "already initialized, skip". User B inherits User A's RC session. If User A was a paying subscriber, User B gets isPro: true.

Reset your initialization bookkeeping on sign-out, right after calling Purchases.logOut():

// Call this on every sign-out, after Purchases.logOut()
export function resetRCSession() {
  initialized = false;
  configuredApiKey = null;
  currentAppUserID = undefined;
}
Enter fullscreen mode Exit fullscreen mode

Purchases.logOut() returns RC to an anonymous identity. resetRCSession() clears your bookkeeping so the next initialize() doesn't short-circuit on the previous user's state.


4. The webhook handles purchases but ignores refunds and cancellations

The first three bugs are client-side. This one is server-side, and it's the one that keeps denying access after everything else is right.

Your subscription table probably gets written on INITIAL_PURCHASE and RENEWAL. If you're not handling CANCELLATION, REFUND, and TRANSFER, your database will say is_active: true for users whose subscriptions have ended. They lose access only when expires_at passes (if you even store that field).

The full event set you need, at minimum:

  • INITIAL_PURCHASE: create the subscription row
  • RENEWAL: extend expires_at
  • CANCELLATION: mark inactive (keep the row; they may have time remaining)
  • REFUND: mark inactive immediately
  • TRANSFER: re-key the row when RC reassigns app_user_id

TRANSFER is the one that closes the loop from bug #1. When an anonymous user creates an account and RC transfers their purchase history to a named identity, a TRANSFER event fires with transferred_from (the old anonymous id) and app_user_id (the new identified one). Ignore it, and the purchase stays associated with an id that doesn't map to any user row.

switch (event.type) {
  case 'INITIAL_PURCHASE':
  case 'RENEWAL': {
    const expiresAt = event.expiration_at_ms
      ? new Date(event.expiration_at_ms).toISOString()
      : null;
    await upsertSubscription(event.app_user_id, { is_active: true, expires_at: expiresAt });
    break;
  }
  case 'CANCELLATION':
    await updateSubscription(event.app_user_id, { is_active: false });
    break;
  case 'REFUND':
    await updateSubscription(event.app_user_id, { is_active: false, refunded: true });
    break;
  case 'TRANSFER':
    // transferred_from is an array; the relevant old id is [0]
    await transferSubscription(event.transferred_from[0], event.app_user_id);
    break;
}
Enter fullscreen mode Exit fullscreen mode

The checklist

Before shipping a RevenueCat-gated feature:

  1. Is Purchases.logIn(userId) called with the stable auth user id before any purchase can happen?
  2. Is there an active customerInfoUpdateListener on every screen that gates on isPro?
  3. Does sign-out call Purchases.logOut() AND reset your initialization bookkeeping?
  4. Does the webhook handler cover CANCELLATION, REFUND, and TRANSFER, not just INITIAL_PURCHASE?

Credentials being valid is table stakes. The silent failures come after.


Building with RevenueCat on Expo/React Native and hit a different version of these? Comments are open. Also at raeduslabs.com.

Top comments (0)