Skip to main content
友田 陽大
Payments & billing
RevenueCat
アプリ内課金
決済
サブスクリプション
React Native
TypeScript
冪等性

RevenueCat implementation guide (2026 edition, official-compliant): taking in-app subscriptions to production quality with Entitlements, Offerings, and webhooks

A RevenueCat-official-compliant implementation guide. Entitlement/Offering design, SDK setup, purchases and restores, the App User ID pitfalls, granting access server-side from HMAC-signed webhooks, REST API v2, Trusted Entitlements, sandbox testing, and migration — in real Swift/Kotlin/TypeScript code.

Published
Reading time
48 min read
Author
友田 陽大
Share
Contents

Mobile subscriptions are not hard because the billing APIs are hard. They are hard because one single fact — "this person is on the Pro plan" — lives in four places (Apple, Google, your server, your app), in four different formats, with four different delays. A receipt on iOS, a Purchase Token on Android, a row in your database, a flag in memory. One of them lags, one of them is missing, and one of them lies.

RevenueCat is the layer that normalizes all four into a single vocabulary: the Entitlement. This article stays faithful to RevenueCat's official documentation while showing, in real code, where and why and how to use each piece. The parts the docs get right but scatter across many pages — where the source of truth for access lives, how to receive webhooks safely and idempotently, whether to revoke on CANCELLATION or EXPIRATION — are stitched here into one design.

My background, and an honest boundary for this article I use RevenueCat as the billing layer in two of my own Expo apps (Palmia and memofu). Both are still pre-launch, so I cannot claim to have operated store billing at scale in production. What I have shipped and run in production is subscription billing on the web: a multi-channel subscription learning platform (Stripe webhooks made idempotent with a unique event-id constraint, ordering guaranteed via event.created, PII redacted; pricing resolution as pure functions; 433 tests) and the reliability layer of a serverless payments platform with zero double charges in production. Every RevenueCat detail below was checked against the official documentation directly, and no unverified numbers — MRR, churn, conversion lift — appear anywhere in this article. Specifications move. Always confirm against the official docs (this article was written against them on 2026-08-06).


1. What RevenueCat actually takes off your hands

Filing it under "billing SDK" leads to bad decisions. What RevenueCat takes on is not the purchase itself, but the job of normalizing what a purchase means across platforms. Apple and Google still process every payment.

The jobBuild it yourselfRevenueCat
Show the store's purchase UICall StoreKit / Play Billing directlyThe SDK wraps it (the store still runs the purchase)
Server-side validation of receipts / purchase tokensImplement against both Apple's and Google's APIsHandled
Receiving server notifications from the storesImplement App Store Server Notifications and Google RTDN separatelyHandled (normalized into one webhook)
Unifying subscription state across iOS and AndroidMatch it up per user yourselfUnified as an Entitlement
Restores, account transfers, aliasesBuild it yourself (where most accidents happen)Handled (behavior is configurable)
Refunds, grace periods, family sharingAbsorb the differences between storesHandled
Changing prices and paywalls remotelyYou need your own config-delivery systemStandard, via Offerings
A/B tests and analyticsBuild it, or buy another SaaSBuilt in
The business rules for accessYoursYours (this part cannot be delegated)

That last row matters. RevenueCat normalizes who bought what; what a Pro buyer is allowed to do is your domain. Implementations that blur this boundary are the ones that become impossible to change later.

When you should not use it — honestly

  • One non-consumable, one product. The SDK weighs more than the Entitlement model buys you. Call StoreKit 2 directly.
  • iOS only, no server. If on-device StoreKit 2 validation is sufficient, there is little reason to add an external dependency.
  • You already have a billing platform and the web is your main channel. Moving everything for the sake of the mobile slice is backwards. Keep Stripe on the web and RevenueCat on mobile, and unify the Entitlement in your own database (section 7).

Conversely, the moment you sell the same "Pro" across iOS × Android × web, RevenueCat's return jumps.


2. The core mental model: Product → Entitlement / Offering → Package

Leave this fuzzy and you will eventually lose track of where the truth lives. The docs state the meaning of a purchase directly: "User purchases a Product → Unlocks an Entitlement → You check the entitlement to grant access."

Project  (the top-level dashboard entity; the project_id used by the v2 API lives here)
 ├─ App        (iOS / Android / Web — one per platform)
 ├─ Product    … the real store SKU (com.example.pro.monthly)
 │    │          the join key between RevenueCat and Apple/Google
 │    └─(many-to-many)─ Entitlement … "pro" = the access right. ★ the only thing your app reads ★
 └─ Offering   … "what the paywall shows right now" = remote configuration
      └─ Package … one box holding the iOS/Android/Web versions of the same commercial unit
                   ($rc_monthly / $rc_annual …)

Entitlement: the only vocabulary your app is allowed to know

The official definition: "RevenueCat Entitlements represent a level of access, features, or content that a user is 'entitled' to."

The docs are explicit about granularity too — "Most apps only have one entitlement, unlocking all premium features." Add more only when you genuinely have separate tiers (the docs' example: a navigation app with a pro entitlement plus one per purchasable map region). When in doubt, start with one.

The design rule: no product ID (com.example.pro.monthly) should ever appear in your app's code. The only thing it may read is entitlements.active["pro"]. Do that, and price changes, new annual plans, and region-specific SKUs all ship without an app release. Branch on a product ID instead, and every price change now needs a store review.

Three accidents the docs name explicitly

  1. Forgetting to attach a product to its entitlement

    "Failing to add your products to an entitlement, could lead to your users making purchases that don't unlock access to the promised content."

    You add an annual plan, get it through review — and forget to attach it to pro. Users are charged and nothing unlocks. This is the worst possible failure. Put "attach to the entitlement" on your product-launch checklist.

  2. Attaching a consumable to an entitlement A subscription grants its entitlement for the subscription period, but a non-consumable or consumable attached to an entitlement unlocks it forever. Never attach something like "10 lives" in a game to an entitlement; track balances elsewhere.

  3. Detaching a product while live Attaching and detaching products applies retroactively to every past customer. Detaching in production means "everyone who had access yesterday loses it." Treat it accordingly.

Offering: the box that changes prices without a store review

An Offering is the set of products you show a given user right now. Using Offerings is optional in itself, but Paywalls, Experiments, and Targeting all require them.

  • A Package bundles the per-store versions of one commercial unit. Fetch $rc_monthly once and you get the iOS SKU on iOS and the Android SKU on Android. This is why your app code never has to branch on Platform.OS.
  • An Offering's identifier cannot be changed after creation. Pick names that will not shift in meaning — default, holiday_2026.
  • Do not hardcode past current. Experiments (section 12) assume you read the current Offering; hardcoding an identifier silently disables them.

3. Setup: decide the API-key boundary first

RevenueCat's keys are cleanly split between public (client) and secret (server). Confusing them means a key extracted from your app binary can manipulate other people's subscription data.

KeyWhere it livesWhat it can do
Public SDK key (per app, per platform)In the app binaryRead and purchase for the current user
Secret key (sk_ prefix, per project)Server environment variables onlyRead every customer; grant/revoke entitlements; refund
OAuth access token (atk_ prefix)Server (per developer, across projects)Same as above (rate limits are shared per developer)
Test Store keyLocal development onlySimulated purchases

One of the strongest warnings in the docs sits right here — "You must NEVER submit an app to the App Store or Google Play that is configured with a Test Store API key."

Guarantee at the type level that secrets are only ever read on the server.

// lib/revenuecat/env.server.ts — server-only. Fails the moment a client imports it.
import "server-only";
import { z } from "zod";

/**
 * Keep the secrets boundary in one file (SRP).
 * Validate once at startup so a missing variable fails at init, not mid-request.
 */
const ServerEnvSchema = z.object({
  /** REST API v2 secret key (`sk_` prefix). Never hand this to a client. */
  REVENUECAT_SECRET_KEY: z.string().startsWith("sk_"),
  /** The shared secret you set as the webhook's Authorization header. */
  REVENUECAT_WEBHOOK_SECRET: z.string().min(16),
  /** Signing secret for HMAC verification (shown only once, at creation or rotation). */
  REVENUECAT_WEBHOOK_SIGNING_SECRET: z.string().min(16),
  /** Path element for the v2 API. Shown under Project settings in the dashboard. */
  REVENUECAT_PROJECT_ID: z.string().min(1),
});

export const serverEnv = ServerEnvSchema.parse(process.env);

Initializing the SDK (as the official samples show it)

// Swift (iOS) — once, right at app launch
import RevenueCat

Purchases.logLevel = .debug
Purchases.configure(withAPIKey: <public_apple_api_key>, appUserID: <app_user_id>)
// Kotlin (Android) — once, in Application.onCreate
class MainApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        Purchases.logLevel = LogLevel.DEBUG
        Purchases.configure(PurchasesConfiguration.Builder(this, <public_google_api_key>).build())
    }
}
// React Native / Expo
Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG);
Purchases.configure({ apiKey: <public_apple_api_key> });  // iOS
Purchases.configure({ apiKey: <public_google_api_key> }); // Android

