name: revenuecat-patterns description: RevenueCat SDK entegrasyon pattern'leri. iOS (Swift), Android (Kotlin), React Native ve Flutter icin setup, offerings, entitlement checking, webhook integration, StoreKit 2 migration ve sandbox testing.
RevenueCat Integration Patterns
SDK Setup
iOS (Swift + StoreKit 2)
// AppDelegate.swift veya @main App
import RevenueCat
Purchases.logLevel = .debug // sandbox'ta acik tut
Purchases.configure(
with: .init(withAPIKey: "appl_XXXXXXXXXXXXX")
.with(usesStoreKit2IfAvailable: true)
)
// Kullanici kimlik eslestirme (auth sonrasi)
Purchases.shared.logIn("user_id_from_your_backend") { customerInfo, created, error in
// created: true ise yeni kullanici
}
// Logout
Purchases.shared.logOut { customerInfo, error in }
Android (Kotlin)
// Application.onCreate()
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration.Builder(this, "goog_XXXXXXXXXXXXX")
.appUserID("user_id") // null ise anonim
.build()
)
React Native
import Purchases from 'react-native-purchases';
// App.tsx useEffect icinde
await Purchases.configure({
apiKey: Platform.OS === 'ios'
? 'appl_XXXXXXXXXXXXX'
: 'goog_XXXXXXXXXXXXX',
appUserID: userId ?? undefined, // null = anonim
});
Flutter
// main.dart
await Purchases.configure(
PurchasesConfiguration('appl_XXXXXXXXXXXXX')
..appUserID = userId
);
Offerings ve Paywalls
Offerings Getirme
// iOS
Purchases.shared.getOfferings { offerings, error in
guard let current = offerings?.current else { return }
let monthly = current.monthly // Package?
let annual = current.annual // Package?
let weekly = current.package(identifier: "weekly") // Custom
// Fiyat gosterme
if let monthly = monthly {
priceLabel.text = monthly.storeProduct.localizedPriceString
// "$9.99" (locale'e gore formatlanmis)
}
}
// Android
Purchases.sharedInstance.getOfferingsWith(
onError = { error -> /* PurchasesError */ },
onSuccess = { offerings ->
val current = offerings.current ?: return@getOfferingsWith
val monthly = current.monthly
val annual = current.annual
monthly?.product?.let { product ->
priceText.text = product.price.formatted // "$9.99"
}
}
)
// React Native
const offerings = await Purchases.getOfferings();
const current = offerings.current;
if (current) {
const monthly = current.monthly;
const annual = current.annual;
// Fiyat
monthly?.product.priceString; // "$9.99"
// Savings hesapla
if (monthly && annual) {
const monthlyPerYear = monthly.product.price * 12;
const savings = Math.round((1 - annual.product.price / monthlyPerYear) * 100);
// "Save 60%"
}
}
RevenueCat Paywalls (No-code)
// iOS - RevenueCat Paywall UI
import RevenueCatUI
// SwiftUI
PaywallView()
.onPurchaseCompleted { customerInfo in
// Satin alma basarili
}
.onDismiss {
// Kullanici kapatti
}
// Footer mode (kendi UI'in + RC pricing)
PaywallFooterView()
// Android - Paywall UI
PaywallDialog(
PaywallDialogOptions.Builder()
.setDismissRequest { /* kapandi */ }
.setListener(object : PaywallListener {
override fun onPurchaseCompleted(customerInfo: CustomerInfo, storeTransaction: StoreTransaction) {
// Basarili
}
})
.build()
)
Entitlement Checking
// iOS - Erisim kontrolu
Purchases.shared.getCustomerInfo { customerInfo, error in
let isPremium = customerInfo?.entitlements["premium"]?.isActive == true
// Detayli bilgi
if let entitlement = customerInfo?.entitlements["premium"] {
entitlement.isActive // true/false
entitlement.willRenew // otomatik yenilenecek mi
entitlement.expirationDate // bitis tarihi
entitlement.periodType // .normal, .trial, .intro
entitlement.productIdentifier // hangi urun
}
}
// Listener (real-time degisiklik)
Purchases.shared.delegate = self
func purchases(_ purchases: Purchases,
receivedUpdated customerInfo: CustomerInfo) {
let isPremium = customerInfo.entitlements["premium"]?.isActive == true
updateUI(isPremium: isPremium)
}
// React Native
const customerInfo = await Purchases.getCustomerInfo();
const isPremium = customerInfo.entitlements.active['premium'] !== undefined;
// Listener
Purchases.addCustomerInfoUpdateListener((info) => {
const isPremium = info.entitlements.active['premium'] !== undefined;
setIsPremium(isPremium);
});
Feature Gating Pattern
// Merkezi erisim kontrolu
class EntitlementManager {
private customerInfo: CustomerInfo | null = null;
async refresh(): Promise<void> {
this.customerInfo = await Purchases.getCustomerInfo();
}
get isPremium(): boolean {
return this.customerInfo?.entitlements.active['premium'] !== undefined;
}
get isOnTrial(): boolean {
const ent = this.customerInfo?.entitlements.active['premium'];
return ent?.periodType === 'TRIAL';
}
get trialEndDate(): Date | null {
const ent = this.customerInfo?.entitlements.active['premium'];
if (ent?.periodType !== 'TRIAL') return null;
return ent.expirationDate ? new Date(ent.expirationDate) : null;
}
get willRenew(): boolean {
return this.customerInfo?.entitlements.active['premium']?.willRenew ?? false;
}
}
Purchase Flow
// iOS
Purchases.shared.purchase(package: monthlyPackage) { transaction, customerInfo, error, userCancelled in
if userCancelled {
// Kullanici iptal etti - agresif olmadan geri don
return
}
if let error = error {
// Hata: odeme basarisiz, network vb.
handleError(error)
return
}
if customerInfo?.entitlements["premium"]?.isActive == true {
// BASARILI - premium erisim ac
unlockPremium()
}
}
// React Native
try {
const { customerInfo } = await Purchases.purchasePackage(monthlyPackage);
if (customerInfo.entitlements.active['premium']) {
unlockPremium();
}
} catch (e: any) {
if (e.userCancelled) return;
handleError(e);
}
Restore Purchases (ZORUNLU - Apple Requirement)
// iOS - MUTLAKA paywall'da "Restore Purchases" butonu olmali
Purchases.shared.restorePurchases { customerInfo, error in
if customerInfo?.entitlements["premium"]?.isActive == true {
showAlert("Aboneliginiz geri yuklendi!")
unlockPremium()
} else {
showAlert("Aktif abonelik bulunamadi.")
}
}
Webhook Integration (Server-Side)
Webhook Setup
RevenueCat Dashboard > Project > Integrations > Webhooks
- URL:
https://your-api.com/webhooks/revenuecat - Authorization Header: Bearer token ile dogrula
Webhook Handler
// Next.js API Route
import { NextRequest, NextResponse } from 'next/server';
interface RevenueCatEvent {
api_version: string;
event: {
type: string;
app_user_id: string;
product_id: string;
entitlement_ids: string[];
period_type: 'TRIAL' | 'NORMAL' | 'INTRO';
expiration_at_ms: number;
environment: 'SANDBOX' | 'PRODUCTION';
price_in_purchased_currency: number;
currency: string;
store: 'APP_STORE' | 'PLAY_STORE' | 'STRIPE';
};
}
// Event tipleri ve aksiyonlar
const EVENT_HANDLERS: Record<string, (event: RevenueCatEvent['event']) => Promise<void>> = {
// Yeni satin alma
'INITIAL_PURCHASE': async (event) => {
await db.user.update({
where: { id: event.app_user_id },
data: { isPremium: true, subscriptionStart: new Date() }
});
await analytics.track('subscription_started', {
userId: event.app_user_id,
product: event.product_id,
price: event.price_in_purchased_currency,
currency: event.currency,
periodType: event.period_type,
});
},
// Yenileme
'RENEWAL': async (event) => {
await db.user.update({
where: { id: event.app_user_id },
data: { subscriptionRenewedAt: new Date() }
});
},
// Iptal (hemen degil, sure sonunda biter)
'CANCELLATION': async (event) => {
await db.user.update({
where: { id: event.app_user_id },
data: { willCancel: true, cancelledAt: new Date() }
});
// Win-back kampanyasi baslat
await triggerWinBackCampaign(event.app_user_id, event.expiration_at_ms);
},
// Sure doldu
'EXPIRATION': async (event) => {
await db.user.update({
where: { id: event.app_user_id },
data: { isPremium: false, expiredAt: new Date() }
});
},
// Odeme sorunu
'BILLING_ISSUE': async (event) => {
await db.user.update({
where: { id: event.app_user_id },
data: { hasBillingIssue: true }
});
// Grace period bilgilendirmesi
await sendBillingIssueNotification(event.app_user_id);
},
// Trial donusumu
'SUBSCRIBER_ALIAS': async (event) => {
// Anonim kullanici -> kayitli kullanici eslestirme
},
};
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.REVENUECAT_WEBHOOK_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body: RevenueCatEvent = await req.json();
// Sandbox event'lerini filtrele (production'da)
if (process.env.NODE_ENV === 'production' && body.event.environment === 'SANDBOX') {
return NextResponse.json({ ok: true });
}
const handler = EVENT_HANDLERS[body.event.type];
if (handler) {
await handler(body.event);
}
return NextResponse.json({ ok: true });
}
Sandbox Testing
iOS Sandbox
- App Store Connect > Users and Access > Sandbox Testers
- Yeni sandbox kullanici olustur (gercek email gerekli)
- iPhone Settings > App Store > Sandbox Account ile giris
- Sandbox'ta sureler kisaltilmis:
- 1 haftalik = 3 dakika
- 1 aylik = 5 dakika
- 1 yillik = 1 saat
- Auto-renew 6 kez sonra durur
Android Test
- Google Play Console > Setup > License testing
- Test email'lerini ekle
- Test track'e yukle (internal testing)
Purchases.logLevel = .debugile test et
Debug Checklist
[ ] RevenueCat dashboard'da sandbox event'leri gorunuyor mu?
[ ] Entitlement dogru aktive oluyor mu?
[ ] Restore purchases calisiyor mu?
[ ] Webhook'lar geliyor mu? (RequestBin ile test et)
[ ] Fiyatlar locale'e gore formatlanmis mi?
[ ] Trial suresi dogru gorunuyor mu?
[ ] Iptal sonrasi erisim sure sonunda kapaniyor mu?
StoreKit 2 Migration Notlari
- RevenueCat v4+ StoreKit 2'yi otomatik destekler
usesStoreKit2IfAvailable: trueile etkinlestir- StoreKit 2 avantajlari: real-time transaction updates, better receipt handling
- iOS 15+ gerektirir (iOS 14 icin StoreKit 1 fallback otomatik)
- Server-side receipt validation artik gerekli degil (SK2 bunu yapiyor)
Onemli Kurallar
- Restore Purchases butonu ZORUNLU (Apple reject eder yoksa)
- Fiyatlari localizedPriceString ile goster (hardcode ETME)
- Sandbox'ta test etmeden production'a cikma
- Webhook secret'i .env'de tut (hardcode ETME)
- BILLING_ISSUE event'ini handle et (grace period uygula)
- Trial bitis tarihini kullaniciya goster (seffaflik)
- Anonim -> kayitli kullanici gecisinde merge yap (veri kaybi olmasin)