Install
$ agentstack add skill-ampli-group-agentic-mobile-blueprint-revenuecat ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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
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
- 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.p8 | RevenueCat ← use this | | App Store Connect API key | AuthKey_XXXXXXXXXX.p8 | CI/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 `. 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 ` 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;
presentCustomerCenter: () => Promise;
restorePurchases: () => Promise;
_paywallPending: boolean;
_pendingOffering: PurchasesOffering | null;
_resolvePaywall: (purchased: boolean) => void;
}
const RevenueCatContext = createContext(undefined);
export function RevenueCatProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const [isConfigured, setIsConfigured] = useState(false);
const [customerInfo, setCustomerInfo] = useState(null);
const [offerings, setOfferings] = useState(null);
const [paywallPending, setPaywallPending] = useState(false);
const [pendingOffering, setPendingOffering] = useState(null);
const paywallResolveRef = useRef 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 => {
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((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 (
{ try { await RevenueCatUI.presentCustomerCenter(); } catch(e) {} },
restorePurchases: async () => { const info = await Purchases.restorePurchases(); setCustomerInfo(info); return info; },
_paywallPending: paywallPending, _pendingOffering: pendingOffering, _resolvePaywall: resolvePaywall,
}}>
{children}
);
}
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 (
handleClose(false)}>
handleClose(false)}
onPurchaseCompleted={() => handleClose(true)}
onRestoreCompleted={() => handleClose(true)}
onRestoreError={() => handleClose(false)}
onPurchaseCancelled={() => handleClose(false)}
onPurchaseError={() => handleClose(false)}
/>
);
}
Step 7: Wire into Root Layout
// app/_layout.tsx
import { RevenueCatProvider, PaywallTrigger } from '@/contexts/RevenueCatContext';
export default function RootLayout() {
return (
{/* must be here — outside any Modal */}
);
}
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 = {
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
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Ampli-Group
- Source: Ampli-Group/agentic-mobile-blueprint
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.