Payment System with RevenueCat

Full-Stack Developer with 3+ years of expertise in the JavaScript ecosystem (React, Next.js, TypeScript, Node.js). Currently operating as an independent developer, architecting and shipping scalable products including a global travel platform. I am passionate about building efficient, type-safe web architectures and actively expanding my expertise into Cloud Engineering with AWS, Docker, and Kubernetes.
Following authentication, I decided not to build payments from scratch. The reasoning is similar.
Why RevenueCat
Building in-app purchases yourself requires:
- Apple StoreKit 2 implementation
- Google Play Billing Library implementation
- Receipt verification server (different APIs for Apple/Google)
- Subscription state tracking, renewal/expiry handling
- Refund detection (Server-to-Server Notifications)
- Price localization
- Testing + error handling
And Apple and Google’s payment APIs are completely different. The same logic needs to be written twice.
RevenueCat handles iOS/Android with a single SDK. It provides server-side receipt verification APIs. It abstracts complex payment logic into Entitlement-based access control.
The strategy is to ship quickly with RevenueCat at the MVP stage, then consider building in-house as we scale.
SDK Initialization
RevenueCat SDK initialization has two modes.
// mobile/utils/revenuecat.ts
export const initializeRevenueCat = async (userId?: string) => {
const apiKey = process.env.EXPO_PUBLIC_REVENUECAT_API_KEY;
if (userId) {
// Logged-in user: identify with Clerk ID
Purchases.configure({
apiKey,
appUserID: userId,
});
} else {
// Anonymous mode: guest user
Purchases.configure({ apiKey });
}
};
When an anonymous user logs in later, the account needs to be linked:
export const loginRevenueCat = async (userId: string) => {
const { customerInfo } = await Purchases.logIn(userId);
return customerInfo;
};
This preserves purchases made in guest mode after login.
Dual Verification Pattern
This is the most important part of a payment system.
Why you can’t trust the RevenueCat SDK alone:
Client SDKs can be manipulated
Network delays can cause RevenueCat and backend DB to be out of sync
Solution: RevenueCat + Backend dual verification
// mobile/hooks/queries/usePurchasedCodes.ts
const { data: codes } = useQuery({
queryKey: ["purchased-codes"],
queryFn: async () => {
// 1. Get all purchased product IDs from RevenueCat
const revenueCatProductIds = await getAllPurchasedProductIds();
// 2. Get purchased codes from backend
const response = await purchaseApi.getMyPurchasedCodes(api);
const backendCodes = response.data.data;
// 3. Only codes matching both sources are considered valid
const verifiedCodes = backendCodes.filter((code: string) => {
return revenueCatProductIds.some(productId =>
productId.toUpperCase().includes(code.toUpperCase())
);
});
return verifiedCodes;
},
});
The key points:
In RevenueCat but not in backend → attempt sync
In backend but not in RevenueCat → refunded or tampered, invalidate
Backend Verification Flow
When a purchase completes on mobile, a sync request is sent to the backend. The backend directly calls the RevenueCat API to verify.
// backend/src/services/purchase.service.ts
export const syncPurchase = async (clerkId: string, input: SyncPurchaseInput) => {
const { productIdentifier, entitlementId } = input;
// 1. Extract country code from productIdentifier
// 'sub_th_1y' → 'TH'
const isoCode = extractIsoCodeFromIdentifier(productIdentifier, entitlementId);
if (!isoCode) {
throw new Error('INVALID_IDENTIFIER');
}
// 2. Server-side verification via RevenueCat API
const entitlementToVerify = entitlementId || `${isoCode.toLowerCase()}_access`;
const verification = await verifyEntitlement(clerkId, entitlementToVerify);
if (!verification.valid) {
throw new Error(`VERIFICATION_FAILED:${verification.reason}`);
}
// 3. Check for existing valid purchase (prevent duplicates)
const validExistingPurchase = await Purchase.findOne({
clerkId,
countryIsoCode: isoCode,
isActive: true,
expiryDate: { $gt: new Date() },
});
if (validExistingPurchase) {
return { isNew: false, purchase: validExistingPurchase };
}
// 4. Create new purchase
const newPurchase = await Purchase.create({
userId: user._id,
clerkId,
countryId: country._id,
countryIsoCode: isoCode,
productIdentifier: verification.productIdentifier,
purchaseDate: new Date(verification.purchaseDate),
expiryDate: calculateExpiryDate(...),
isActive: true,
});
return { isNew: true, purchase: newPurchase };
};
The backend calling RevenueCat API directly:
// backend/src/utils/revenuecat.ts
export async function verifyEntitlement(
appUserId: string,
entitlementId: string
): Promise<EntitlementVerificationResult> {
const response = await fetch(
`${REVENUECAT_API_BASE}/subscribers/${appUserId}`,
{
headers: {
Authorization: `Bearer${REVENUECAT_API_KEY}`,
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
const entitlement = data.subscriber?.entitlements?.[entitlementId];
if (!entitlement) {
return { valid: false, reason: 'Entitlement not found' };
}
// Check expiration
if (entitlement.expires_date) {
const expiresDate = new Date(entitlement.expires_date);
if (expiresDate <= new Date()) {
return { valid: false, reason: 'Entitlement expired' };
}
}
return {
valid: true,
expiresDate: entitlement.expires_date,
productIdentifier: entitlement.product_identifier,
purchaseDate: entitlement.purchase_date,
store: entitlement.store,
};
}
Ownership Transfer
There’s an unexpected edge case.
Scenario: User purchases with account A → logs in with account B → attempts restore with same Apple ID
RevenueCat recognizes purchases based on Apple ID. When both accounts A and B exist and RevenueCat transfers ownership to B, our DB needs to be updated accordingly.
// Check for ownership transfer
const originalPurchaseDateMs = verification.originalPurchaseDate
? new Date(verification.originalPurchaseDate).getTime()
: null;
if (originalPurchaseDateMs) {
const existingPurchaseFromOtherUser = await Purchase.findOne({
countryIsoCode: isoCode,
productIdentifier: verification.productIdentifier,
originalPurchaseDateMs,
clerkId: { $ne: clerkId }, // Different user's purchase
});
if (existingPurchaseFromOtherUser) {
// RevenueCat transferred ownership, update DB accordingly
existingPurchaseFromOtherUser.clerkId = clerkId;
existingPurchaseFromOtherUser.userId = user._id;
await existingPurchaseFromOtherUser.save();
return { isNew: false, transferred: true, purchase: existingPurchaseFromOtherUser };
}
}
Why store originalPurchaseDateMs in milliseconds: Date object comparison can fail due to timezone or millisecond differences. Storing as a number enables exact comparison.
Expiration Handling
Subscription products have expiration dates. Expired purchases should be automatically deactivated.
// Expiry date calculation
const calculateExpiryDate = (
purchaseDate: Date,
expiresDate?: string | null,
productIdentifier?: string
): Date => {
// 1. If RevenueCat provides expiry date, use it
if (expiresDate) {
return new Date(expiresDate);
}
// 2. If product ID contains '1y', add 1 year to purchase date
if (productIdentifier?.includes('1y')) {
const expiry = new Date(purchaseDate);
expiry.setFullYear(expiry.getFullYear() + 1);
return expiry;
}
// 3. Lifetime ownership (Non-consumable)
return new Date('2099-12-31T23:59:59.999Z');
};
Auto-deactivate expired purchases on query:
export const updateExpiredPurchases = async (clerkId: string): Promise<number> => {
const result = await Purchase.updateMany(
{
clerkId,
isActive: true,
expiryDate: { $lt: new Date() },
},
{ $set: { isActive: false } }
);
return result.modifiedCount;
};
// Call before fetching purchase list
export const getMyPurchasedCodes = async (clerkId: string) => {
await updateExpiredPurchases(clerkId);
const purchases = await Purchase.find({
clerkId,
isActive: true,
});
return purchases.map((p) => p.countryIsoCode);
};
Immediate UI Update After Purchase
After a purchase completes, users need immediate feedback. Waiting for server response feels slow.
// mobile/hooks/mutations/usePurchaseActions.ts
const mutation = useMutation({
mutationFn: async (payload) => {
const res = await purchaseApi.syncPurchase(api, payload);
return res.data;
},
onSuccess: (response) => {
const newCode = response.data?.purchase?.countryIsoCode;
if (newCode) {
// 1. Update Zustand store immediately
addPurchase(newCode);
// 2. Manually update React Query cache (prevents network request)
queryClient.setQueryData(
["purchased-codes"],
(oldData: string[] | undefined) =>
oldData ? [...oldData, newCode] : [newCode]
);
// 3. Refetch related queries (My Trips screen, etc.)
queryClient.refetchQueries({
queryKey: ["active-purchased-guides"]
});
}
showSuccess("Your purchase was completed successfully!");
},
});
Why update both Zustand and React Query:
Zustand: Immediate UI reflection (synchronous)
React Query: Cache consistency + ensures latest state in other screens
Guest Mode Handling
Logged-out users cannot have purchases verified.
// mobile/hooks/usePurchaseCheck.ts
export const usePurchaseCheck = (countryIsoCode: string | undefined) => {
const { isSignedIn } = useAuth();
const [isPurchased, setIsPurchased] = useState(false);
const checkPurchase = useCallback(async () => {
// Guest mode: not logged in means no purchases
if (!isSignedIn) {
setIsPurchased(false);
setIsLoading(false);
return;
}
// Logged-in user: check Entitlement from RevenueCat
const customerInfo = await Purchases.getCustomerInfo();
const entitlementId = `${countryIsoCode.toLowerCase()}_access`;
const isPurchasedAndActive =
customerInfo.entitlements.active[entitlementId] !== undefined;
setIsPurchased(isPurchasedAndActive);
}, [countryIsoCode, isSignedIn]);
// ...
};
Restore Purchases
When reinstalling the app or logging in on a different device, existing purchases need to be restored.
export const restorePurchases = async () => {
const TIMEOUT_MS = 15_000;
const customerInfo = await Promise.race([
Purchases.restorePurchases(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('RESTORE_TIMEOUT')), TIMEOUT_MS)
),
]);
return customerInfo;
};
Why the timeout: Apple/Google server responses can be slow. This prevents indefinite waiting.
Sync Recovery Logic
Sometimes purchases exist in RevenueCat but not in the backend DB. This happens when sync fails due to network errors.
// usePurchasedCodes.ts
// Detect purchases in RevenueCat but not in backend
const unSyncedProducts = revenueCatProductIds.filter(productId =>
!backendCodes.some((code: string) =>
productId.toUpperCase().includes(code.toUpperCase())
)
);
if (unSyncedProducts.length > 0) {
// Attempt sync in background
for (const productId of unSyncedProducts) {
try {
await purchaseApi.syncPurchase(api, { productIdentifier: productId });
} catch (syncError) {
logger.warn(`Failed to sync product${productId}`);
}
}
// Re-fetch after sync
const refreshResponse = await purchaseApi.getMyPurchasedCodes(api);
return refreshResponse.data.data;
}
Conclusion
Payment systems are more complex than authentication. Apple and Google APIs are completely different, and refund/expiry/renewal handling is required.
What RevenueCat provides:
Single SDK for iOS/Android
Server-side receipt verification API
Entitlement-based access control
Automatic subscription state tracking
The key is dual verification. Don’t trust the client SDK alone. Verify by calling the RevenueCat API directly from the backend.
Next Post
The next post covers React Native performance optimization.