Setting logLevel to .debug prints the whole flow of purchases, receipts, and cache. It is the first place to look when billing misbehaves (turn it down in release builds).

The trap every Expo developer hits first: RevenueCat does not work in Expo Go. Expo Go substitutes mock APIs (Preview API Mode), so nothing errors and no purchase happens — the worst way to fail. You need an EAS development build to exercise the real SDK. After installing, a full native build is mandatory; hot-reloading throws Invariant Violation.

On Android: do not call getOfferings() from Application.onCreate. It can fire extra network requests on events like push notifications; the SDK warms that cache itself.

Wrap the SDK in one type-safe file (my recommendation)

Call the SDK directly from all over your app and the string "pro" ends up in twenty places. Make the Entitlement ID a single source of truth and confine the SDK dependency to one file.

// lib/billing/entitlements.ts — the only place in the app that knows an Entitlement ID (DRY)
export const ENTITLEMENTS = {
  /** All premium features. Start with exactly one, as the docs recommend. */
  pro: "pro",
} as const;

export type EntitlementId = (typeof ENTITLEMENTS)[keyof typeof ENTITLEMENTS];
// lib/billing/client.ts — the RevenueCat SDK dependency lives here and nowhere else (ETC / SRP)
import Purchases, {
  PURCHASES_ERROR_CODE,
  type CustomerInfo,
  type PurchasesPackage,
} from "react-native-purchases";
import { type EntitlementId } from "./entitlements";

/** Collapse the purchase result into a vocabulary the UI can branch on. No control flow via exceptions. */
export type PurchaseOutcome =
  | { readonly status: "granted"; readonly customerInfo: CustomerInfo }
  | { readonly status: "pending" } // awaiting approval (family-sharing ask-to-buy, pending transactions)
  | { readonly status: "cancelled" }
  | { readonly status: "failed"; readonly code: string; readonly message: string };

/**
 * A check against the device's cached info. For UI gating only — this is not authoritative.
 * Note: CustomerInfo is empty for a user who has never purchased. Entitlements defined in
 * the dashboard do not appear until a transaction has been synced.
 */
export function hasEntitlement(info: CustomerInfo, id: EntitlementId): boolean {
  return info.entitlements.active[id] !== undefined;
}

export async function purchase(
  pkg: PurchasesPackage,
  required: EntitlementId,
): Promise<PurchaseOutcome> {
  try {
    const { customerInfo } = await Purchases.purchasePackage({ aPackage: pkg });
    return hasEntitlement(customerInfo, required)
      ? { status: "granted", customerInfo }
      : { status: "pending" };
  } catch (error: unknown) {
    // Catch SDK errors as unknown and don't trust the type until the code is extracted (narrow at the boundary)
    const code =
      typeof error === "object" && error !== null && "code" in error
        ? String((error as { code: unknown }).code)
        : "unknown";
    if (code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
      return { status: "cancelled" };
    }
    return {
      status: "failed",
      code,
      message: error instanceof Error ? error.message : "purchase failed",
    };
  }
}

Why pending exists: there are real cases where the purchase API succeeds but the entitlement does not become active — ask-to-buy approval under family sharing, pending transactions on Google Play. Write "success means unlocked" and you unlock premium features before approval. "They purchased" and "the entitlement is active" are two different facts.

API names are not consistent across the SDKs. This is the single biggest source of copy-paste bugs. Even reading an entitlement differs: Swift/Kotlin index entitlements[id]?.isActive, Flutter uses entitlements.all[id], and the JS/TS SDKs check for presence in entitlements.active[id]. Do not lift a sample from another platform verbatim.


4. The purchase flow: fetch an Offering, sell a Package, check the Entitlement

The official purchase samples all have the same shape on all three platforms: purchase, then check the entitlement right there.

// Swift
Purchases.shared.purchase(package: package) { (transaction, customerInfo, error, userCancelled) in
  if customerInfo.entitlements["your_entitlement_id"]?.isActive == true {
    // Unlock that great "pro" content
  }
}
// Kotlin
Purchases.sharedInstance.purchaseWith(
  PurchaseParams.Builder(this, aPackage).build(),
  onError = { error, userCancelled -> /* No purchase */ },
  onSuccess = { storeTransaction, customerInfo ->
    if (customerInfo.entitlements["my_entitlement_identifier"]?.isActive == true) {
      // Unlock that great "pro" content
    }
  }
)
// React Native / TypeScript
try {
  const purchaseResult = await Purchases.purchasePackage({ aPackage: packageToBuy });
  if (typeof purchaseResult.customerInfo.entitlements.active['my_entitlement_identifier'] !== "undefined") {
    // Unlock that great "pro" content
  }
} catch (error: any) {
  if (error.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
    // Purchase cancelled
  } else {
    // Error making purchase
  }
}

Finishing the transaction (finish on iOS, acknowledge / consume on Android) is done by RevenueCat automatically. Doing it yourself invites double-finishing or automatic refunds from unacknowledged transactions, so leave it alone outside of a migration from an existing implementation (section 14).

On the fetch side, use current and keep it simple.

// Fetch the current Offering and feed its Packages to the UI
const offerings = await Purchases.getOfferings();
const current = offerings.current;            // the dashboard's Default Offering, unless another condition applies
const monthly = current?.monthly;             // maps to $rc_monthly (undefined if absent)
const all = current?.availablePackages ?? []; // display order is controlled in the dashboard

Using current means taking the decision of "which plan at which price" out of the app binary and putting it in the dashboard. That is the main payoff of Offerings.


5. App User IDs: the one thing you cannot fix later

The most expensive design mistake in billing is not cryptography or webhooks — it is how you choose the user ID. The docs call the App User ID "a source of truth for the subscription status of the customer across different devices and platforms." Get it wrong and a subscription belongs to the wrong person, or can never be restored.

Anonymous IDs and logIn

The SDK generates an anonymous ID ($RCAnonymousID:...) on initialization. When the user later logs in, that anonymous ID is grouped as an alias of the new ID.

// React Native — once, when your own auth has resolved
const { customerInfo, created } = await Purchases.logIn(userId);
// created === true means RevenueCat created the ID just now
// Swift
Purchases.shared.logIn(<my_app_user_id>) { (customerInfo, created, error) in
    // customerInfo updated for my_app_user_id
}

IDs RevenueCat blocks, and IDs you must not use

RevenueCat rejects these values as App User IDs: 'no_user', 'null', 'none', 'nil', '(null)', 'NaN', the NULL character, the empty string, 'unidentified', 'undefined', 'unknown', 'anonymous', 'guest', '-1', '0', '[]', '{}', '[object Object]', and any string containing /.

What is not rejected but must not be used is PII — email addresses, phone numbers. Three reasons: (1) it propagates into webhook payloads and downstream integrations, (2) if the user changes their email the subscription is orphaned, (3) deletion requests (GDPR/APPI) become far more expensive. Use an immutable, meaningless identifier — a UUID such as Supabase's auth.users.id.

// lib/billing/identity.ts
import Purchases from "react-native-purchases";

/**
 * Pin the RevenueCat App User ID to an identifier that is immutable, non-PII,
 * and authoritative in your own system. Supabase's user.id (a UUID) satisfies all three.
 */
export async function syncIdentity(supabaseUserId: string | null): Promise<void> {
  if (supabaseUserId === null) {
    // Official guidance: if you only ever use custom IDs, don't call logOut —
    // just call logIn with the next account's ID. logOut mints a new anonymous ID
    // and clears the cache, which is how subscriptions drift onto anonymous users.
    return;
  }
  await Purchases.logIn(supabaseUserId);
}

Looking a user up on the server: one customer can hold several IDs. In a webhook payload, app_user_id (most recently seen), original_app_user_id (the first one), and aliases (every past ID) are different things, and the docs explicitly tell you to search both original_app_user_id and aliases. If you logIn your own UUID early, most cases line up — but as long as "purchase anonymously, then sign in" is possible, your lookup should check all three.

