This plugin allows you to implement in-app purchases and subscriptions in your Capacitor app using native APIs.
npm install @capgo/native-purchases
npx cap sync
Add this to manifest
<uses-permission android:name="com.android.vending.BILLING" />
Import the plugin in your TypeScript file:
import { NativePurchases } from '@capgo/native-purchases';
Before attempting to make purchases, check if billing is supported on the device: We only support Storekit 2 on iOS (iOS 15+) and google play on Android
const checkBillingSupport = async () => {
try {
const { isBillingSupported } = await NativePurchases.isBillingSupported();
if (isBillingSupported) {
console.log('Billing is supported on this device');
} else {
console.log('Billing is not supported on this device');
}
} catch (error) {
console.error('Error checking billing support:', error);
}
};
Retrieve information about available products:
const getAvailableProducts = async () => {
try {
const { products } = await NativePurchases.getProducts({
productIdentifiers: ['product_id_1', 'product_id_2'],
productType: PURCHASE_TYPE.INAPP // or PURCHASE_TYPE.SUBS for subscriptions
});
console.log('Available products:', products);
} catch (error) {
console.error('Error getting products:', error);
}
};
To initiate a purchase:
const purchaseProduct = async (productId: string) => {
try {
const transaction = await NativePurchases.purchaseProduct({
productIdentifier: productId,
productType: PURCHASE_TYPE.INAPP // or PURCHASE_TYPE.SUBS for subscriptions
});
console.log('Purchase successful:', transaction);
// Handle the successful purchase (e.g., unlock content, update UI)
} catch (error) {
console.error('Purchase failed:', error);
}
};
To restore previously purchased products:
const restorePurchases = async () => {
try {
const { customerInfo } = await NativePurchases.restorePurchases();
console.log('Restored purchases:', customerInfo);
// Update your app's state based on the restored purchases
} catch (error) {
console.error('Failed to restore purchases:', error);
}
};
Here's a basic example of how you might implement a simple store in your app:
import { Capacitor } from '@capacitor/core';
import { NativePurchases, PURCHASE_TYPE, Product } from '@capgo/native-purchases';
class Store {
private products: Product[] = [];
async initialize() {
if (Capacitor.isNativePlatform()) {
try {
await this.checkBillingSupport();
await this.loadProducts();
} catch (error) {
console.error('Store initialization failed:', error);
}
}
}
private async checkBillingSupport() {
const { isBillingSupported } = await NativePurchases.isBillingSupported();
if (!isBillingSupported) {
throw new Error('Billing is not supported on this device');
}
}
private async loadProducts() {
const productIds = ['premium_subscription', 'remove_ads', 'coin_pack'];
const { products } = await NativePurchases.getProducts({
productIdentifiers: productIds,
productType: PURCHASE_TYPE.INAPP
});
this.products = products;
}
getProducts() {
return this.products;
}
async purchaseProduct(productId: string) {
try {
const transaction = await NativePurchases.purchaseProduct({
productIdentifier: productId,
productType: PURCHASE_TYPE.INAPP
});
console.log('Purchase successful:', transaction);
// Handle the successful purchase
return transaction;
} catch (error) {
console.error('Purchase failed:', error);
throw error;
}
}
async restorePurchases() {
try {
const { customerInfo } = await NativePurchases.restorePurchases();
console.log('Restored purchases:', customerInfo);
// Update app state based on restored purchases
return customerInfo;
} catch (error) {
console.error('Failed to restore purchases:', error);
throw error;
}
}
}
// Usage
const store = new Store();
await store.initialize();
// Display products
const products = store.getProducts();
console.log('Available products:', products);
// Purchase a product
try {
await store.purchaseProduct('premium_subscription');
console.log('Purchase completed successfully');
} catch (error) {
console.error('Purchase failed:', error);
}
// Restore purchases
try {
await store.restorePurchases();
console.log('Purchases restored successfully');
} catch (error) {
console.error('Failed to restore purchases:', error);
}
This example provides a basic structure for initializing the store, loading products, making purchases, and restoring previous purchases. You'll need to adapt this to fit your specific app's needs, handle UI updates, and implement proper error handling and user feedback.
It's crucial to validate receipts on your server to ensure the integrity of purchases. Here's an example of how to implement backend validation using a Cloudflare Worker:
Cloudflare Worker Setup
Create a new Cloudflare Worker and follow the instructions in folder (validator
)[/validator/README.md]
Then in your app, modify the purchase function to validate the receipt on the server:
import { Capacitor } from '@capacitor/core';
import { NativePurchases, PURCHASE_TYPE, Product, Transaction } from '@capgo/native-purchases';
import axios from 'axios'; // Make sure to install axios: npm install axios
class Store {
// ... (previous code remains the same)
async purchaseProduct(productId: string) {
try {
const transaction = await NativePurchases.purchaseProduct({
productIdentifier: productId,
productType: PURCHASE_TYPE.INAPP
});
console.log('Purchase successful:', transaction);
// Immediately grant access to the purchased content
await this.grantAccess(productId);
// Initiate server-side validation asynchronously
this.validatePurchaseOnServer(transaction).catch(console.error);
return transaction;
} catch (error) {
console.error('Purchase failed:', error);
throw error;
}
}
private async grantAccess(productId: string) {
// Implement logic to grant immediate access to the purchased content
console.log(`Granting access to ${productId}`);
// Update local app state, unlock features, etc.
}
private async validatePurchaseOnServer(transaction: Transaction) {
const serverUrl = 'https://your-server-url.com/validate-purchase';
try {
const response = await axios.post(serverUrl, {
transactionId: transaction.transactionId,
platform: Capacitor.getPlatform(),
// Include any other relevant information
});
console.log('Server validation response:', response.data);
// The server will handle the actual validation with the Cloudflare Worker
} catch (error) {
console.error('Error in server-side validation:', error);
// Implement retry logic or notify the user if necessary
}
}
}
// Usage remains the same
const store = new Store();
await store.initialize();
try {
await store.purchaseProduct('premium_subscription');
console.log('Purchase completed successfully');
} catch (error) {
console.error('Purchase failed:', error);
}
Now, let's look at how the server-side (Node.js) code might handle the validation:
import express from 'express';
import axios from 'axios';
const app = express();
app.use(express.json());
const CLOUDFLARE_WORKER_URL = 'https://your-cloudflare-worker-url.workers.dev';
app.post('/validate-purchase', async (req, res) => {
const { transactionId, platform } = req.body;
try {
const endpoint = platform === 'ios' ? '/apple' : '/google';
const validationResponse = await axios.post(`${CLOUDFLARE_WORKER_URL}${endpoint}`, {
receipt: transactionId
});
const validationResult = validationResponse.data;
// Process the validation result
if (validationResult.isValid) {
// Update user status in the database
// await updateUserStatus(userId, 'paid');
// Log the successful validation
console.log(`Purchase validated for transaction ${transactionId}`);
// You might want to store the validation result for future reference
// await storeValidationResult(userId, transactionId, validationResult);
} else {
// Handle invalid purchase
console.warn(`Invalid purchase detected for transaction ${transactionId}`);
// You might want to flag this for further investigation
// await flagSuspiciousPurchase(userId, transactionId);
}
// Always respond with a success to the app
// This ensures the app doesn't block the user's access
res.json({ success: true });
} catch (error) {
console.error('Error validating purchase:', error);
// Still respond with success to the app
res.json({ success: true });
// You might want to log this error or retry the validation later
// await logValidationError(userId, transactionId, error);
}
});
// Start the server
app.listen(3000, () => console.log('Server running on port 3000'));
Key points about this approach:
- The app immediately grants access after a successful purchase, ensuring a smooth user experience.
- The app initiates server-side validation asynchronously, not blocking the user's access.
- The server handles the actual validation by calling the Cloudflare Worker.
- The server always responds with success to the app, even if validation fails or encounters an error.
- The server can update the user's status in the database, log results, and handle any discrepancies without affecting the user's immediate experience.
Comments on best practices:
// After successful validation:
// await updateUserStatus(userId, 'paid');
// It's crucial to not block or revoke access immediately if validation fails
// Instead, flag suspicious transactions for review:
// if (!validationResult.isValid) {
// await flagSuspiciousPurchase(userId, transactionId);
// }
// Implement a system to periodically re-check flagged purchases
// This could be a separate process that runs daily/weekly
// Consider implementing a grace period for new purchases
// This allows for potential delays in server communication or store processing
// const GRACE_PERIOD_DAYS = 3;
// if (daysSincePurchase < GRACE_PERIOD_DAYS) {
// grantAccess = true;
// }
// For subscriptions, regularly check their status with the stores
// This ensures you catch any cancelled or expired subscriptions
// setInterval(checkSubscriptionStatuses, 24 * 60 * 60 * 1000); // Daily check
// Implement proper error handling and retry logic for network failures
// This is especially important for the server-to-Cloudflare communication
// Consider caching validation results to reduce load on your server and the stores
// const cachedValidation = await getCachedValidation(transactionId);
// if (cachedValidation) return cachedValidation;
This approach balances immediate user gratification with proper server-side validation, adhering to Apple and Google's guidelines while still maintaining the integrity of your purchase system.
restorePurchases()
purchaseProduct(...)
getProducts(...)
getProduct(...)
isBillingSupported()
getPluginVersion()
- Interfaces
- Enums
restorePurchases() => Promise<{ customerInfo: CustomerInfo; }>
Restores a user's previous and links their appUserIDs to any user's also using those .
Returns: Promise<{ customerInfo: CustomerInfo; }>
purchaseProduct(options: { productIdentifier: string; planIdentifier?: string; productType?: PURCHASE_TYPE; quantity?: number; }) => Promise<Transaction>
Started purchase process for the given product.
Param | Type | Description |
---|---|---|
options |
{ productIdentifier: string; planIdentifier?: string; productType?: PURCHASE_TYPE; quantity?: number; } |
- The product to purchase |
Returns: Promise<Transaction>
getProducts(options: { productIdentifiers: string[]; productType?: PURCHASE_TYPE; }) => Promise<{ products: Product[]; }>
Gets the product info associated with a list of product identifiers.
Param | Type | Description |
---|---|---|
options |
{ productIdentifiers: string[]; productType?: PURCHASE_TYPE; } |
- The product identifiers you wish to retrieve information for |
Returns: Promise<{ products: Product[]; }>
getProduct(options: { productIdentifier: string; productType?: PURCHASE_TYPE; }) => Promise<{ product: Product; }>
Gets the product info for a single product identifier.
Param | Type | Description |
---|---|---|
options |
{ productIdentifier: string; productType?: PURCHASE_TYPE; } |
- The product identifier you wish to retrieve information for |
Returns: Promise<{ product: Product; }>
isBillingSupported() => Promise<{ isBillingSupported: boolean; }>
Check if billing is supported for the current device.
Returns: Promise<{ isBillingSupported: boolean; }>
getPluginVersion() => Promise<{ version: string; }>
Get the native Capacitor plugin version
Returns: Promise<{ version: string; }>
Prop | Type | Description |
---|---|---|
activeSubscriptions |
[string] |
Set of active subscription skus |
allPurchasedProductIdentifiers |
[string] |
Set of purchased skus, active and inactive |
nonSubscriptionTransactions |
Transaction[] |
Returns all the non-subscription a user has made. The are ordered by purchase date in ascending order. |
latestExpirationDate |
string | null |
The latest expiration date of all purchased skus |
firstSeen |
string |
The date this user was first seen in RevenueCat. |
originalAppUserId |
string |
The original App User Id recorded for this user. |
requestDate |
string |
Date when this info was requested |
originalApplicationVersion |
string | null |
Returns the version number for the version of the application when the user bought the app. Use this for grandfathering users when migrating to subscriptions. This corresponds to the value of CFBundleVersion (in iOS) in the Info.plist file when the purchase was originally made. This is always null in Android |
originalPurchaseDate |
string | null |
Returns the purchase date for the version of the application when the user bought the app. Use this for grandfathering users when migrating to subscriptions. |
managementURL |
string | null |
URL to manage the active subscription of the user. If this user has an active iOS subscription, this will point to the App Store, if the user has an active Play Store subscription it will point there. If there are no active subscriptions it will be null. If there are multiple for different platforms, it will point to the device store. |
Prop | Type | Description |
---|---|---|
transactionId |
string |
RevenueCat Id associated to the transaction. |
Prop | Type | Description |
---|---|---|
identifier |
string |
Product Id. |
description |
string |
Description of the product. |
title |
string |
Title of the product. |
price |
number |
Price of the product in the local currency. |
priceString |
string |
Formatted price of the item, including its currency sign, such as €3.99. |
currencyCode |
string |
Currency code for price and original price. |
currencySymbol |
string |
Currency symbol for price and original price. |
isFamilyShareable |
boolean |
Boolean indicating if the product is sharable with family |
subscriptionGroupIdentifier |
string |
Group identifier for the product. |
subscriptionPeriod |
SubscriptionPeriod |
The Product subcription group identifier. |
introductoryPrice |
SKProductDiscount | null |
The Product introductory Price. |
discounts |
SKProductDiscount[] |
The Product discounts list. |
Prop | Type | Description |
---|---|---|
numberOfUnits |
number |
The Subscription Period number of unit. |
unit |
number |
The Subscription Period unit. |
Prop | Type | Description |
---|---|---|
identifier |
string |
The Product discount identifier. |
type |
number |
The Product discount type. |
price |
number |
The Product discount price. |
priceString |
string |
Formatted price of the item, including its currency sign, such as €3.99. |
currencySymbol |
string |
The Product discount currency symbol. |
currencyCode |
string |
The Product discount currency code. |
paymentMode |
number |
The Product discount paymentMode. |
numberOfPeriods |
number |
The Product discount number Of Periods. |
subscriptionPeriod |
SubscriptionPeriod |
The Product discount subscription period. |
Members | Value | Description |
---|---|---|
INAPP |
"inapp" |
A type of SKU for in-app products. |
SUBS |
"subs" |
A type of SKU for subscriptions. |