Revenuecat
Skill Ampli-Group/agentic-mobile-blueprint/.agents/skills/revenuecat
The production-ready foundation for shipping mobile and web apps. Auth, deployment, monitoring, and CI/CD — already wired together.
npx -y skills add Ampli-Group/agentic-mobile-blueprint --skill revenuecatAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Integrate RevenueCat for in-app purchases — consumable credit packs or subscriptions. Use when setting up RevenueCat, creating products in App Store Connect / Google Play, configuring the SDK, building a paywall, or handling purchase webhooks in Supabase Edge Functions.
SKILL.md
18.9 KB, as published. Nobody here has run it
RevenueCat Integration
Architecture
App Store Connect / Google Play ←→ RevenueCat Dashboard
↓ purchase events
RevenueCat Webhook → Supabase Edge Function → profiles.credits
↓ SDK
RevenueCatContext (React) → PaywallTrigger (root Modal)
RevenueCat is the source of truth for purchases. Supabase is the source of truth for credits. They stay in sync via webhook.
Step 1: Create Products in App Store Connect (iOS)
- appstoreconnect.apple.com → My Apps → your app
- Left sidebar: Monetization → In-App Purchases → +
- Type: Consumable → Create
- For each product:
- Product ID:
credits_50/credits_150(must match exactly across all platforms) - Reference Name:
50 Credits/150 Credits - Price: set your price tier
- Add at least one Localization (English)
- Review Information: upload a screenshot of the paywall (required by Apple — mockup is fine)
- Save
- Product ID:
Products will show "Missing Metadata" until your next app submission — this is normal. RevenueCat can read them immediately.
Step 2: Create Products in Google Play Console (Android)
- play.google.com/console → your app
- Left sidebar: Monetize → In-app products → Create product
- For each product:
- Product ID:
credits_50/credits_150 - Name and Description
- Default price
- Status: Active ← required, draft products don't work
- Product ID:
- Save
Note: Google Play products only work after the app has at least one published release (even internal testing). Purchases will fail in development until then.
Step 3: Set Up RevenueCat Dashboard
Create project + add iOS app
-
app.revenuecat.com → + New Project
-
+ Add App → App Store
-
Bundle ID: your
ios.bundleIdentifier -
In-App Purchase key (.p8): this is NOT the App Store Connect API key
Critical distinction:
Key type Filename Used for In-App Purchase key SubscriptionKey_XXXXXXXXXX.p8RevenueCat ← use this App Store Connect API key AuthKey_XXXXXXXXXX.p8CI/CD, EAS Submit To get the right key:
- App Store Connect → Users and Access → Integrations
- Under In-App Purchase → + → Name it
RevenueCat→ Download - File will be named
SubscriptionKey_…p8— upload this to RevenueCat
Add Android app
- + Add App → Play Store
- Package name: your
android.package - Service Account credentials JSON: same service account used for Play Console API access (see google-play-setup skill)
- Play Console → Setup → API access → linked Google Cloud project
- Create service account → grant Release Manager in Play Console
- Download JSON → upload to RevenueCat
Add Products
For each app (iOS + Android):
- Products tab → + New Product
- Enter Product IDs:
credits_50,credits_150 - Type: Consumable
Create Entitlement
- Entitlements tab → + New Entitlement
- Identifier:
credits - Attach both products
- Save
Create Offering
- Offerings tab → + New Offering
- Identifier:
default - Add 2 packages: attach iOS + Android products for each credit pack
- Set as Current
Configure Webhook
- Integrations → Webhooks → + Add webhook
- URL:
https://YOUR_PROJECT.supabase.co/functions/v1/revenuecat-webhook - Events:
INITIAL_PURCHASE,NON_SUBSCRIPTION_PURCHASE,NON_RENEWING_PURCHASE - Copy the Webhook Shared Secret → add to Supabase secrets
Get SDK Keys
- Project Settings → API Keys
- Copy iOS Public SDK Key (
appl_...) - Copy Android Public SDK Key (
goog_...)
Step 4: Install SDK
cd mobile
npx expo install react-native-purchases react-native-purchases-ui
Step 5: Environment Variables
Separate keys per platform — app.config.js picks the right one at build time:
# mobile/.env.local
EXPO_PUBLIC_REVENUECAT_IOS_KEY=appl_...
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=goog_...
# supabase/functions/.env.local
REVENUECAT_WEBHOOK_SECRET=whsec_...
mobile/app.config.js:
const isIos = process.env.EAS_BUILD_PLATFORM === 'ios';
const revenueCatApiKey = isIos
? process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY
: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_KEY;
export default {
expo: {
extra: { revenueCatApiKey },
},
};
Add to eas.json build profiles:
{
"build": {
"production": {
"env": {
"EXPO_PUBLIC_REVENUECAT_IOS_KEY": "appl_...",
"EXPO_PUBLIC_REVENUECAT_ANDROID_KEY": "goog_..."
}
}
}
}
Step 6: RevenueCatContext
Critical architecture: RevenueCatUI.presentPaywall() must be called from the root view controller, never from inside a React Native <Modal>. Calling it from a modal causes iOS to throw "not in window hierarchy". The solution: queue the request via a promise, resolve it from a root-level <PaywallTrigger> component.
// contexts/RevenueCatContext.tsx
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Alert, LogBox, Modal, StyleSheet, View } from 'react-native';
import Constants from 'expo-constants';
import Purchases, {
CustomerInfo, CustomerInfoUpdateListener, LOG_LEVEL,
PurchasesOffering, PurchasesOfferings,
} from 'react-native-purchases';
import RevenueCatUI from 'react-native-purchases-ui';
import { useAuth } from './AuthContext';
const REVENUECAT_API_KEY =
(Constants.expoConfig?.extra?.revenueCatApiKey as string) ||
process.env.EXPO_PUBLIC_REVENUECAT_API_KEY || '';
if (__DEV__) {
LogBox.ignoreLogs([
'Purchase failure simulated successfully',
'[RevenueCat] [Test Store]',
]);
}
interface RevenueCatContextType {
isConfigured: boolean;
customerInfo: CustomerInfo | null;
offerings: PurchasesOfferings | null;
presentPaywall: () => Promise<boolean>;
presentCustomerCenter: () => Promise<void>;
restorePurchases: () => Promise<CustomerInfo>;
_paywallPending: boolean;
_pendingOffering: PurchasesOffering | null;
_resolvePaywall: (purchased: boolean) => void;
}
const RevenueCatContext = createContext<RevenueCatContextType | undefined>(undefined);
export function RevenueCatProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const [isConfigured, setIsConfigured] = useState(false);
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
const [offerings, setOfferings] = useState<PurchasesOfferings | null>(null);
const [paywallPending, setPaywallPending] = useState(false);
const [pendingOffering, setPendingOffering] = useState<PurchasesOffering | null>(null);
const paywallResolveRef = useRef<((purchased: boolean) => void) | null>(null);
// Configure SDK and register listener together on mount
useEffect(() => {
if (!REVENUECAT_API_KEY) {
console.warn('[RevenueCat] API key not set — SDK not configured');
return;
}
const configure = async () => {
try {
if (__DEV__) Purchases.setLogLevel(LOG_LEVEL.DEBUG);
Purchases.configure({ apiKey: REVENUECAT_API_KEY, appUserID: user?.id ?? null });
const listener: CustomerInfoUpdateListener = (info) => setCustomerInfo(info);
Purchases.addCustomerInfoUpdateListener(listener);
setIsConfigured(true);
const [info, offs] = await Promise.all([Purchases.getCustomerInfo(), Purchases.getOfferings()]);
setCustomerInfo(info);
setOfferings(offs);
return listener;
} catch (e) {
console.error('[RevenueCat] configure error:', e);
return null;
}
};
let listenerRef: CustomerInfoUpdateListener | null = null;
configure().then((ref) => { listenerRef = ref; });
return () => { if (listenerRef) Purchases.removeCustomerInfoUpdateListener(listenerRef); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync Supabase user with RevenueCat
useEffect(() => {
if (!isConfigured) return;
const sync = async () => {
try {
if (user?.id) {
const { customerInfo: info } = await Purchases.logIn(user.id);
setCustomerInfo(info);
// Consume any stuck unconsumed Google Play purchases — prevents "You already own this item" errors
await Purchases.syncPurchasesForResult();
} else {
const isAnon = await Purchases.isAnonymous();
if (!isAnon) { const info = await Purchases.logOut(); setCustomerInfo(info); }
}
} catch (e) { console.error('[RevenueCat] user sync error:', e); }
};
sync();
}, [user?.id, isConfigured]);
const presentPaywall = useCallback(async (): Promise<boolean> => {
try {
const offs = await Purchases.getOfferings();
const hasPackages = offs.current?.availablePackages.length ?? 0 > 0;
if (!hasPackages) {
Alert.alert('Not available', 'In-app purchases are not available yet.');
return false;
}
return new Promise<boolean>((resolve) => {
paywallResolveRef.current = resolve;
setPendingOffering(offs.current);
setPaywallPending(true);
});
} catch (e) {
console.error('[RevenueCat] getOfferings error:', e);
return false;
}
}, []);
const resolvePaywall = useCallback((purchased: boolean) => {
setPaywallPending(false);
setPendingOffering(null);
paywallResolveRef.current?.(purchased);
paywallResolveRef.current = null;
}, []);
return (
<RevenueCatContext.Provider value={{
isConfigured, customerInfo, offerings, presentPaywall,
presentCustomerCenter: async () => { try { await RevenueCatUI.presentCustomerCenter(); } catch(e) {} },
restorePurchases: async () => { const info = await Purchases.restorePurchases(); setCustomerInfo(info); return info; },
_paywallPending: paywallPending, _pendingOffering: pendingOffering, _resolvePaywall: resolvePaywall,
}}>
{children}
</RevenueCatContext.Provider>
);
}
const NOOP: RevenueCatContextType = {
isConfigured: false, customerInfo: null, offerings: null,
presentPaywall: async () => false, presentCustomerCenter: async () => {},
restorePurchases: async () => { throw new Error('RevenueCat not available'); },
_paywallPending: false, _pendingOffering: null, _resolvePaywall: () => {},
};
export function useRevenueCat() {
return useContext(RevenueCatContext) ?? NOOP;
}
/**
* Mount ONCE at the root layout, outside any other Modal.
* Never use visible={false} — on Android Fabric this causes "child already has a parent" crashes.
* Unmount entirely when not needed instead.
*/
export function PaywallTrigger() {
const { _paywallPending, _pendingOffering, _resolvePaywall } = useRevenueCat();
const handleClose = useCallback(async (purchased: boolean) => {
try {
if (purchased) await Purchases.syncPurchasesForResult();
} catch(e) { console.warn('[RevenueCat] paywall close sync error:', e); }
finally { _resolvePaywall(purchased); }
}, [_resolvePaywall]);
if (!_paywallPending) return null;
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={() => handleClose(false)}>
<View style={{ flex: 1 }}>
<RevenueCatUI.Paywall
options={_pendingOffering ? { offering: _pendingOffering } : undefined}
onDismiss={() => handleClose(false)}
onPurchaseCompleted={() => handleClose(true)}
onRestoreCompleted={() => handleClose(true)}
onRestoreError={() => handleClose(false)}
onPurchaseCancelled={() => handleClose(false)}
onPurchaseError={() => handleClose(false)}
/>
</View>
</Modal>
);
}
Step 7: Wire into Root Layout
// app/_layout.tsx
import { RevenueCatProvider, PaywallTrigger } from '@/contexts/RevenueCatContext';
export default function RootLayout() {
return (
<RevenueCatProvider>
<Stack />
<PaywallTrigger /> {/* must be here — outside any Modal */}
</RevenueCatProvider>
);
}
Step 8: Trigger Paywall from Anywhere
const { presentPaywall } = useRevenueCat();
// On insufficient credits error from your backend (HTTP 402)
const purchased = await presentPaywall();
if (purchased) {
// Credits updated automatically via Supabase Realtime on profiles.credits
// Retry the action
}
Step 9: Supabase Webhook Edge Function
// supabase/functions/revenuecat-webhook/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
const CREDITS_BY_PRODUCT: Record<string, number> = {
credits_50: 50,
credits_150: 150,
};
const PURCHASE_EVENTS = new Set([
'INITIAL_PURCHASE',
'NON_SUBSCRIPTION_PURCHASE',
'NON_RENEWING_PURCHASE',
]);
Deno.serve(async (req) => {
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
// Verify shared secret
const secret = Deno.env.get('REVENUECAT_WEBHOOK_SECRET');
if (secret && req.headers.get('Authorization') !== secret) {
return new Response('Unauthorized', { status: 401 });
}
const body = await req.json();
const event = body.event;
if (!PURCHASE_EVENTS.has(event?.type)) {
return new Response(JSON.stringify({ received: true, processed: false }), { status: 200 });
}
const appUserId = event.app_user_id;
const creditsToAdd = CREDITS_BY_PRODUCT[event.product_id];
if (!creditsToAdd) {
console.warn(`Unknown product_id: ${event.product_id}`);
return new Response(JSON.stringify({ received: true, processed: false }), { status: 200 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Atomic credit increment — use RPC to avoid race conditions
const { error } = await supabase.rpc('add_credits', {
p_user_id: appUserId,
p_amount: creditsToAdd,
p_admin_note: `RevenueCat: ${event.product_id}`,
});
if (error) {
console.error('Failed to add credits:', error);
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
}
return new Response(JSON.stringify({ received: true, processed: true, credits_added: creditsToAdd }), { status: 200 });
});
Disable JWT verification for this function in supabase/config.toml:
[functions.revenuecat-webhook]
verify_jwt = false
Step 10: Database Schema
-- Credits on profiles
alter table profiles add column if not exists credits integer not null default 100 check (credits >= 0);
-- Audit log
create table credit_transactions (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users on delete cascade not null,
amount integer not null,
balance_after integer not null,
action_type text not null,
metadata jsonb default '{}',
created_at timestamptz default now()
);
-- Atomic increment RPC (avoids race conditions)
create or replace function add_credits(p_user_id uuid, p_amount integer, p_admin_note text default null)
returns integer as $$
declare v_new_balance integer;
begin
select credits + p_amount into v_new_balance from profiles where id = p_user_id for update;
update profiles set credits = v_new_balance, credits_updated_at = now() where id = p_user_id;
insert into credit_transactions (user_id, amount, balance_after, action_type, metadata)
values (p_user_id, p_amount, v_new_balance, 'purchase', jsonb_build_object('note', p_admin_note));
return v_new_balance;
end;
$$ language plpgsql security definer;
-- Enable Realtime on profiles so the app reacts instantly after webhook credits the user
alter publication supabase_realtime add table profiles;
Listen for credit updates in the app:
supabase.channel('profile-credits')
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'profiles', filter: `id=eq.${userId}` },
(payload) => setCredits(payload.new.credits))
.subscribe();
Setup Checklist
- App Store Connect: consumable products created with correct Product IDs
- Google Play: in-app products created and set to Active
- RevenueCat: project created
- RevenueCat: iOS app added with In-App Purchase key (
SubscriptionKey_*.p8, notAuthKey_*.p8) - RevenueCat: Android app added with Google Play service account JSON
- RevenueCat: all products added (for both platforms)
- RevenueCat: entitlement
creditscreated with products attached - RevenueCat: offering
defaultcreated and set as Current - RevenueCat: webhook configured pointing to Supabase edge function
-
REVENUECAT_WEBHOOK_SECRETadded to Supabase secrets - iOS + Android SDK keys added to
.env.localandeas.json
Gotchas
Wrong .p8 key type for RevenueCat — RevenueCat requires the In-App Purchase key (SubscriptionKey_*.p8), not the App Store Connect API key (AuthKey_*.p8). They look identical but are created in different sections of App Store Connect. Create it at: App Store Connect → Users and Access → Integrations → In-App Purchase.
"You already own this item" on Android — Google Play consumables must be "consumed" before they can be repurchased. Call Purchases.syncPurchasesForResult() after login and after paywall close to consume any stuck purchases. The RevenueCatContext above handles this.
PaywallTrigger causes iOS crash: "not in window hierarchy" — The paywall must be presented from root context, never from inside a <Modal>. Use the queue pattern above: presentPaywall() queues the request, PaywallTrigger (at root) presents it.
Android Fabric: "child already has a parent" crash — Never render <Modal visible={false}>. Unmount the component entirely with if (!_paywallPending) return null instead of toggling visible.
Google Play products not working in development — Products are only purchasable after the app has at least one published release (internal testing counts). Use RevenueCat's sandbox testing until then.
Product IDs must be identical across all three platforms — App Store Connect, Google Play, and RevenueCat dashboard must all use exactly the same string: credits_50, credits_150. A single character difference breaks the mapping.
Webhook not triggering — Check RevenueCat dashboard → Integrations → Webhooks → Recent deliveries for errors. Common causes: wrong URL, missing verify_jwt = false in supabase/config.toml, or webhook secret mismatch.
Credits not updating in real-time — Supabase Realtime must be enabled on the profiles table (alter publication supabase_realtime add table profiles). Without this, the credit balance only updates on next app launch.