Purchase a specific product directly using RevenueCat through Apple App Store and Google Play billing - without launching a full paywall UI.
AI Prompt
Add native in-app purchase functionality to my app using the Despia SDK from: https://www.npmjs.com/package/despia-nativeFirst, install the package: npm install despia-nativeThen import it: import despia from 'despia-native'Create a payment system that uses RevenueCat URLs like: despia("revenuecat://purchase?external_id={USER_ID}&product={PRODUCT_ID}") to handle purchases through Apple App Store and Google Play billing.Detect the platform using the Despia user agent and use the correct product identifier for each store. If not running in Despia (i.e. the user is on your web app), fall back to a RevenueCat Web Purchase Link:
Use platform-specific product IDs. On Android, subscription product IDs must be prefixed with the subscription group name as configured in Google Play billing:
iOS: monthly_premium_ios
Android: premium:monthly_premium_android (group name premium + : + product ID)
For the web fallback, redirect to a RevenueCat Web Purchase Link with the user’s ID appended:
Copy
Ask AI
if (isDespiaIOS) { despia(`revenuecat://purchase?external_id=${userId}&product=monthly_premium_ios`);} else if (isDespiaAndroid) { despia(`revenuecat://purchase?external_id=${userId}&product=premium:monthly_premium_android`);} else { // Web - redirect to RevenueCat Web Purchase Link window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`;}
Always append the user’s ID to the RevenueCat Web Purchase Link URL. This links the web payment directly to their account - so if they later open your mobile app, the purchase and entitlements are already there waiting for them.
In RevenueCat, create a single entitlement (e.g. "premium") and link both the iOS and Android products to it. This means entitlementId will be "premium" for all platforms - check with p.entitlementId === "premium". For one-time purchases, match the exact entitlementId (e.g. "no_ads").
The Despia Native Runtime will call the global function onRevenueCatPurchase() when an in-app purchase or subscription is successfully made on the client side. Although this should not grant access immediately, it’s a good time to start polling your backend to check if the RevenueCat webhook has already updated the user’s status or plan permissions.
This feature requires native capabilities which will be fully provided by the “despia-native” npm package, no additional native libraries are needed!
Payment confirmation will happen via RevenueCat’s Webhooks, so make sure to include or instruct me to set up the correct backend structure.You can find the documentation about this here:https://www.revenuecat.com/docs/integrations/webhooks
Once the payment is confirmed on the backend, the backend should send a webhook to the frontend to let it know that this user’s in-app purchase session was completed.Also add a Restore Purchases button that calls: despia("getpurchasehistory://", ["restoredData"])This will query the native app stores and return all purchases associated with the current device/account, including active subscriptions, expired subscriptions, and one-time purchases.The response array includes objects with:
transactionId - unique identifier for this specific transaction
originalTransactionId - identifier linking to the original purchase (useful for subscription renewals)
productId - the product identifier configured in App Store Connect / Google Play Console
type - either "subscription" or "product" (one-time purchase)
entitlementId - the entitlement/access level this purchase grants
isActive - boolean indicating if the purchase currently grants access
willRenew - boolean indicating if a subscription will auto-renew
purchaseDate - ISO timestamp of the most recent transaction
originalPurchaseDate - ISO timestamp of the initial purchase
expirationDate - ISO timestamp when access expires (null for lifetime purchases)
store - either "app_store" or "play_store"
country - user’s country code
environment - "production" or "sandbox"
receipt - the raw receipt data for server-side validation
Use the restore response to check active entitlements and re-grant access where appropriate. Here is an example of how to check if the user has an active "premium" entitlement:
Copy
Ask AI
const data = await despia("getpurchasehistory://", ["restoredData"]);const purchases = data.restoredData;// Filter for active purchases onlyconst activePurchases = purchases.filter(p => p.isActive);// Check if user has premium accessconst hasPremium = activePurchases.some(p => p.entitlementId === "premium");if (hasPremium) { // Grant premium features}
Please follow the installation instructions for the “despia-native” npm package closely, and do not modify my instructions. Implementation as mentioned is critical.
How it Works: Despia acts as a bridge between your app and native mobile payment systems. When a user taps a purchase button, Despia triggers the native RevenueCat purchase flow for the specified product - directly through Apple’s App Store or Google Play Store - without opening a full paywall UI. The purchase flow handles secure transactions and automatic subscription management.
Always check for Despia first before attempting native purchases. If not running in Despia (e.g. the user is on your web app), fall back to a RevenueCat Web Purchase Link instead.
Copy
Ask AI
// Detect if running in Despiaconst isDespia = navigator.userAgent.toLowerCase().includes('despia');// Detect iOS in Despiaconst isDespiaIOS = isDespia && (navigator.userAgent.toLowerCase().includes('iphone') || navigator.userAgent.toLowerCase().includes('ipad'));// Detect Android in Despiaconst isDespiaAndroid = isDespia && navigator.userAgent.toLowerCase().includes('android');function handleUpgrade() { if (isDespiaIOS) { // Native iOS purchase - plain product ID despia(`revenuecat://purchase?external_id=${userId}&product=monthly_premium_ios`); } else if (isDespiaAndroid) { // Native Android purchase - prefixed with Play subscription group: group:productId despia(`revenuecat://purchase?external_id=${userId}&product=premium:monthly_premium_android`); } else { // Web fallback - redirect to RevenueCat Web Purchase Link // Append the user's ID so the purchase is linked to their account // and entitlements are available in the mobile app too window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`; }}
Always include the user’s ID in the Web Purchase Link URL. This ties the web payment to their account so entitlements are instantly available across both your web app and mobile app - no extra steps needed for the user.
All URL parameters should be URL encoded. Web Purchase Links support the following optional parameters:Email - Pre-fills the email field on the checkout page (cannot be overridden by the user):
The app_user_id in the URL is what RevenueCat includes in every webhook event. The user must already exist in your database before you redirect them to the Web Purchase Link - otherwise when the INITIAL_PURCHASE webhook fires, your backend won’t find the user and won’t be able to grant access. Always ensure the user is created in your DB first.
When the purchase completes, RevenueCat fires a webhook event to your backend containing the email. You can store it against the user’s account at that point - and since the purchase is linked to the same app_user_id, users who are logged in on mobile will have full access to their entitlements in the app as well.Currency - Override automatic currency selection:
Copy
Ask AI
https://pay.rev.cat/<token>/<userId>?currency=EUR
Package ID - Pre-select a specific package and skip straight to checkout:
### 3. Handle the Purchase CallbackThe Despia Native Runtime calls `onRevenueCatPurchase()` once the store confirms the transaction. Register this handler globally before triggering any purchase:```javascriptwindow.onRevenueCatPurchase = async () => { // Do not grant access yet - poll your backend until the webhook confirms const verified = await pollForPurchaseVerification(userId); if (verified) { setIsPremium(true); showSuccessMessage('Purchase complete!'); }};
Never grant access based on the client-side callback alone. Always wait for your backend to confirm the purchase via the RevenueCat webhook before unlocking features.
If you don’t have a backend, you can use getpurchasehistory:// as your source of truth - both to confirm a purchase right after it completes, and to check entitlements anywhere in your app before gating a feature.
getpurchasehistory:// queries the native store directly, reflecting the real-time entitlement state from Apple or Google. It’s reliable for client-side access checks with no backend required.
Copy
Ask AI
import despia from 'despia-native';// ─── Platform detection ───────────────────────────────────────────────────────// Always check for Despia first - if not in Despia, the user is on webconst isDespia = navigator.userAgent.toLowerCase().includes('despia');const isDespiaIOS = isDespia && ( navigator.userAgent.toLowerCase().includes('iphone') || navigator.userAgent.toLowerCase().includes('ipad'));const isDespiaAndroid = isDespia && navigator.userAgent.toLowerCase().includes('android');// In RevenueCat, create one "premium" entitlement and link both iOS and Android// products to it. entitlementId will be "premium" on both platforms.// Product ID format:// iOS: "monthly_premium_ios" (plain)// Android: "premium:monthly_premium_android" (Google Play group:productId format)const PRODUCTS = { premiumMonthly: isDespiaIOS ? "monthly_premium_ios" : "premium:monthly_premium_android", premiumAnnual: isDespiaIOS ? "annual_premium_ios" : "premium:annual_premium_android", removeAds: "remove_ads",};// ─── Entitlement helpers ──────────────────────────────────────────────────────async function getPurchases() { const data = await despia("getpurchasehistory://", ["restoredData"]); return data.restoredData ?? [];}// Both iOS and Android resolve to entitlementId "premium" via RevenueCat entitlementsasync function hasPremiumEntitlement() { const purchases = await getPurchases(); return purchases.some(p => p.isActive && p.entitlementId === "premium");}// One-time purchases use an exact entitlementId matchasync function hasEntitlement(entitlementId) { const purchases = await getPurchases(); return purchases.some(p => p.isActive && p.entitlementId === entitlementId);}// ─── On app load: gate features ───────────────────────────────────────────────if (await hasPremiumEntitlement()) { unlockPremiumFeatures();}if (await hasEntitlement("no_ads")) { removeAds();}// ─── Purchase callback: confirm via restore, then show success ────────────────window.onRevenueCatPurchase = async () => { const purchases = await getPurchases(); const latest = purchases.find(p => p.isActive); if (!latest) return; if (latest.entitlementId === "premium") { unlockPremiumFeatures(); alert(" Welcome to Premium! All features are now unlocked."); } else if (latest.entitlementId === "no_ads") { removeAds(); alert(" Ads removed! Thanks for your purchase."); }};// ─── Purchase buttons ─────────────────────────────────────────────────────────function handleUpgradePremium() { if (isDespia) { despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.premiumMonthly}`); } else { // Web fallback - RevenueCat Web Purchase Link window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`; }}function handleRemoveAds() { if (isDespia) { despia(`revenuecat://purchase?external_id=${userId}&product=${PRODUCTS.removeAds}`); } else { window.location.href = `https://pay.rev.cat/<your_token>/${encodeURIComponent(userId)}`; }}// ─── Restore button (required by App Store guidelines) ───────────────────────async function handleRestore() { const purchases = await getPurchases(); const active = purchases.filter(p => p.isActive); if (active.length === 0) { alert("No active purchases found."); return; } if (active.some(p => p.entitlementId.startsWith("premium"))) { unlockPremiumFeatures(); } if (active.some(p => p.entitlementId === "no_ads")) { removeAds(); } alert(" Purchases restored!");}
Call hasPremiumEntitlement() or hasEntitlement() anywhere you need to gate a feature - on page load, before opening premium content, or when the user navigates to a restricted section. No server required.
Create a POST /webhooks/revenuecat endpoint. RevenueCat sends an Authorization header with a secret you define - just make up a random string and store it as an environment variable.
Some backends like Supabase Edge Functions or Convex apply their own authentication middleware by default, which will block incoming RevenueCat requests before your code runs. Make sure to disable the default auth middleware on this endpoint so RevenueCat’s Authorization: Bearer token can reach your handler and be validated there.
Copy
Ask AI
const WEBHOOK_SECRET = process.env.REVENUECAT_WEBHOOK_SECRET; // e.g. "xK9mP2qRt7vL"export async function POST(req) { // 1. Validate the Authorization header const auth = req.headers.get('authorization') ?? ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth; if (token !== WEBHOOK_SECRET) { return new Response('Unauthorized', { status: 401 }); } // 2. Parse the event const body = await req.json(); const event = body.event; if (!event) return new Response('Bad Request', { status: 400 }); const { type, app_user_id, environment } = event; // 3. Handle sandbox events - skip unless user is a tester // Mark users as testers in your DB (e.g. is_tester: true) so they can // verify the full purchase flow in sandbox without affecting real users if (environment === 'SANDBOX') { const user = await db.findOne('users', { app_user_id }); if (!user?.is_tester) { return new Response('OK', { status: 200 }); } // User is a tester - fall through and process as if production } // 4. Extract entitlements - RevenueCat sends them as: // { "premium": { "expires_date": "2025-02-15T...", "product_identifier": "..." } } const entitlements = event.entitlements ?? {}; const isActive = Object.values(entitlements).some(ent => !ent.expires_date || new Date(ent.expires_date) > new Date() ); // 5. Handle the event type switch (type) { case 'INITIAL_PURCHASE': case 'RENEWAL': case 'UNCANCELLATION': case 'SUBSCRIPTION_EXTENDED': // Grant access - update your database await db.upsert('user_subscriptions', { app_user_id, is_active: true, subscription_status: 'active', entitlements: JSON.stringify(entitlements), expires_at: event.expiration_at_ms ? new Date(event.expiration_at_ms) : null, product_id: event.product_id, store: event.store, last_webhook_event: type, last_updated: new Date(), }); break; case 'CANCELLATION': // Subscription cancelled but access continues until expiry await db.upsert('user_subscriptions', { app_user_id, is_active: true, // still active until expires_at subscription_status: 'cancelled', expires_at: event.expiration_at_ms ? new Date(event.expiration_at_ms) : null, last_webhook_event: type, last_updated: new Date(), }); break; case 'EXPIRATION': case 'REFUND': // Revoke access immediately await db.upsert('user_subscriptions', { app_user_id, is_active: false, subscription_status: 'expired', last_webhook_event: type, last_updated: new Date(), }); break; case 'BILLING_ISSUE': // Grace period - flag but keep active for now await db.upsert('user_subscriptions', { app_user_id, billing_issue: true, grace_period_expires_at: event.grace_period_expires_at_ms ? new Date(event.grace_period_expires_at_ms) : null, last_webhook_event: type, last_updated: new Date(), }); break; } return new Response('OK', { status: 200 });}
Webhooks can occasionally fail (server downtime, timeouts, etc.). A cron job that periodically syncs subscription status from the RevenueCat API is a reliable safety net - especially important for catching cancellations and expirations that your webhook might have missed.
Despia queries the native platform’s billing system to retrieve all purchases associated with the current user’s App Store or Google Play account. This includes active subscriptions, expired subscriptions, consumables, and non-consumable (lifetime) purchases. The data is normalized into a consistent format across both iOS and Android platforms.
const data = await despia("getpurchasehistory://", ["restoredData"]);const purchases = data.restoredData;// In RevenueCat, create a single "premium" entitlement and link both your iOS// and Android products to it. entitlementId will be "premium" on both platforms.const hasPremium = purchases.some(p => p.isActive && p.entitlementId === "premium");// One-time purchases use an exact matchconst hasNoAds = purchases.some(p => p.isActive && p.entitlementId === "no_ads");if (hasPremium) { // Grant premium features}
AI cannot test payment integrations for you. RevenueCat, native StoreKit/Google Billing, webhooks, and your backend are cross-platform processes that require real end-to-end testing with multiple accounts and devices. There is no shortcut here - a payment integration is one of the most critical parts of your app and must be thoroughly verified by you before going live.
Here’s what you should test manually:Web → Mobile
Log in to your web app and complete a purchase via the Web Purchase Link
Open the mobile app logged in with the same account
Verify entitlements are active and premium features are unlocked
Mobile → Web
Complete a purchase on the mobile app via TestFlight (no real money - use sandbox test accounts)
Open your web app logged in with the same account
Verify your backend has the correct subscription state
Webhooks
Go to RevenueCat Dashboard → Integrations → Webhooks
Check the event log - verify events are arriving at your server
Confirm your database is updating is_active, subscription_status, and expires_at correctly for each event type
Test EXPIRATION and CANCELLATION events to make sure access is revoked properly
Restore Purchases
Delete and reinstall the app
Tap the Restore Purchases button
Verify native IAP entitlements are recovered correctly
Payment integrations are complex and critical for most apps. We hope this documentation and our examples make it easier and provide helpful guidance - but a carefully and successfully tested setup by you is the only way to ship with confidence.