# 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: 2026-08-06
- Author: 友田 陽大
- Tags: RevenueCat, アプリ内課金, 決済, サブスクリプション, React Native, TypeScript, 冪等性
- URL: https://tomodahinata.com/en/blog/revenuecat-in-app-subscription-entitlements-webhooks-production-guide
- Category: Payments & billing
- Pillar guide: https://tomodahinata.com/en/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions

## Key points

- RevenueCat is the layer that translates "a store purchase" into an Entitlement. Your app should look at `entitlements.active["pro"]` and never at a product ID.
- Offerings and Packages are the box that lets you swap prices and paywalls remotely — changing pricing without shipping a store review is the real payoff.
- Webhooks can be verified with an HMAC signature (`X-RevenueCat-Webhook-Signature`), and only HTTP 200 counts as success — 202 and 204 are failures that burn your retry budget.
- Revoke access on `EXPIRATION`, never on `CANCELLATION`. The docs explicitly tell you not to revoke on `SUBSCRIPTION_PAUSED` or `BILLING_ISSUE` either.
- Pricing is free up to $2,500 in monthly tracked revenue, then 1% — of the full MTR, not just the excess (official pricing as read on 2026-08-06).

---

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](/labs/palmia) and [memofu](/labs/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](/case-studies/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](/case-studies/payment-platform-reliability) 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](https://www.revenuecat.com/docs/welcome/overview) (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 job | Build it yourself | RevenueCat |
| --- | --- | --- |
| Show the store's purchase UI | Call StoreKit / Play Billing directly | The SDK wraps it (the store still runs the purchase) |
| **Server-side validation** of receipts / purchase tokens | Implement against both Apple's and Google's APIs | **Handled** |
| Receiving **server notifications** from the stores | Implement App Store Server Notifications and Google RTDN separately | **Handled** (normalized into one webhook) |
| Unifying subscription state across iOS and Android | Match it up per user yourself | **Unified as an Entitlement** |
| Restores, account transfers, aliases | Build it yourself (where most accidents happen) | **Handled** (behavior is configurable) |
| Refunds, grace periods, family sharing | Absorb the differences between stores | **Handled** |
| Changing prices and paywalls remotely | You need your own config-delivery system | **Standard, via Offerings** |
| A/B tests and analytics | Build it, or buy another SaaS | Built in |
| **The business rules for access** | Yours | **Yours** (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."**

```text
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.

| Key | Where it lives | What it can do |
| --- | --- | --- |
| **Public SDK key** (per app, per platform) | In the app binary | Read and purchase for the current user |
| **Secret key** (`sk_` prefix, per project) | Server environment variables only | Read 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 key** | Local development only | Simulated 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.

```ts
// 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
// Swift (iOS) — once, right at app launch
import RevenueCat

Purchases.logLevel = .debug
Purchases.configure(withAPIKey: <public_apple_api_key>, appUserID: <app_user_id>)
```

```kotlin
// 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())
    }
}
```

```ts
// 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.

```ts
// 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];
```

```ts
// 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
// 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
// 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
    }
  }
)
```

```ts
// 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.

```ts
// 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.

```ts
// 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
// 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`.**

```ts
// 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

| API | When to call it | Caveat |
| --- | --- | --- |
| `restorePurchases()` | **Only when the user taps a "Restore" button** | It can raise an OS sign-in dialog, so never call it automatically |
| `syncPurchases()` | When you need a programmatic sync, e.g. during migration | The 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 default** — `iam.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

```text
[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

| Item | What 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 |
| Success | **HTTP 200 only.** "Any other status code will be considered a failure" — **including 202 and 204** |
| Timeout | **60 seconds.** Exceeding it disconnects and consumes retry budget |
| Retries | **Up to 5**, at **5 / 10 / 20 / 40 / 80 minutes** — roughly **2 hours 35 minutes total** from the first attempt, then it stops |
| Duplicates | **At-least-once.** Retries reuse **the same `event.id` and the same `event_timestamp_ms`** |
| Ordering | **Not documented** (so design for out-of-order delivery) |
| Latency | Usually 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 compatibility | **New 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)

```json
{
  "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_percentage` → **use `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.

```ts
// 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.**

```sql
-- 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)
);
```

```sql
-- 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;
```

```sql
-- 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.

```ts
// 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.

| Approach | Strengths | Weaknesses | Fits when |
| --- | --- | --- | --- |
| **Payload projection** (the code above) | No extra API calls, low latency, immune to rate limits | You must handle ordering and loss yourself | Entitlements are simple (one `pro`), event volume is high |
| **REST re-fetch** (official recommendation) | Always canonical; ordering stops mattering | One request per event (against 480 req/min), exposed to external outages | Entitlements 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

```ts
// 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

| Item | Value |
| --- | --- |
| Base URL | `https://api.revenuecat.com/v2` |
| Auth | `Authorization: Bearer <secret key>` (**the `Bearer ` prefix is mandatory**; v1 keys do not work with v2) |
| Rate limits | Customer Information **480 req/min**, Charts & Metrics **25**, Project Configuration **60**, Virtual Currencies **480** (**shared per domain, not per endpoint**) |
| Limit headers | `RevenueCat-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) |
| Pagination | `limit` (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 endpoints | `GET /projects/{project_id}/customers/{customer_id}` / `.../active_entitlements` / `.../subscriptions` |

```ts
// 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**)

```ts
// 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**.

```swift
// SwiftUI — show the paywall only when the entitlement is missing
.presentPaywallIfNeeded(
    requiredEntitlementIdentifier: Constants.ENTITLEMENT_ID,
    purchaseCompleted: { customerInfo in ... },
    restoreCompleted: { customerInfo in ... }
)
```

```kotlin
// Android (Compose) — the API still carries an Experimental annotation
PaywallDialog(
    PaywallDialogOptions.Builder()
        .setRequiredEntitlementIdentifier(Constants.ENTITLEMENT_ID)
        .setListener(object : PaywallListener { ... })
        .build()
)
```

```ts
// 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 period | Sandbox renewal | TestFlight renewal |
| --- | --- | --- |
| 3 days | 2 minutes | 1 day |
| 1 week | 3 minutes | 1 day |
| 1 month | **5 minutes** | 1 day |
| 2 months | 10 minutes | 1 day |
| 3 months | 15 minutes | 1 day |
| 6 months | 30 minutes | 1 day |
| 1 year | 1 hour | 1 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 `RENEWAL` → `EXPIRATION` 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

| | Apple | Google Play | RevenueCat Billing |
| --- | --- | --- | --- |
| **Upgrade** | Immediate, with **a prorated refund of the original subscription** | Pass `oldProductId`. **The default replacement mode is `WITHOUT_PRORATION`** | Immediate; unused time is **credited via a partial refund** |
| **Downgrade** | The current plan runs to the next renewal date, then renews at the lower level and price | **`DEFERRED` is Google's recommendation** | Scheduled for the end of the current cycle. **Not prorated** |
| **Crossgrade** (same level) | Immediate if the durations match; otherwise at the next renewal date | Controlled 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. **`RENEWAL` → `EXPIRATION` 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](https://www.revenuecat.com/docs/welcome/overview) before you implement.)*