A Google Play-specific trap: configuring obfuscatedExternalAccountId as the App User ID is a trap when using the RevenueCat SDK — the SDK puts a hashed App User ID in that field, causing unintended overwrites. Leave the default, "use anonymous App User IDs."

Restore versus syncPurchases

APIWhen to call itCaveat
restorePurchases()Only when the user taps a "Restore" buttonIt can raise an OS sign-in dialog, so never call it automatically
syncPurchases()When you need a programmatic sync, e.g. during migrationThe docs state it carries "a risk of transferring or aliasing an anonymous user"

Consumables and non-renewing purchases do not remain on the store receipt, so they cannot be restored without a custom App User ID scheme. The product decision "should sign-in be mandatory?" is, in fact, also a decision about whether purchases can be restored. On top of that, in the Google Play Billing Library 8 generation (purchases-android 9.0.0+, react-native-purchases 9.0.0+, and so on), consumed one-time purchases can no longer be queried and therefore cannot be restored.

What happens when a purchase is already attached to a different user is decided by the project's Restore Behavior setting. It maps directly onto real situations — a family sharing one store account, someone switching phones into a different account — so decide it as a product policy before you implement (changing it later changes past attribution).


6. Store connections: what actually blocks shipping is not your code

The most common "it doesn't work" in a RevenueCat implementation is not the SDK code — it is store-side credentials. And every one of them fails silently.

Apple

  • The In-App Purchase Key (.p8) matters most. From Purchases v5.x on, without it transactions are not recorded. It was not historically required, so an upgraded project can look fully configured and still record nothing. Fixing the code and forgetting the dashboard is the classic failure during the iOS v5 migration.
  • The In-App Purchase Key and the App Store Connect API Key are different keys (different sections of the same Integrations tab). They do, however, share an Issuer ID, and the Issuer ID only becomes visible once at least one App Store Connect API key exists.
  • Each .p8 can be downloaded exactly once, and a revoked In-App Purchase Key can never be reinstated. Store it somewhere safe at creation time.
  • App Store Server Notifications are not required for RevenueCat to work. They are needed for Refund Control and price-change auto-detection — and they materially affect how fast events reach you (section 8).
  • Apple allows only one notification URL per environment. Point it at RevenueCat and use RevenueCat's forwarding to reach your own server; the docs explicitly discourage the reverse.
  • Adding an In-App Purchase Key to an existing app rewrites historical data — estimated country, currency, and price values are replaced with Apple's real ones. If your charts shift, that is not a bug.

Google Play

  • Validating service-account credentials can take up to 36 hours. Until then purchases keep failing with "Invalid Play Store credentials." Wait out the full 36 hours before wiring up RTDN. Not knowing this costs a full day of misdiagnosing your own implementation.
  • Google Cloud organizations created on or after May 3, 2024 have three org policies on by defaultiam.disableServiceAccountCreation, iam.disableServiceAccountKeyCreation, and Domain Restricted Sharing — that block this flow outright. It needs admin rights, so check before you start.
  • The RTDN procedure is inverted relative to Google's own docs. With RevenueCat you do not create the Pub/Sub topic yourself: RevenueCat generates the topic ID and you paste it into the Play Console.

If you add the web (RevenueCat Billing / Stripe)

You can fold web payments into the same Entitlement. The name has moved around, though — the current one is "RevenueCat Billing (formerly Web Billing)." Practical constraints if you sell in Japan:

  • RevenueCat Billing supports JPY (¥99 minimum price). Payment methods are card, Apple Pay, and Google Pay only.
  • Konbini (convenience-store) payment is not supported, along with asynchronous off-session methods generally (bank debits, bank transfers, cash vouchers). If you were planning on it, drop it from your assumptions now.

7. Where the source of truth for access lives (the heart of this article)

This is the part that is hard to assemble from the docs alone. Start with what the docs say about the client, precisely.

  • The SDK caches CustomerInfo: "The SDK caches the user's subscription information to reduce your app's reliance on the network."
  • The cache refreshes if it is older than 5 minutes — but only when you call getCustomerInfo(), make a purchase, or restore purchases.
  • The decisive sentence: "CustomerInfo updates are not pushed to your app from the RevenueCat backend, updates can only happen from an outbound network request to RevenueCat."
  • When you need subscription status outside the SDK (i.e. on your backend), the docs point you at the REST API.

In other words: the client's CustomerInfo is a fast, usually-correct, possibly-stale cache. Ideal for gating UI, unfit for authorizing server resources.

The three-tier model

