AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Revenuecat

skill-ampli-group-agentic-mobile-blueprint-revenuecat · by Ampli-Group

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.

No reviews yet
0 installs
34 views
0.0% view→install

Install

$ agentstack add skill-ampli-group-agentic-mobile-blueprint-revenuecat

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-ampli-group-agentic-mobile-blueprint-revenuecat)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

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 →
Are you the author of Revenuecat? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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)

  1. appstoreconnect.apple.com → My Apps → your app
  2. Left sidebar: Monetization → In-App Purchases+
  3. Type: Consumable → Create
  4. 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)

  1. play.google.com/console → your app
  2. Left sidebar: Monetize → In-app productsCreate product
  3. For each product:
  • Product ID: credits_50 / credits_150
  • Name and Description
  • Default price
  • Status: Active ← required, draft products don't work
  1. 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

  1. app.revenuecat.com+ New Project
  2. + Add App → App Store
  3. Bundle ID: your ios.bundleIdentifier
  4. 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 AccessIntegrations
  • Under In-App Purchase+ → Name it RevenueCat → Download
  • File will be named SubscriptionKey_…p8 — upload this to RevenueCat

Add Android app

  1. + Add App → Play Store
  2. Package name: your android.package
  3. 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, not AuthKey_*.p8)
  • [ ] RevenueCat: Android app added with Google Play service account JSON
  • [ ] RevenueCat: all products added (for both platforms)
  • [ ] RevenueCat: entitlement credits created 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.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.