1 # RevenueCat Initial Setup
2
3 ## Step-by-Step Implementation
4
5 Ensure prerequisites are met. Do not proceed until you have done so.
6
7 Follow these steps in order when implementing RevenueCat integration for the user. Create a task list to track implementation progress.
8
9 Do not reference the @replit/revenuecat-sdk SDK docs during initial setup unless absolutely necessary. The instructions below should be sufficient.
10
11 1. Create a seed script, using the generated `getUncachableRevenueCatClient()` function in the scripts directory to get an authenticated client, to set up RevenueCat entities (project, app, products, entitlements, offerings, etc.).
12 - This seed script should be a new file that runs as a standalone script.
13 - It should not be used in the API server; instead, it should be run manually.
14 - This seed script should be run BEFORE implementing the client-side code for RevenueCat.
15
16 2. Run the seed script to create the entities in RevenueCat. Use the run command defined in the Project Structure section of the main skill doc.
17
18 3. After the seed script runs successfully:
19 - Three public API keys will be logged: test store, app store, and play store. Store these keys in the following environment variables:
20 - EXPO_PUBLIC_REVENUECAT_TEST_API_KEY
21 - EXPO_PUBLIC_REVENUECAT_IOS_API_KEY
22 - EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY
23
24 - The project id, test store app id, apple app store app id, and google play store app id will also be logged. Store this in the following environment variables:
25 - REVENUECAT_PROJECT_ID
26 - REVENUECAT_TEST_STORE_APP_ID
27 - REVENUECAT_APPLE_APP_STORE_APP_ID
28 - REVENUECAT_GOOGLE_PLAY_STORE_APP_ID
29
30 4. Implement client-side logic in React Native using the `react-native-purchases` SDK to set up the purchase flow.
31 - Use the public API keys obtained from the RevenueCat project created in step 3.
32 - Use the entitlement identifier to check if the user has access to the feature.
33 - Use the product identifier to get the product details.
34 - Use the package identifier to get the package details.
35 - Use the offering identifier to get the offering details.
36 - To make a purchase, use `Purchases.purchasePackage` to purchase the package. When in test mode, add a confirmation dialog to confirm whether we should purchase it or not.
37 - Do NOT Use Alert.alert or regular JavaScript alert to show confirmation prompts as these may not fully work. Implement custom modals if necessary.
38
39 5a. Implement the code for `lib/revenuecat.tsx` in the client app directory using the provided code snippet.
40
41 5b. Modify App.tsx (or the root layout file) as such:
42
43 1. Import `initializeRevenueCat` and call it at the top-level of the file with a try-catch block.
44
45 ```ts
46 import { Alert } from "react-native"
47
48 try {
49 initializeRevenueCat();
50 } catch (err: any) {
51 Alert.alert("RevenueCat Unavailable", err?.message ?? "Unknown error");
52 }
53 ```
54
55 2. Wrap the app in the `SubscriptionProvider` provider.
56
57 5c. Use the `useSubscription` hook whenever RevenueCat functionality is required.
58
59 - For paywalls or any subscription-related UI, NEVER hardcode prices, product names, or details; always derive them from the subscription context
60 - Example:
61
62 ```ts
63 const currentOffering = offerings?.current;
64 const packageToPurchase = currentOffering?.availablePackages[0];
65 const price = packageToPurchase?.product.priceString || "<fallback>";
66 ```
67
68 ## Code Files
69
70 Filename: seedRevenueCat.ts (in the scripts directory)
71
72 ```ts
73 import { getUncachableRevenueCatClient } from "./revenueCatClient";
74
75 import {
76 listProjects,
77 createProject,
78 listApps,
79 createApp,
80 listAppPublicApiKeys,
81 listProducts,
82 createProduct,
83 listEntitlements,
84 createEntitlement,
85 attachProductsToEntitlement,
86 listOfferings,
87 createOffering,
88 updateOffering,
89 listPackages,
90 createPackages,
91 attachProductsToPackage,
92 type App,
93 type Product,
94 type Project,
95 type Entitlement,
96 type Offering,
97 type Package,
98 type CreateProductData,
99 } from "@replit/revenuecat-sdk";
100
101 const PROJECT_NAME = "My Example App"; // REPLIT AGENT: Use the app's name as the project name
102
103 const PRODUCT_IDENTIFIER = "premium_monthly"; // REPLIT AGENT: Fill this in
104 const PLAY_STORE_PRODUCT_IDENTIFIER = "premium_monthly:monthly"; // REPLIT AGENT: Must follow the format: {subscriptionId}:{basePlanId}
105
106 const PRODUCT_DISPLAY_NAME = "Premium Monthly"; // REPLIT AGENT: Fill this in
107 const PRODUCT_USER_FACING_TITLE = "Premium Monthly"; // REPLIT AGENT: Fill this in
108 const PRODUCT_DURATION = "P1M"; // REPLIT AGENT: Fill in duration of product subscription; Enum: "P1W" "P1M" "P2M" "P3M" "P6M" "P1Y"
109
110 const APP_STORE_APP_NAME = "My iOS App"; // REPLIT AGENT: Fill this in
111 const APP_STORE_BUNDLE_ID = "com.example.ios"; // REPLIT AGENT: Fill this in
112 const PLAY_STORE_APP_NAME = "My Android App"; // REPLIT AGENT: Fill this in
113 const PLAY_STORE_PACKAGE_NAME = "com.example.android"; // REPLIT AGENT: Fill this in
114
115 const ENTITLEMENT_IDENTIFIER = "premium"; // REPLIT AGENT: Fill in the entitlement identifier
116 const ENTITLEMENT_DISPLAY_NAME = "Premium Access"; // REPLIT AGENT: Fill in the entitlement display name
117
118 const OFFERING_IDENTIFIER = "default"; // REPLIT AGENT: Fill this in
119 const OFFERING_DISPLAY_NAME = "Default Offering"; // REPLIT AGENT: Fill this in
120
121 const PACKAGE_IDENTIFIER = "$rc_monthly"; // REPLIT AGENT: Fill this in
122 const PACKAGE_DISPLAY_NAME = "Monthly Subscription"; // REPLIT AGENT: Fill this in
123
124 // REPLIT AGENT: Pricing configuration (amount in micros = dollars * 1,000,000)
125 const PRODUCT_PRICES = [
126 { amount_micros: 9990000, currency: "USD" }, // $9.99
127 { amount_micros: 8990000, currency: "EUR" }, // €8.99
128 ];
129
130 type TestStorePricesResponse = {
131 object: string;
132 prices: { amount_micros: number; currency: string }[];
133 };
134
135 async function seedRevenueCat() {
136 const client = await getUncachableRevenueCatClient();
137
138 let project: Project;
139 const { data: existingProjects, error: listProjectsError } = await listProjects({
140 client,
141 query: { limit: 20 },
142 });
143
144 if (listProjectsError) throw new Error("Failed to list projects");
145
146 const existingProject = existingProjects.items?.find((p) => p.name === PROJECT_NAME);
147
148 if (existingProject) {
149 console.log("Project already exists:", existingProject.id);
150 project = existingProject;
151 } else {
152 const { data: newProject, error: createProjectError } = await createProject({
153 client,
154 body: { name: PROJECT_NAME },
155 });
156 if (createProjectError) throw new Error("Failed to create project");
157 console.log("Created project:", newProject.id);
158 project = newProject;
159 }
160
161 const { data: apps, error: listAppsError } = await listApps({
162 client,
163 path: { project_id: project.id },
164 query: { limit: 20 },
165 });
166
167 if (listAppsError || !apps || apps.items.length === 0) {
168 throw new Error("No apps found");
169 }
170
171 let app: App | undefined = apps.items.find((a) => a.type === "test_store");
172 let appStoreApp: App | undefined = apps.items.find((a) => a.type === "app_store");
173 let playStoreApp: App | undefined = apps.items.find((a) => a.type === "play_store");
174
175 if (!app) {
176 throw new Error("No app with test store found");
177 } else {
178 console.log("App with test store found:", app.id);
179 }
180
181 if (!appStoreApp) {
182 const { data: newApp, error } = await createApp({
183 client,
184 path: { project_id: project.id },
185 body: {
186 name: APP_STORE_APP_NAME,
187 type: "app_store",
188 app_store: { bundle_id: APP_STORE_BUNDLE_ID },
189 },
190 });
191 if (error) throw new Error("Failed to create App Store app");
192 appStoreApp = newApp;
193 console.log("Created App Store app:", appStoreApp.id);
194 } else {
195 console.log("App Store app found:", appStoreApp.id);
196 }
197
198 if (!playStoreApp) {
199 const { data: newApp, error } = await createApp({
200 client,
201 path: { project_id: project.id },
202 body: {
203 name: PLAY_STORE_APP_NAME,
204 type: "play_store",
205 play_store: { package_name: PLAY_STORE_PACKAGE_NAME },
206 },
207 });
208 if (error) throw new Error("Failed to create Play Store app");
209 playStoreApp = newApp;
210 console.log("Created Play Store app:", playStoreApp.id);
211 } else {
212 console.log("Play Store app found:", playStoreApp.id);
213 }
214
215 const { data: existingProducts, error: listProductsError } = await listProducts({
216 client,
217 path: { project_id: project.id },
218 query: { limit: 100 },
219 });
220
221 if (listProductsError) throw new Error("Failed to list products");
222
223 const ensureProductForApp = async (targetApp: App, label: string, productIdentifier: string, isTestStore: boolean): Promise<Product> => {
224 const existingProduct = existingProducts.items?.find((p) => p.store_identifier === productIdentifier && p.app_id === targetApp.id);
225
226 if (existingProduct) {
227 console.log(label + " product already exists:", existingProduct.id);
228 return existingProduct;
229 }
230
231 const body: CreateProductData["body"] = {
232 store_identifier: productIdentifier,
233 app_id: targetApp.id,
234 type: "subscription",
235 display_name: PRODUCT_DISPLAY_NAME,
236 };
237
238 if (isTestStore) {
239 body.subscription = { duration: PRODUCT_DURATION };
240 body.title = PRODUCT_USER_FACING_TITLE;
241 }
242
243 const { data: createdProduct, error } = await createProduct({
244 client,
245 path: { project_id: project.id },
246 body,
247 });
248
249 if (error) throw new Error("Failed to create " + label + " product");
250 console.log("Created " + label + " product:", createdProduct.id);
251 return createdProduct;
252 };
253
254 const testStoreProduct = await ensureProductForApp(app, "Test Store", PRODUCT_IDENTIFIER, true);
255 const appStoreProduct = await ensureProductForApp(appStoreApp, "App Store", PRODUCT_IDENTIFIER, false);
256 const playStoreProduct = await ensureProductForApp(playStoreApp, "Play Store", PLAY_STORE_PRODUCT_IDENTIFIER, false);
257
258 // Add test store prices for the product using undocumented endpoint
259 console.log("Adding test store prices for product:", testStoreProduct.id);
260 console.log("Prices to add:", JSON.stringify(PRODUCT_PRICES, null, 2));
261
262 const { data: priceData, error: priceError } = await client.post<TestStorePricesResponse>({
263 url: "/projects/{project_id}/products/{product_id}/test_store_prices",
264 path: { project_id: project.id, product_id: testStoreProduct.id },
265 body: { prices: PRODUCT_PRICES },
266 });
267
268 if (priceError) {
269 if (priceError && typeof priceError === "object" && "type" in priceError && priceError["type"] === "resource_already_exists") {
270 console.log("Test store prices already exist for this product");
271 } else {
272 throw new Error("Failed to add test store prices");
273 }
274 } else {
275 console.log("Successfully added test store prices");
276 console.log("Response:", JSON.stringify(priceData, null, 2));
277 }
278
279 let entitlement: Entitlement | undefined;
280 const { data: existingEntitlements, error: listEntitlementsError } = await listEntitlements({
281 client,
282 path: { project_id: project.id },
283 query: { limit: 20 },
284 });
285
286 if (listEntitlementsError) throw new Error("Failed to list entitlements");
287
288 const existingEntitlement = existingEntitlements.items?.find((e) => e.lookup_key === ENTITLEMENT_IDENTIFIER);
289
290 if (existingEntitlement) {
291 console.log("Entitlement already exists:", existingEntitlement.id);
292 entitlement = existingEntitlement;
293 } else {
294 const { data: newEntitlement, error } = await createEntitlement({
295 client,
296 path: { project_id: project.id },
297 body: {
298 lookup_key: ENTITLEMENT_IDENTIFIER,
299 display_name: ENTITLEMENT_DISPLAY_NAME,
300 },
301 });
302 if (error) throw new Error("Failed to create entitlement");
303 console.log("Created entitlement:", newEntitlement.id);
304 entitlement = newEntitlement;
305 }
306
307 const { error: attachEntitlementError } = await attachProductsToEntitlement({
308 client,
309 path: { project_id: project.id, entitlement_id: entitlement.id },
310 body: {
311 product_ids: [testStoreProduct.id, appStoreProduct.id, playStoreProduct.id],
312 },
313 });
314
315 if (attachEntitlementError) {
316 if (attachEntitlementError.type === "unprocessable_entity_error") {
317 console.log("Product already attached to entitlement");
318 } else {
319 throw new Error("Failed to attach products to entitlement");
320 }
321 } else {
322 console.log("Attached products to entitlement");
323 }
324
325 let offering: Offering | undefined;
326 const { data: existingOfferings, error: listOfferingsError } = await listOfferings({
327 client,
328 path: { project_id: project.id },
329 query: { limit: 20 },
330 });
331
332 if (listOfferingsError) throw new Error("Failed to list offerings");
333
334 const existingOffering = existingOfferings.items?.find((o) => o.lookup_key === OFFERING_IDENTIFIER);
335
336 if (existingOffering) {
337 console.log("Offering already exists:", existingOffering.id);
338 offering = existingOffering;
339 } else {
340 const { data: newOffering, error } = await createOffering({
341 client,
342 path: { project_id: project.id },
343 body: {
344 lookup_key: OFFERING_IDENTIFIER,
345 display_name: OFFERING_DISPLAY_NAME,
346 },
347 });
348 if (error) throw new Error("Failed to create offering");
349 console.log("Created offering:", newOffering.id);
350 offering = newOffering;
351 }
352
353 if (!offering.is_current) {
354 const { error } = await updateOffering({
355 client,
356 path: { project_id: project.id, offering_id: offering.id },
357 body: { is_current: true },
358 });
359 if (error) throw new Error("Failed to set offering as current");
360 console.log("Set offering as current");
361 }
362
363 let pkg: Package | undefined;
364 const { data: existingPackages, error: listPackagesError } = await listPackages({
365 client,
366 path: { project_id: project.id, offering_id: offering.id },
367 query: { limit: 20 },
368 });
369
370 if (listPackagesError) throw new Error("Failed to list packages");
371
372 const existingPackage = existingPackages.items?.find((p) => p.lookup_key === PACKAGE_IDENTIFIER);
373
374 if (existingPackage) {
375 console.log("Package already exists:", existingPackage.id);
376 pkg = existingPackage;
377 } else {
378 const { data: newPackage, error } = await createPackages({
379 client,
380 path: { project_id: project.id, offering_id: offering.id },
381 body: {
382 lookup_key: PACKAGE_IDENTIFIER,
383 display_name: PACKAGE_DISPLAY_NAME,
384 },
385 });
386 if (error) throw new Error("Failed to create package");
387 console.log("Created package:", newPackage.id);
388 pkg = newPackage;
389 }
390
391 const { error: attachPackageError } = await attachProductsToPackage({
392 client,
393 path: { project_id: project.id, package_id: pkg.id },
394 body: {
395 products: [
396 { product_id: testStoreProduct.id, eligibility_criteria: "all" },
397 { product_id: appStoreProduct.id, eligibility_criteria: "all" },
398 { product_id: playStoreProduct.id, eligibility_criteria: "all" },
399 ],
400 },
401 });
402
403 if (attachPackageError) {
404 if (attachPackageError.type === "unprocessable_entity_error" && attachPackageError.message?.includes("Cannot attach product")) {
405 console.log("Skipping package attach: package already has incompatible product");
406 } else {
407 throw new Error("Failed to attach products to package");
408 }
409 } else {
410 console.log("Attached products to package");
411 }
412
413 const { data: testStoreApiKeys, error: testStoreApiKeysError } = await listAppPublicApiKeys({
414 client,
415 path: { project_id: project.id, app_id: app.id },
416 });
417 if (testStoreApiKeysError) {
418 throw new Error("Failed to list public API keys for Test Store app");
419 }
420 const { data: appStoreApiKeys, error: appStoreApiKeysError } = await listAppPublicApiKeys({
421 client,
422 path: { project_id: project.id, app_id: appStoreApp.id },
423 });
424 if (appStoreApiKeysError) {
425 throw new Error("Failed to list public API keys for App Store app");
426 }
427 const { data: playStoreApiKeys, error: playStoreApiKeysError } = await listAppPublicApiKeys({
428 client,
429 path: { project_id: project.id, app_id: playStoreApp.id },
430 });
431 if (playStoreApiKeysError) {
432 throw new Error("Failed to list public API keys for Play Store app");
433 }
434
435 console.log("\n====================");
436 console.log("RevenueCat setup complete!");
437 console.log("Project ID:", project.id);
438 console.log("Test Store App ID:", app.id);
439 console.log("App Store App ID:", appStoreApp.id);
440 console.log("Play Store App ID:", playStoreApp.id);
441 console.log("Test Store Product ID:", testStoreProduct.id);
442 console.log("App Store Product ID:", appStoreProduct.id);
443 console.log("Play Store Product ID:", playStoreProduct.id);
444 console.log("Entitlement Identifier:", ENTITLEMENT_IDENTIFIER);
445 console.log("Public API Keys - Test Store:", testStoreApiKeys?.items.map((item) => item.key).join(", ") ?? "N/A");
446 console.log("Public API Keys - App Store:", appStoreApiKeys?.items.map((item) => item.key).join(", ") ?? "N/A");
447 console.log("Public API Keys - Play Store:", playStoreApiKeys?.items.map((item) => item.key).join(", ") ?? "N/A");
448 console.log("====================\n");
449 }
450
451 seedRevenueCat().catch(console.error);
452 ```
453
454 Filename: lib/revenuecat.tsx (in the client app directory)
455
456 ```ts
457 import React, { createContext, useContext } from "react";
458 import { Platform } from "react-native";
459 import Purchases from "react-native-purchases";
460 import { useMutation, useQuery } from "@tanstack/react-query";
461 import Constants from "expo-constants";
462
463 const REVENUECAT_TEST_API_KEY = process.env.EXPO_PUBLIC_REVENUECAT_TEST_API_KEY;
464 const REVENUECAT_IOS_API_KEY = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY;
465 const REVENUECAT_ANDROID_API_KEY = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY;
466
467 export const REVENUECAT_ENTITLEMENT_IDENTIFIER = ""; // REPLIT AGENT: Use the entitlement identifier obtained from the seed script. e.g. "premium"
468
469 function getRevenueCatApiKey() {
470 if(!REVENUECAT_TEST_API_KEY || !REVENUECAT_IOS_API_KEY || !REVENUECAT_ANDROID_API_KEY) {
471 throw new Error("RevenueCat Public API Keys not found");
472 }
473
474 if(!REVENUECAT_ENTITLEMENT_IDENTIFIER) {
475 throw new Error("RevenueCat Entitlement Identifier not provided");
476 }
477
478 if (__DEV__ || Platform.OS === "web" || Constants.executionEnvironment === "storeClient") {
479 return REVENUECAT_TEST_API_KEY;
480 }
481
482 if (Platform.OS === "ios") {
483 return REVENUECAT_IOS_API_KEY;
484 }
485
486 if (Platform.OS === "android") {
487 return REVENUECAT_ANDROID_API_KEY;
488 }
489
490 return REVENUECAT_TEST_API_KEY;
491 }
492
493 export function initializeRevenueCat() {
494 const apiKey = getRevenueCatApiKey();
495 if (!apiKey) throw new Error("RevenueCat Public API Key not found");
496
497 Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG);
498 Purchases.configure({ apiKey });
499
500 console.log("Configured RevenueCat");
501 }
502
503 function useSubscriptionContext() {
504 const customerInfoQuery = useQuery({
505 queryKey: ["revenuecat", "customer-info"],
506 queryFn: async () => {
507 const info = await Purchases.getCustomerInfo();
508 return info;
509 },
510 staleTime: 60 * 1000,
511 });
512
513 const offeringsQuery = useQuery({
514 queryKey: ["revenuecat", "offerings"],
515 queryFn: async () => {
516 const offerings = await Purchases.getOfferings();
517 return offerings;
518 },
519 staleTime: 300 * 1000,
520 });
521
522 const purchaseMutation = useMutation({
523 mutationFn: async (packageToPurchase: any) => {
524 const { customerInfo } = await Purchases.purchasePackage(packageToPurchase);
525 return customerInfo;
526 },
527 onSuccess: () => customerInfoQuery.refetch(),
528 });
529
530 const restoreMutation = useMutation({
531 mutationFn: async () => {
532 return Purchases.restorePurchases();
533 },
534 onSuccess: () => customerInfoQuery.refetch(),
535 });
536
537 const isSubscribed = customerInfoQuery.data?.entitlements.active?.[REVENUECAT_ENTITLEMENT_IDENTIFIER] !== undefined;
538
539 return {
540 customerInfo: customerInfoQuery.data,
541 offerings: offeringsQuery.data,
542 isSubscribed,
543 isLoading: customerInfoQuery.isLoading || offeringsQuery.isLoading,
544 purchase: purchaseMutation.mutateAsync,
545 restore: restoreMutation.mutateAsync,
546 isPurchasing: purchaseMutation.isPending,
547 isRestoring: restoreMutation.isPending,
548 };
549 }
550
551 type SubscriptionContextValue = ReturnType<typeof useSubscriptionContext>;
552 const Context = createContext<SubscriptionContextValue | null>(null);
553
554 export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
555 const value = useSubscriptionContext();
556 return <Context.Provider value={value}>{children}</Context.Provider>;
557 }
558
559 export function useSubscription() {
560 const ctx = useContext(Context);
561 if (!ctx) {
562 throw new Error("useSubscription must be used within a SubscriptionProvider");
563 }
564 return ctx;
565 }
566 ```