[1] Client (CustomerInfo)
      Used for: gating UI (show/hide a button, present a paywall)
      Nature:   5-minute cache, works offline, can be tampered with
      ↓ (don't trust it)
[2] Your database (user_entitlements table)
      Used for: the source of truth for authorizing server features. APIs, RLS, and jobs read only this
      Updated:  from the RevenueCat webhook (sections 8 and 9)
      ↓ (when it drifts, or an event is lost)
[3] RevenueCat REST API v2
      Used for: fetching canonical state, reconciliation, recovery, support (section 10)

"Why do I need [2]? Why not just call [3] every time?" Because putting an external API in the request path turns a RevenueCat outage into an outage of your API, and latency plus rate limits (480 req/min for Customer Information) start to bite. Sync into your own database and authorization becomes one local query, with the external dependency confined to the update path. This is the same thinking I applied in production with Stripe: events are the source of truth, the database is their projection.


8. Receiving webhooks safely

8.1 What the docs guarantee — and what they don't

ItemWhat the docs say
Auth (basic)You can set an Authorization header in the dashboard; it is sent on every request. Verify it on every notification
Auth (stronger)HMAC signature. Off by default — enable "HMAC webhook signing" per integration and X-RevenueCat-Webhook-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex> is added
SuccessHTTP 200 only. "Any other status code will be considered a failure" — including 202 and 204
Timeout60 seconds. Exceeding it disconnects and consumes retry budget
RetriesUp to 5, at 5 / 10 / 20 / 40 / 80 minutes — roughly 2 hours 35 minutes total from the first attempt, then it stops
DuplicatesAt-least-once. Retries reuse the same event.id and the same event_timestamp_ms
OrderingNot documented (so design for out-of-order delivery)
LatencyUsually 5–60 seconds. CANCELLATION can take around 2 hours, and EXPIRATION can lag about an hour if store-side notifications (section 6) are not configured
Forward compatibilityNew fields and new event types can be added without bumping api_version; nothing is removed. Your parser must tolerate unknown types and unknown fields

"5 retries, about 2 hours 35 minutes" is a critical design input. If a bad deploy takes your endpoint down for three hours, the events from that window are gone for good (you can re-dispatch manually from the dashboard). That is why the reconciliation path in section 10 exists.

8.2 The payload (the real official sample)

{
  "event": {
    "type": "INITIAL_PURCHASE",
    "id": "12345678-1234-1234-1234-123456789012",
    "app_id": "1234567890",
    "event_timestamp_ms": 1658726378679,
    "app_user_id": "1234567890",
    "original_app_user_id": "$RCAnonymousID:87c6049c58069238dce29853916d624c",
    "aliases": ["$RCAnonymousID:8069238d6049ce87cc529853916d624c"],
    "product_id": "com.subscription.weekly",
    "entitlement_ids": ["pro"],
    "period_type": "NORMAL",
    "purchased_at_ms": 1658726374000,
    "expiration_at_ms": 1659331174000,
    "store": "APP_STORE",
    "environment": "PRODUCTION",
    "is_family_share": false,
    "country_code": "US",
    "currency": "USD",
    "price": 4.99,
    "tax_percentage": 0.0,
    "commission_percentage": 0.3,
    "subscriber_attributes": {
      "$email": { "updated_at_ms": 1662955084635, "value": "firstlast@gmail.com" }
    }
  },
  "api_version": "1.0"
}

Four things to notice.

  1. subscriber_attributes can contain $email. That means the webhook payload can contain PII. Pipe the raw payload straight into your logs or an error tracker (Sentry, say) and you have just sent an email address to a third party. Redact before you store or record.
  2. "Present" and "null" are different. In the official field tables, "Always" means the key is always there but the value may be null; "Sometimes" means the key may be absent entirely. store, currency, price, cancel_reason are the latter — optional, not merely nullable — and your schema should say so.
  3. Deprecated fields still arrive: entitlement_id (singular) → use entitlement_ids; takehome_percentageuse tax_percentage + commission_percentage.
  4. Google Play product_id is composite. Products configured in RevenueCat after February 2023 arrive as <subscription_id>:<base_plan_id>. Look them up by the bare subscription ID and your backend product lookup silently misses.

8.3 Implementation: HMAC verification, idempotency, ordering (Next.js 16 Route Handler)

One prerequisite first: the HMAC signature does not arrive by default — you have to enable "HMAC webhook signing" on the webhook integration before X-RevenueCat-Webhook-Signature is attached. The signing secret is shown only once, at creation or rotation, and rotating invalidates the old secret immediately (there is no documented overlap window, so plan the cutover as a brief break). If you have not enabled it, drop the signature check below and run on the Authorization header alone — with correspondingly weaker defenses.

The HMAC is computed over the raw request body. Re-serializing a parsed object always fails verification, so taking the raw string with request.text(), verifying, and only then parsing is the one correct order.

// app/api/revenuecat/webhook/route.ts
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { z } from "zod";
import { serverEnv } from "@/lib/revenuecat/env.server";
import { applyMutation } from "@/lib/billing/apply-mutation";
import { decide } from "@/lib/billing/decide";

/**
 * RevenueCat webhook endpoint.
 * Principles:
 *  - Liberal in what it accepts, strict in what it uses (never 4xx on an unknown field or event type)
 *  - HTTP 200 is the only success. 202/204 count as failures and burn the retry budget
 *  - Idempotency via event.id, ordering via event_timestamp_ms — both enforced by DB constraints
 */

/** Validate only the fields we depend on, so new fields never break us (passthrough). */
const EventSchema = z
  .object({
    id: z.string().min(1),
    type: z.string().min(1),
    event_timestamp_ms: z.number().int().nonnegative(),
    // "Sometimes" fields are optional — that is not the same as nullable.
    app_user_id: z.string().min(1).optional(),
    original_app_user_id: z.string().min(1).optional(),
    aliases: z.array(z.string()).optional(),
    entitlement_ids: z.array(z.string()).nullish(),
    expiration_at_ms: z.number().int().nullish(),
    cancel_reason: z.string().nullish(),
    environment: z.string().optional(),
    store: z.string().optional(),
    transferred_from: z.array(z.string()).optional(),
    transferred_to: z.array(z.string()).optional(),
  })
  .passthrough();

const PayloadSchema = z.object({ api_version: z.string(), event: EventSchema });

/** A thin wrapper so broken JSON never throws — it becomes "invalid input" for Zod to reject. */
function safeJsonParse(raw: string): unknown {
  try {
    return JSON.parse(raw) as unknown;
  } catch {
    return null;
  }
}

/** The alert that pages a human. Send the shape of the issues only, never the raw payload (section 8.2). */
function reportMalformedWebhook(error: z.ZodError): void {
  console.error("[revenuecat] malformed webhook", {
    issues: error.issues.map((i) => ({ path: i.path, code: i.code })),
  });
}

/** Hash both sides first so the comparison is fixed-length and leaks no length information. */
function safeEqual(a: string, b: string): boolean {
  const digest = (v: string): Buffer => createHash("sha256").update(v, "utf8").digest();
  return timingSafeEqual(digest(a), digest(b));
}

/** Parse `t=<unix>,v1=<hex>` and check it against HMAC-SHA256("<t>.<rawBody>"). */
function hasValidSignature(rawBody: string, header: string | null): boolean {
  if (header === null) return false;
  const parts = new Map(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()] as const;
    }),
  );
  const timestamp = parts.get("t");
  const signature = parts.get("v1");
  if (timestamp === undefined || signature === undefined) return false;

  // A signature only proves who sent it. Closing the replay window — someone
  // re-posting a captured, still-valid request — needs a clock check. The
  // official verification steps and reference implementations use 300 seconds.
  // But this trusts the receiving host's clock: if NTP breaks and you drift past
  // 300s, every legitimate delivery 401s and is lost once the section 8.1 retry
  // budget runs out. Clock sync is a precondition, not a detail.
  const skewSec = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(skewSec) || skewSec > 300) return false;

  const expected = createHmac("sha256", serverEnv.REVENUECAT_WEBHOOK_SIGNING_SECRET)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");
  return safeEqual(expected, signature);
}

export async function POST(request: Request): Promise<Response> {
  // (1) Capture the raw body first. The HMAC cannot be verified after JSON.parse.
  const rawBody = await request.text();

  const authorized =
    safeEqual(request.headers.get("authorization") ?? "", serverEnv.REVENUECAT_WEBHOOK_SECRET) &&
    hasValidSignature(rawBody, request.headers.get("x-revenuecat-webhook-signature"));

  if (!authorized) {
    // Don't tell an attacker which of the two failed. Retrying won't help, so 401 and stop.
    return new Response("unauthorized", { status: 401 });
  }

  const parsed = PayloadSchema.safeParse(safeJsonParse(rawBody));
  if (!parsed.success) {
    // Returning 5xx on a broken payload means ~2.5 hours of retries.
    // Accept input you cannot fix with a 200 and page a human instead.
    reportMalformedWebhook(parsed.error);
    return new Response(null, { status: 200 });
  }

  const { event } = parsed.data;
  const mutation = decide(event); // pure function (section 9); touches neither DB nor network
  await applyMutation(event, mutation);

  // ★ Never return anything but 200. 202/204 are treated as failures and retried.
  return new Response(null, { status: 200 });
}

The schema expresses idempotency and ordering as database constraints, not application logic.

-- Received events: the primary key structurally eliminates redelivery
-- (retries arrive with the same event.id, so this alone drives duplicate processing to zero)
create table revenuecat_events (
  id                  text primary key,           -- event.id
  type                text        not null,
  app_user_id         text,
  event_timestamp_ms  bigint      not null,
  received_at         timestamptz not null default now()
);

-- The projection of access rights: authorization reads only this table
create table user_entitlements (
  user_id                  text        not null,
  entitlement_id           text        not null,
  expires_at               timestamptz,            -- null = no expiry (a lifetime purchase)
  last_event_timestamp_ms  bigint      not null,   -- the breakwater against reordering
  store                    text,
  updated_at               timestamptz not null default now(),
  primary key (user_id, entitlement_id)
);
-- Applying an update: if an older event arrives late, the WHERE clause voids the UPDATE entirely.
-- No "should I apply this?" branch in application code — so nothing is ever missed.
insert into user_entitlements
  (user_id, entitlement_id, expires_at, last_event_timestamp_ms, store)
values ($1, $2, $3, $4, $5)
on conflict (user_id, entitlement_id) do update
   set expires_at              = excluded.expires_at,
       last_event_timestamp_ms = excluded.last_event_timestamp_ms,
       store                   = excluded.store,
       updated_at              = now()
 where user_entitlements.last_event_timestamp_ms < excluded.last_event_timestamp_ms;
-- Authorization always takes this shape (decided by expiry, never by event type)
select 1
  from user_entitlements
 where user_id = $1
   and entitlement_id = $2
   and (expires_at is null or expires_at > now());

event_timestamp_ms is identical on a retry. So while it works for ordering comparisons (<), it is not a clock for "when did I receive this." Stamp your own received_at.


9. Don't branch on event type — collapse it to an expiry

Write a switch over 26 event types to decide grant-versus-revoke and your billing breaks the day RevenueCat adds an event type. Given that RevenueCat itself declares it can add event types without bumping api_version, that is a question of when, not if.

Instead, collapse events into just three classes — "does this carry the state of an entitlement?" — and let expires_at decide the actual answer.

// lib/billing/decide.ts — a pure function. No DB, no network, no clock, so it is trivial to test.
/** The official Event Types list (as read on 2026-08-06). Add to it as it grows. */
export type KnownEventType =
  | "TEST" | "INITIAL_PURCHASE" | "RENEWAL" | "CANCELLATION" | "UNCANCELLATION"
  | "NON_RENEWING_PURCHASE" | "SUBSCRIPTION_PAUSED" | "EXPIRATION" | "BILLING_ISSUE"
  | "PRODUCT_CHANGE" | "SUBSCRIPTION_EXTENDED" | "REFUND_REVERSED" | "INVOICE_ISSUANCE"
  | "TRANSFER" | "TEMPORARY_ENTITLEMENT_GRANT" | "VIRTUAL_CURRENCY_TRANSACTION"
  | "EXPERIMENT_ENROLLMENT" | "PURCHASE_REDEEMED" | "PAYWALL_IMPRESSION" | "PAYWALL_CLOSE"
  | "PAYWALL_CANCEL" | "PAYWALL_EXIT_OFFER" | "PAYWALL_COMPONENT_INTERACTED"
  | "SUBSCRIBER_ALIAS" | "PRICE_INCREASE_CONSENT_REQUIRED" | "PRICE_INCREASE_CONSENT_APPROVED";

