Separate store access from product usage. RevenueCat entitlements answer “may this customer use premium features?” Your database answers “how many consumable credits remain?” Neither value should be trusted from a client request alone.
Expose Entitlement State Through One Provider
Configure the native SDK once, fetch current customer info, and subscribe to SDK refreshes. Keep the entitlement id in one place. Put this implementation in a .native.tsx module so a universal web bundle never imports the native SDK.
// PurchaseProvider.native.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { Platform } from "react-native";
import Purchases, { type CustomerInfo } from "react-native-purchases";
const PREMIUM_ENTITLEMENT = "premium";
let purchasesConfigured = false;
type PurchaseState =
| { status: "loading"; isPremium: false }
| { status: "ready"; isPremium: boolean }
| { status: "error"; isPremium: false };
const PurchaseContext = createContext<PurchaseState>({ status: "loading", isPremium: false });
function hasPremium(info: CustomerInfo): boolean {
return info.entitlements.active[PREMIUM_ENTITLEMENT] !== undefined;
}
export function PurchaseProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<PurchaseState>({ status: "loading", isPremium: false });
useEffect(() => {
const apiKey = Platform.select({
ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY,
android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY,
});
if (!apiKey) {
setState({ status: "error", isPremium: false });
return;
}
if (!purchasesConfigured) {
Purchases.configure({ apiKey });
purchasesConfigured = true;
}
const receive = (info: CustomerInfo) =>
setState({ status: "ready", isPremium: hasPremium(info) });
Purchases.addCustomerInfoUpdateListener(receive);
Purchases.getCustomerInfo().then(receive).catch(() => {
setState({ status: "error", isPremium: false });
});
return () => {
Purchases.removeCustomerInfoUpdateListener(receive);
};
}, []);
return <PurchaseContext.Provider value={state}>{children}</PurchaseContext.Provider>;
}
export const usePurchases = () => useContext(PurchaseContext);
RevenueCat SDK keys used by the app are public, platform-specific keys. Private RevenueCat REST keys still belong on the server. Use production store keys in release builds, never Test Store keys. For web, provide a separate .web.tsx adapter backed by RevenueCat Web and its web SDK instead of falling into this provider's error state.
Give Restore Purchases a Visible Result
Every app should expose restoration. Update UI from the returned CustomerInfo, not from a success toast alone.
// PurchaseProvider.native.tsx
export async function restorePremium(): Promise<boolean> {
const info = await Purchases.restorePurchases();
return info.entitlements.active[PREMIUM_ENTITLEMENT] !== undefined;
}
restorePurchases is a user-triggered Apple, Google, or Amazon store action; do not call this native helper from the web adapter. While restoring, disable only the restore control and label the progress. On success, say what access was restored. On failure, keep the paywall usable and offer a manual retry.
Identify Purchases with the Authenticated User
After sign-in, link the store identity to the stable application user id:
export async function identifyPurchaser(userId: string) {
const { customerInfo } = await Purchases.logIn(userId);
return hasPremium(customerInfo);
}
Do not accept a user id from an unauthenticated purchase-sync request. Derive it from the server session. Define logout behavior deliberately so one person’s entitlement does not appear in the next person’s session on a shared device.
Consume Credits Atomically on the Server
The server checks entitlement and decrements usage in one transaction. The exact SQL library may differ; the invariant should not.
type ConsumeResult =
| { ok: true; remaining: number }
| { ok: false; reason: "NOT_ENTITLED" | "LIMIT_REACHED" };
type AuthenticatedUser = { id: string };
export async function consumeCredit(
user: AuthenticatedUser,
idempotencyKey: string,
): Promise<ConsumeResult> {
return db.transaction(async (tx) => {
const request = await tx.creditRequests.claimForUpdate({
userId: user.id,
idempotencyKey,
});
if (request.result) return request.result as ConsumeResult;
const entitlement = await tx.entitlements.findActiveForUpdate(
user.id,
PREMIUM_ENTITLEMENT,
);
if (!entitlement) {
const result: ConsumeResult = { ok: false, reason: "NOT_ENTITLED" };
await tx.creditRequests.complete(request.id, result);
return result;
}
const usage = await tx.usage.findForUpdate(user.id);
if (!usage || usage.remaining < 1) {
const result: ConsumeResult = { ok: false, reason: "LIMIT_REACHED" };
await tx.creditRequests.complete(request.id, result);
return result;
}
const remaining = usage.remaining - 1;
const result: ConsumeResult = { ok: true, remaining };
await tx.usage.update(user.id, { remaining });
await tx.creditRequests.complete(request.id, result);
return result;
});
}
Derive user from the authenticated server session, never from request JSON. claimForUpdate must insert or lock a row protected by a unique (userId, idempotencyKey) constraint; completing that row in the same transaction makes every duplicate return the original outcome without consuming twice. If generation fails permanently before delivering value, refund the credit in an auditable compensating transaction.
Sync Store Events to Your Backend
Use RevenueCat webhooks to update server-side access promptly. Verify the configured authorization header, store the webhook event id, and ignore duplicates. Periodically reconcile from RevenueCat because webhook delivery and your handler can fail.
The mobile listener is useful for immediate UI updates after SDK operations, but RevenueCat does not push arbitrary backend changes into a running client. Refresh customer info at deliberate lifecycle points.
Purchase UX Checks
- State the billing period, trial terms, and renewal behavior before purchase.
- Distinguish user cancellation from a failed transaction.
- Keep Restore Purchases and subscription management discoverable.
- Never gate already-purchased access on an animation or transient toast.
- Show limits before the action that would exceed them.
Sources
SDK behavior is checked against RevenueCat’s React Native installation, SDK configuration, entitlements, restoration, and webhooks documentation. The state and usage layers are independently written production patterns.