type Handling =
  | "entitlement" // entitlement_ids + expiration_at_ms carry the entitlement's current value
  | "transfer"    // the owner of the entitlement changes
  | "observe";    // analytics only; access rights don't move

/**
 * `satisfies` turns a missing classification into a compile error.
 * Add one member to KnownEventType and the build fails until you classify it here.
 */
const EVENT_HANDLING = {
  INITIAL_PURCHASE: "entitlement",
  RENEWAL: "entitlement",
  UNCANCELLATION: "entitlement",
  NON_RENEWING_PURCHASE: "entitlement",
  PRODUCT_CHANGE: "entitlement",
  SUBSCRIPTION_EXTENDED: "entitlement",
  REFUND_REVERSED: "entitlement",
  TEMPORARY_ENTITLEMENT_GRANT: "entitlement",
  EXPIRATION: "entitlement",          // ★ revocation happens here: expiration_at_ms moves into the past
  SUBSCRIPTION_PAUSED: "entitlement", // ★ official: do not revoke on a pause
  BILLING_ISSUE: "entitlement",       // ★ official: do not revoke on a billing failure
  CANCELLATION: "entitlement",        // ★ valid until the period ends; refunds are the one exception
  TRANSFER: "transfer",
  SUBSCRIBER_ALIAS: "observe",        // deprecated (new projects never receive it)
  TEST: "observe",
  INVOICE_ISSUANCE: "observe",
  VIRTUAL_CURRENCY_TRANSACTION: "observe",
  EXPERIMENT_ENROLLMENT: "observe",
  PURCHASE_REDEEMED: "observe",
  PAYWALL_IMPRESSION: "observe",
  PAYWALL_CLOSE: "observe",
  PAYWALL_CANCEL: "observe",
  PAYWALL_EXIT_OFFER: "observe",
  PAYWALL_COMPONENT_INTERACTED: "observe",
  PRICE_INCREASE_CONSENT_REQUIRED: "observe",
  PRICE_INCREASE_CONSENT_APPROVED: "observe",
} as const satisfies Record<KnownEventType, Handling>;

const isKnown = (type: string): type is KnownEventType => type in EVENT_HANDLING;

export type AccessMutation =
  | {
      readonly kind: "upsert";
      readonly appUserId: string;
      readonly entitlementIds: readonly string[];
      /** null means no expiry — a lifetime purchase. A past timestamp means it lapsed then. */
      readonly expiresAtMs: number | null;
    }
  | { readonly kind: "revokeNow"; readonly appUserId: string; readonly entitlementIds: readonly string[] }
  | { readonly kind: "transfer"; readonly from: readonly string[]; readonly to: readonly string[] }
  | { readonly kind: "noop"; readonly reason: string };

export interface RevenueCatEvent {
  readonly type: string;
  readonly app_user_id?: string;
  readonly entitlement_ids?: readonly string[] | null;
  readonly expiration_at_ms?: number | null;
  readonly cancel_reason?: string | null;
  readonly transferred_from?: readonly string[];
  readonly transferred_to?: readonly string[];
}

export function decide(event: RevenueCatEvent): AccessMutation {
  // Drop unknown event types to "observe". A provider's feature launch is not your outage.
  // (Paywall event type strings are configurable per integration, so this path really is exercised.)
  if (!isKnown(event.type)) return { kind: "noop", reason: `unknown type: ${event.type}` };

  switch (EVENT_HANDLING[event.type]) {
    case "transfer":
      // TRANSFER is delivered only to the destination user.
      // Drop transferred_from's entitlements or the old user keeps access forever.
      return {
        kind: "transfer",
        from: event.transferred_from ?? [],
        to: event.transferred_to ?? [],
      };

    case "entitlement": {
      const appUserId = event.app_user_id;
      const entitlementIds = event.entitlement_ids ?? [];
      if (appUserId === undefined || entitlementIds.length === 0) {
        return { kind: "noop", reason: "no entitlement in payload" };
      }
      // Refunds are the one case that revokes without waiting for the period to end.
      // In the official cancel_reason definitions, CUSTOMER_SUPPORT means "was refunded".
      if (event.type === "CANCELLATION" && event.cancel_reason === "CUSTOMER_SUPPORT") {
        return { kind: "revokeNow", appUserId, entitlementIds };
      }
      return { kind: "upsert", appUserId, entitlementIds, expiresAtMs: event.expiration_at_ms ?? null };
    }

    case "observe":
      return { kind: "noop", reason: "analytics-only event" };
  }
}

The design rests directly on the official definitions.

  • CANCELLATION = "A subscription or non-renewing purchase was canceled or refunded." cancel_reason is one of UNSUBSCRIBE / BILLING_ERROR / DEVELOPER_INITIATED / PRICE_INCREASE / CUSTOMER_SUPPORT / UNKNOWN, and CUSTOMER_SUPPORT is the one meaning "refunded via Apple support or similar." Cancellation is not immediate revocation — the user is entitled to the period they paid for.
  • For EXPIRATION the docs state plainly: "The associated user's access should be removed." That is the revocation signal. expiration_reason has seven values, including Google Play's SUBSCRIPTION_PAUSED.
  • The docs explicitly say not to revoke on SUBSCRIPTION_PAUSED or BILLING_ISSUE. Neither a pause nor a failed charge means "expired." When it really expires, EXPIRATION arrives. Cutting someone off at that point is exactly the same mistake as revoking on Stripe's payment_failed during dunning.
  • TEMPORARY_ENTITLEMENT_GRANT is a store-outage fallback lasting at most 24 hours. It is followed by either a normal INITIAL_PURCHASE (validation succeeded) or an EXPIRATION (it did not). Grant it straightforwardly with its expiry and either outcome stays consistent.

9.1 How this relates to the official recommendation (an honest note)

What the docs actually recommend is not reconstructing state from the webhook payload, but using the delivery as a trigger to re-fetch canonical state from the REST customer endpoint and syncing that. I am not arguing against it. The two approaches serve different goals.

ApproachStrengthsWeaknessesFits when
Payload projection (the code above)No extra API calls, low latency, immune to rate limitsYou must handle ordering and loss yourselfEntitlements are simple (one pro), event volume is high
REST re-fetch (official recommendation)Always canonical; ordering stops matteringOne request per event (against 480 req/min), exposed to external outagesEntitlements are complex, consistency comes first

In practice the best answer is both. Project from the payload immediately so the experience is fast, then — via waitUntil or similar — re-fetch canonical state from REST right after the same event and overwrite. The last_event_timestamp_ms guard means neither order of arrival can break it. And the periodic reconciliation in section 10 recovers whatever was lost after the five retries ran out. Separate the fast path from the correct path, and let the correct one win last — the same structure I have applied consistently in payments work.

9.2 Testing: a pure function needs no database and no network

// lib/billing/decide.test.ts
import { describe, it, expect } from "vitest";
import { decide } from "./decide";

const base = { app_user_id: "u1", entitlement_ids: ["pro"], expiration_at_ms: 1_800_000_000_000 };

describe("decide", () => {
  it("a voluntary cancellation does not revoke; it just records the expiry", () => {
    expect(decide({ ...base, type: "CANCELLATION", cancel_reason: "UNSUBSCRIBE" })).toEqual({
      kind: "upsert", appUserId: "u1", entitlementIds: ["pro"], expiresAtMs: 1_800_000_000_000,
    });
  });

  it("a refund (CUSTOMER_SUPPORT) revokes immediately, without waiting for expiry", () => {
    expect(decide({ ...base, type: "CANCELLATION", cancel_reason: "CUSTOMER_SUPPORT" })).toEqual({
      kind: "revokeNow", appUserId: "u1", entitlementIds: ["pro"],
    });
  });

  it.each(["BILLING_ISSUE", "SUBSCRIPTION_PAUSED"])(
    "%s does not revoke (an explicit prohibition in the docs)",
    (type) => {
      expect(decide({ ...base, type }).kind).toBe("upsert");
    },
  );

  it("TRANSFER returns from/to so the source user's entitlement can be dropped too", () => {
    expect(
      decide({ type: "TRANSFER", transferred_from: ["old"], transferred_to: ["new"] }),
    ).toEqual({ kind: "transfer", from: ["old"], to: ["new"] });
  });

  it("an unknown event type falls through to observe without throwing (future-proof)", () => {
    expect(decide({ type: "SOME_FUTURE_EVENT" }).kind).toBe("noop");
  });
});

10. REST API v2: the reconciliation and recovery path

ItemValue
Base URLhttps://api.revenuecat.com/v2
AuthAuthorization: Bearer <secret key> (the Bearer prefix is mandatory; v1 keys do not work with v2)
Rate limitsCustomer Information 480 req/min, Charts & Metrics 25, Project Configuration 60, Virtual Currencies 480 (shared per domain, not per endpoint)
Limit headersRevenueCat-Rate-Limit-Current-Usage / RevenueCat-Rate-Limit-Current-Limit (not the IETF RateLimit-* headers). On a 429 you also get Retry-After (seconds) and backoff_ms in the body (milliseconds)
Paginationlimit (default 20; out-of-range values are clamped, not rejected) plus a forward-only starting_after cursor. next_page is absent when there is no next page (not null)
Key endpointsGET /projects/{project_id}/customers/{customer_id} / .../active_entitlements / .../subscriptions
// lib/revenuecat/api.server.ts — server-only. A client that ignores neither 429 nor 423.
import "server-only";
import { serverEnv } from "./env.server";

const BASE_URL = "https://api.revenuecat.com/v2";

export class RetryableApiError extends Error {
  constructor(readonly retryAfterMs: number, readonly status: number) {
    super(`revenuecat api ${status}; retry after ${retryAfterMs}ms`);
    this.name = "RetryableApiError";
  }
}

/**
 * A single HTTP boundary. Callers never see a status code — only three states:
 * a value, "retry later," or a permanent failure.
 */
async function get<T>(path: string, parse: (json: unknown) => T): Promise<T> {
  const response = await fetch(`${BASE_URL}${path}`, {
    headers: { Authorization: `Bearer ${serverEnv.REVENUECAT_SECRET_KEY}` },
    cache: "no-store", // used for authorization, so it must never be cached
  });

  // 429 = rate limited, 423 = another request is mutating the same resource (both clear with time)
  if (response.status === 429 || response.status === 423) {
    const body: unknown = await response.json().catch(() => null);
    const backoffMs =
      typeof body === "object" && body !== null && "backoff_ms" in body
        ? Number((body as { backoff_ms: unknown }).backoff_ms)
        : Number(response.headers.get("Retry-After") ?? "60") * 1000;
    throw new RetryableApiError(Number.isFinite(backoffMs) ? backoffMs : 60_000, response.status);
  }
  if (!response.ok) throw new Error(`revenuecat api ${response.status} for ${path}`);

  return parse(await response.json());
}

/** Fetch the customer's currently active entitlements as canonical state. */
export function fetchActiveEntitlements(appUserId: string) {
  const { REVENUECAT_PROJECT_ID } = serverEnv;
  // The App User ID goes in the URL, so always encode it (`/` is banned by RevenueCat, but be defensive)
  return get(
    `/projects/${REVENUECAT_PROJECT_ID}/customers/${encodeURIComponent(appUserId)}/active_entitlements`,
    (json) => json, // in practice, narrow this with a Zod schema
  );
}

Never call v1's GET /subscribers/{app_user_id} from an authorization path. It is not a side-effect-free read: passing an App User ID that does not exist creates the customer (200 versus 201 tells you which happened). Plenty of samples online still use this v1 endpoint, and it will manufacture ghost customers on every request. Use v2's active_entitlements for lookups.

The casing differs between the two surfaces. In webhooks, store and type are uppercase (APP_STORE, INITIAL_PURCHASE); in REST API v2, and in the event_types you send when configuring a webhook, they are lowercase (app_store, initial_purchase). The value sets don't match either (v2's store includes external, paypal, and galaxy, which do not appear in the webhook list). Sharing one TypeScript union across both surfaces will break. Define them separately.

The reason to keep this path is section 8.1's "5 retries, about 2 hours 35 minutes." A daily job that reconciles the active entitlements in your database against RevenueCat's state stops missed webhooks from quietly accumulating. It is the same event-driven-plus-periodic-reconciliation pairing I have used throughout my payments work.


11. Trusted Entitlements: enabling it protects nothing on its own

Tampering with a device to intercept traffic with RevenueCat and forge an entitlement response — that is the MiTM attack the docs have in mind. Trusted Entitlements makes the SDK verify the signature on the entitlement data it receives.

Here is the warning the docs state explicitly:

"Enabling Trusted Entitlements does not automatically protect your app. The SDK provides verification data, but it's your responsibility to check the verification result in your code and decide whether to grant access based on unverified entitlements."

"I turned it on" is not a countermeasure. Nothing is protected until you write code that reads verificationResult and acts on it.

  • Modes: EntitlementVerificationMode.disabled / .informational
  • Defaults: enabled by default on iOS 5.15.0+ and Android 8.11.0+ (disabled by default on the earlier 4.25.0–5.14.x and 6.6.0–8.10.x)
  • The four results: notRequested (no verification performed), verified (verified with the server), verifiedOnDevice (created and verified on device via StoreKit 2), and failed (possible MiTM)
// Make the policy for "entitlements that failed verification" explicit on the client
import { VERIFICATION_RESULT, type CustomerInfo } from "react-native-purchases";
import { type EntitlementId } from "./entitlements";

/**
 * Treat an entitlement that failed verification as absent.
 * This is a UI-level defense only; server resources are authorized by tier [2] in section 7.
 */
export function hasTrustedEntitlement(info: CustomerInfo, id: EntitlementId): boolean {
  const entitlement = info.entitlements.active[id];
  if (entitlement === undefined) return false;
  return entitlement.verification !== VERIFICATION_RESULT.FAILED;
}

The correct threat model: client-side verification reduces "one person tampers with their device to get premium for themselves." It is not what protects the parts of your server that cost money (AI inference, video transcoding, third-party APIs). Those are protected by the three-tier model in section 7, where the server reads its own database. Client verification and server authorization are not alternatives; they cover different ground.


12. Paywalls, experiments, and what the metrics actually mean

The real payoff of making Offerings remote config is being able to change prices and messaging without a store review. RevenueCat's Paywalls are the UI layer on top of that.

Note that the paywall UI is always a second package (RevenueCatUI, react-native-purchases-ui, purchases_ui_flutter, and so on). Installing only the purchases SDK will not give you presentPaywall.

Required SDK versions per the docs: purchases-ios 5.27.1+, purchases-android 8.19.2+, react-native-purchases 8.11.3+, purchases-flutter 8.10.1+. Supported on iOS 15.0+, Android 7.0+, macOS 12.0+, and web; watchOS, tvOS, and visionOS are unsupported. Multipage paywalls require considerably newer SDKs (iOS 5.83.0, Android 10.16.0, and so on) — and since the editor lets you build one anyway, a multipage paywall on an older SDK simply will not appear on device.

// SwiftUI — show the paywall only when the entitlement is missing
.presentPaywallIfNeeded(
    requiredEntitlementIdentifier: Constants.ENTITLEMENT_ID,
    purchaseCompleted: { customerInfo in ... },
    restoreCompleted: { customerInfo in ... }
)
// Android (Compose) — the API still carries an Experimental annotation
PaywallDialog(
    PaywallDialogOptions.Builder()
        .setRequiredEntitlementIdentifier(Constants.ENTITLEMENT_ID)
        .setListener(object : PaywallListener { ... })
        .build()
)
// React Native
const paywallResult = await RevenueCatUI.presentPaywall();
  • Targeting and Placements decide which user sees which Offering, on which screen. A Placement can legitimately be configured to "No Offering" and return null — force-unwrap it and you crash.
  • Experiments A/B-test Offerings. Hardcoding an identifier disables them (they assume you read current). You also need to identify the user before showing the paywall, or one person on two devices counts as two, corrupting the result.
  • Customer Center handles cancellations, refund requests, and plan changes in-app. Like Stripe's Customer Portal, the right move is not to build it yourself — but feature parity is not equal across platforms: refund requests and plan changes are iOS-only, and purchase history is unsupported on Android.

Don't misread the metric definitions

If dashboard numbers are going to drive decisions, know how they are defined.

  • Experiment results are Bayesian. RevenueCat reports a "Chance to Win" and a 95% credible interval — not a p-value, not a confidence interval. Do not restate it as "statistically significant."
  • Realized LTV is net of refunds but gross of store commissions. It is not proceeds.
  • MRR is an end-of-period snapshot, excluding trials, non-recurring subscriptions, and one-time purchases.
  • Active Subscriptions counts cancelled-but-not-yet-expired as active and excludes family-sharing recipients.

13. Testing: time runs fast in the sandbox

Apple's sandbox compresses subscription periods. The official table (with the sandbox account's default "Renewal every 5 minutes" setting):

Production subscription periodSandbox renewalTestFlight renewal
3 days2 minutes1 day
1 week3 minutes1 day
1 month5 minutes1 day
2 months10 minutes1 day
3 months15 minutes1 day
6 months30 minutes1 day
1 year1 hour1 day

Auto-renewal stops after a maximum of 12 renewals per day. So rather than leaving a monthly plan for a month, wait five minutes and the renewal event fires — you can verify the whole RENEWALEXPIRATION sequence over a lunch break. Note that Apple lets you pick a renewal rate per sandbox account (3 minutes / 5 minutes / 30 minutes / 1 hour), so this table is the table for the default setting. TestFlight's behavior changed in December 2024 to once every 24 hours, capped at 6 renewals in a week (the "every few minutes" in older blog posts is no longer how it works).

Know the StoreKit Configuration file's limits too. Per the docs, "StoreKit testing only works if you are running your app directly through Xcode," and command-line tools do not recognize it. Further, "StoreKit testing won't show cancellation or refund events" — those never appear in the receipt, so this path cannot verify them. Products in a .storekit file need not exist in App Store Connect, but they must exist in RevenueCat, and you must upload the StoreKit public certificate.

Sandbox events arrive with environment: "SANDBOX". Separate them at intake so they never mix into production aggregates or billing logic (the dashboard's webhook settings also let you choose which environments to send). Note, though, that SANDBOX versus PRODUCTION is a property of the transaction, not of the user — the same customer can hold both.

The docs are explicit that what you should verify in the sandbox is the purchase flow, not product metadata (prices, names). Verify price display through the store's own configuration.

A small constraint that bites: RevenueCat caps App Store subscription receipts at 100 per customer. Repeated sandbox testing reaches that easily, and the symptom is a generic UNKNOWN error. The fix is deleting that test customer — but deleting a customer does not delete Apple's purchase history, so reproducing a genuinely fresh user also means signing out of the sandbox account on the device and creating a new one.


14. Migrating an existing app: syncPurchases is not a backfill

If your app already has IAP, the official recommendation is a server-side import.

  • Recommended: send Apple receipts or Google purchase tokens to POST /receipts. RevenueCat validates and deduplicates them.
  • The limits of client-side syncPurchases: it only sees the receipt on the device (iOS) or currently-owned purchases (Android) of someone who opens the new build. It cannot retrieve the store account's full order history, so it is not a migration backfill.
  • ⚠️ The official warning: "Do not sync or restore on every app launch." Calling it every launch adds latency and risks unintentionally aliasing customers. If you call it, call it once, under the condition "subscribed in the old system but not in RevenueCat."
  • ⚠️ If you configure your app to complete transactions itself during the migration, the current API is purchasesAreCompletedBy (formerly Observer Mode). Older SDKs still use the observer-mode naming, so real codebases contain both. On iOS you must also specify storeKitVersion in that case.
  • ⚠️ From Android SDK v9 / Google Play Billing Library 8 on, syncPurchases covers only active subscriptions and unconsumed one-time purchases. A migration planned against the older behavior will quietly drop historical rows.
  • ⚠️ Older Google Play data has limits, and a separate path — Google Historical Import — exists for it. But that is a charts backfill, not an event replay: no events fire to your integrations, and billing issues, partial refunds, and auto-renewal status are not recovered.
  • Imported customers take several hours to appear in Customer Lists, and charts update within roughly 24 hours.
  • ⚠️ The same transaction arriving via both the SDK and your server is deduplicated, but App User IDs are not reconciled for you. If the ID used by your server-side import differs from the one the app calls logIn with, the same purchase ends up attached to two different people. The first design decision in any migration is which ID everything standardizes on.

Migration is safest as a period of being correct twice, not as a switchover day. Run a window where you check both the old system's subscription state and the RevenueCat entitlement and OR them together, log the differences, and retire the old path once that difference reaches zero. In payments work, this is the procedure with the fewest accidents.


15. Plan changes and price changes: the store differences that cannot be abstracted away

RevenueCat absorbs a great deal of difference between stores, but upgrades, downgrades, and price changes are where per-store behavior surfaces directly. Implement them on the assumption that "they're roughly the same" and your refunds and charges will disagree.

First: developers cannot change a plan on a user's behalf

Apple states it plainly: "Apple does not allow developers to manage subscriptions on behalf of users." Google Play does allow developer-initiated cancellation via the Console or API (except for prepaid plans). So even if you build your own "change plan" screen, it has to hand off to the store's management UI in the end. RevenueCat's managementURL deep-links to the management screen of the store the customer actually purchased through (App Store, Google Play, Amazon, RevenueCat Billing, Paddle). Do not branch and assemble store URLs yourself.

Upgrade and downgrade behavior

AppleGoogle PlayRevenueCat Billing
UpgradeImmediate, with a prorated refund of the original subscriptionPass oldProductId. The default replacement mode is WITHOUT_PRORATIONImmediate; unused time is credited via a partial refund
DowngradeThe current plan runs to the next renewal date, then renews at the lower level and priceDEFERRED is Google's recommendationScheduled for the end of the current cycle. Not prorated
Crossgrade (same level)Immediate if the durations match; otherwise at the next renewal dateControlled by the replacement mode

Google Play's replacement modes are WITHOUT_PRORATION, WITH_TIME_PRORATION, CHARGE_FULL_PRICE, CHARGE_PRORATED_PRICE, and DEFERRED. Implement an upgrade without knowing the default is "no proration" and your users throw away their remaining time and pay full price.

A trap the docs name explicitly: "When a customer upgrades products during an introductory period (including a free trial), Apple does not cancel the introductory offer but instead keeps the introductory offer active in addition to the upgraded product." Upgrade during a trial and Apple does not cancel the introductory offer — it keeps it running alongside the upgraded product. Any logic written on the assumption that "a trial user who upgrades starts paying" is off its premise right here.

Price changes and grandfathering

  • Prices are changed in App Store Connect or Google Play, not in RevenueCat. Existing subscribers may need to opt in to the new price.
  • Apple: affected subscribers are notified through a message sheet that displays automatically inside your app. If you would rather avoid that, the docs describe grandfathering existing subscriptions at the lower price and raising the price for new subscriptions only.
  • Google Play: subscribers are notified by email and a Play notification 7 days after the price change, and have 30 days to accept or the subscription cancels at renewal.
  • RevenueCat records the price at purchase time. For Apple price changes to be detected automatically you need App Store Server Notifications V2 plus an uploaded API key. On Google Play, keeping existing subscribers on the current price as a legacy price cohort is what the docs recommend for correct reporting.
  • The corresponding webhook events are PRICE_INCREASE_CONSENT_REQUIRED / PRICE_INCREASE_CONSENT_APPROVED, plus EXPIRATION with expiration_reason: PRICE_INCREASE. Section 9 classifies those first two as observe because the price-consent process does not move access rights — when a subscription actually lapses, EXPIRATION arrives.

16. Cost, and the ability to walk away

As of 2026-08-06 the official pricing is straightforward.

  • Free up to $2,500 in monthly tracked revenue (MTR).
  • Above that, 1% of what you track. This is widely misread: the 1% applies to the full MTR, not just the excess (RevenueCat's own FAQ example: $2.5K MTR → $25).
  • MTR is measured before the store's cut, in USD. A Japanese app grossing ¥400,000 a month is billed on the gross, not on the payout after Apple's share. Ad revenue does not count toward MTR.
  • For high transaction volume or complex billing models there is Enterprise (volume discounts, dedicated support, custom SLAs).
  • Growth Tools — paywalls, web-to-app funnels, and A/B testing only — is also 1% of MTR.

"Is 1% expensive?" has no answer in isolation. The comparison is the total cost of building and maintaining it yourself: server-side receipt validation for two stores, intake and redelivery handling for App Store Server Notifications and Google RTDN, transfers and aliases, refunds, grace periods, a dashboard — and the machinery that tells you when any of it breaks, plus the maintenance of following every store spec change. For solo developers and small teams, that 1% is often the cheapest insurance available. For an organization with large MTR and an engineering team, the break-even for building it in-house is genuinely reachable.

And do not forget the ability to walk away. Here I will be blunt: there is no page in the official documentation describing how to migrate off RevenueCat. Everything under /docs/migrating-to-revenuecat/* runs inbound only. What you can officially get out is Scheduled Data Exports — per-transaction subscriber and revenue data plus the virtual-currency ledger, delivered as CSV or Parquet to Amazon S3, Google Cloud Storage, or Azure Blob Storage, once per day by default (shorter intervals on Enterprise) — and it is gated by plan (available to accounts signed up after September '23, the legacy Grow and Pro plans, and Enterprise).

Put the other way round: the authority over a purchase remains, to the very end, Apple's and Google's receipts and purchase tokens. RevenueCat is only the normalization layer above them. Which is exactly why:

  • Keeping the user_entitlements projection in your own database (section 8) is also insurance against vendor lock-in.
  • Making the customer ID (App User ID) your own UUID makes reconciliation during a migration trivial.

The designs in sections 5 and 8 are, in other words, directly about how easily you could leave tomorrow. The healthy posture toward an external SaaS is not "should we use it," but "use it while staying in a shape that lets you step off at any time."


17. Pre-launch checklist

  1. No product IDs anywhere in app code. The only thing it may read is the Entitlement ID (section 2).
  2. New products attached to their Entitlement. The number-one accident the docs name (section 2).
  3. Not shipping with a Test Store key. The one warning the docs write as "NEVER" (section 3).
  4. Not concluding "it works" from Expo Go. Mocks run; no purchase happens (section 3).
  5. Apple's In-App Purchase Key registered. Without it, transactions are not recorded (section 6).
  6. Google credentials registered, then 36 hours waited. Failures before that are the wait, not a misconfiguration (section 6).
  7. App User ID immutable and non-PII. No email addresses (section 5).
  8. logOut not called casually. With custom IDs only, switch with logIn (section 5).
  9. Server features not authorized from CustomerInfo. The source of truth is your database (section 7).
  10. Webhooks verified with the HMAC signature — against the raw body, before parsing, and with the signature's t inside a 300-second tolerance so the replay window is closed (section 8.3).
  11. Nothing but 200 returned. 202 and 204 are failures that burn retries (section 8.1).
  12. A unique constraint on event.id and ordering via event_timestamp_ms (section 8.3).
  13. No revocation on BILLING_ISSUE or SUBSCRIPTION_PAUSED. Revoke on EXPIRATION; refunds are cancel_reason: CUSTOMER_SUPPORT (section 9).
  14. No 4xx/5xx on unknown event types. A provider's feature launch is not your outage (section 9).
  15. TRANSFER drops the source user's entitlement. The event only reaches the destination (section 9).
  16. No raw payloads containing subscriber_attributes.$email in your logs (section 8.2).
  17. A reconciliation job for missed webhooks. Retries stop after about 2 hours 35 minutes (section 10).
  18. verificationResult actually read. Enabling it alone protects nothing (section 11).
  19. RENEWALEXPIRATION verified end to end in the sandbox. A monthly plan renews in five minutes (section 13).
  20. Plan changes not hand-rolled per store. Send users to managementURL; Google's upgrade default is no proration (section 15).

Conclusion: correct billing is built from translation and projection

The essence of RevenueCat is not payment processing — it is translation. Two dialects, Apple's receipts and Google's purchase tokens, rendered into one word: pro. On top of that, your implementation has only three jobs left.

  • The app reads only the Entitlement. Product IDs and prices leave the binary (section 2).
  • The server reads only your database. Project into user_entitlements off the webhook and let authorization be a single local query (sections 7 and 8).
  • Decide by expiry, not by event type. Don't revoke on CANCELLATION, BILLING_ISSUE, or SUBSCRIPTION_PAUSED; leave it to EXPIRATION and expires_at. Drop unknown events to observation (section 9).

Hold those three and adding a plan, changing a price, and expanding from iOS to Android to web all become configuration changes rather than a billing rewrite. Break any one of them — trust the client somewhere, or let business logic hang off an event type — and that is where your outage starts six months later.

I have implemented and operated billing at this standard on a multi-channel Stripe subscription platform (idempotent webhooks via a unique event-id constraint, ordering guarantees, PII redaction; pricing resolution as pure functions; 433 tests) and on a payments reliability layer with zero double charges in production. I use RevenueCat as the billing layer in my own Expo apps (Palmia and memofu). If you are considering building mobile subscription billing from scratch, migrating off an existing IAP implementation, unifying entitlements across iOS/Android/web, or repairing webhook and access-rights consistency, I take that on end to end — from requirements through production operation and testability — at the standard of this article.

(The RevenueCat details in this article reflect the official documentation as of 2026-08-06. SDK version requirements, pricing, and event types move quickly in this area. Always confirm against the official documentation before you implement.)

Frequently asked questions

Do I actually need RevenueCat? How is it different from using StoreKit / Google Play Billing directly?
If you ship one store and one non-consumable, direct implementation is enough. RevenueCat earns its place when you have to normalize two receipt formats and two server-notification formats (App Store Server Notifications and Google RTDN) into a single Entitlement that your server can read too. Once you add restores, transfers between accounts, refunds, family sharing, and grace periods, the cost of building it correctly yourself is not "implementing billing" — it is operating billing. Section 1 has the decision table.
Can I trust the client's CustomerInfo for access control?
Use it to decide what the UI shows, yes. Do not let a client's claim decide whether a server resource (an API call, an AI inference, a database write) is allowed. RevenueCat's own docs point you at the REST API when you need subscription status outside the SDK. In practice the cheapest, fastest, most reliable shape is three tiers: sync entitlements into your own database from the webhook, and have your server read only your own database (sections 7 and 8).
When should I cut off access after a cancellation?
Not on `CANCELLATION`. By the official definition, `CANCELLATION` means auto-renewal was turned off or the purchase was refunded, and access continues until the period ends. The event the docs describe with "The associated user's access should be removed" is `EXPIRATION`. The docs also explicitly say not to revoke on `SUBSCRIPTION_PAUSED` or `BILLING_ISSUE`. The one case for immediate revocation is a refund — `cancel_reason: CUSTOMER_SUPPORT` (section 9).
What happens if a webhook arrives twice? How long does RevenueCat keep retrying?
The docs state "at least one delivery," so the same event can arrive more than once. Retries reuse the same `event.id`, so making it a primary key eliminates duplicates structurally. Retries stop after 5 attempts at 5/10/20/40/80-minute intervals — roughly 2 hours 35 minutes from the first attempt — and nothing arrives automatically after that. This is exactly why you also need the periodic reconciliation job in section 10.
What does it cost? Is 1% expensive?
As of 2026-08-06 the official pricing is: free up to $2,500 in monthly tracked revenue (MTR), then 1% of what you track. Two things to note — (1) the 1% applies to the full MTR, not just the amount above the threshold (RevenueCat's own FAQ example is $2.5K MTR → $25), and (2) MTR is measured before the store's cut. The decision is a comparison against the total cost of building it yourself (receipt validation for two stores, notification intake, transfers, refunds, a dashboard, and the maintenance of all of it), not a judgment about the rate in isolation (section 16).
Can I move an app that already has in-app purchases onto RevenueCat?
Yes. The official recommendation is a server-side import (`POST /receipts` with Apple receipts or Google purchase tokens), which RevenueCat validates and deduplicates. The client-side `syncPurchases` is not a backfill — it only sees the receipt on the device of someone who opens the new build. The docs also warn against calling it on every launch, because it invites unintended aliasing. See section 14.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Mobile subscription billing (RevenueCat / in-app purchases), from design to production

Entitlement design, price changes shipped through Offerings without a store review, a three-tier setup where your server — not the client — holds access rights off the webhook, and the boundaries where accidents cluster: restores, transfers, refunds, and migration off an existing IAP. I built a multi-channel subscription platform on Stripe (idempotent webhooks, ordering guarantees, 433 tests) and led the reliability layer of a payments platform with zero double charges in production; I use RevenueCat in two of my own Expo apps. I get the billing you can't fix later right the first time.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading