Integration & getting started
The minimal path to a working paywall and reliable entitlements, running modes, SDK footprint, and what does or does not require an app release.
What is the minimal integration path to get a working paywall and reliable entitlements?
Three pillars. A first operational paywall is typically live in a few hours.
1. Console configuration (no-code)
- Create the application, connect it to the stores (App Store, Play Store) and import your catalogue (Products and Plans).
- Design the Screen in the Screen Composer.
- Attach the Screen to a Placement (onboarding funnel, settings, home…).
2. SDK integration
// 1. Initialize at launch
Purchasely
.apiKey("X-API-KEY")
.runningMode(.full) // ⚠️ default is .observer
.storekitSettings(.storeKit2)
.start { error in
print(error == nil)
}
// 2. Identify the user before any paywall is shown
Purchasely.userLogin(with: "your_opaque_id")
// 3. Display
PLYPresentationBuilder
.forPlacementId("my_placement")
.build()
.preload { presentation, error in
presentation?.display(from: myUIViewController)
}// 1. Initialize at launch
Purchasely {
context(applicationContext)
apiKey("X-API-KEY")
stores(listOf(GoogleStore()))
runningMode(PLYRunningMode.Full) // ⚠️ default is Observer
onInitialized { error ->
if (error == null) { /* ready */ }
}
}
// 2. Identify the user before any paywall is shown
Purchasely.userLogin("your_opaque_id")
// 3. Display
PLYPresentation {
placementId("my_placement")
}.display(context)📚 SDK installation · SDK initialization · Displaying Screens with Placements
3. Entitlement architecture
Two complementary approaches:
| Approach | How it works | Best for |
|---|---|---|
| Backend entitlements | Our S2S webhooks (ACTIVATE / DEACTIVATE) make your server the single source of truth. | Production. Reliable cross-device experience, independent of the device. |
| SDK entitlements | Query the subscription status directly from the SDK. | Offline behavior and immediate UI gating at launch. |
Recommended patternUse your backend as the absolute reference and the SDK as a local cache for responsiveness at launch. Call
Purchasely.synchronize()when the app returns to the foreground.
Full mode or Observer mode — which one should I pick?
| Mode | Who owns the purchase flow | When to choose it |
|---|---|---|
| Full mode | Purchasely triggers the purchase, validates and acknowledges the receipt, manages rights. | New apps, or apps that want a complete subscription infrastructure. |
| Observer mode | You keep your existing transactional logic (in-house, RevenueCat…). | Apps with an established IAP system that want the no-code, A/B testing and analytics layer without touching purchases. |
In SDK 6, the default running mode is ObserverIf you want Purchasely to handle purchases, you must explicitly set
runningModeto Full. This is a change from SDK 5, where Full was the default.
Both modes give you the full no-code feature set: Screen Composer, Placements, Audiences, A/B tests, Flows, Campaigns and analytics.
Where should start() be called?
start() be called?As early as possible in the app lifecycle — AppDelegate / App on iOS, Application.onCreate() on Android — and once per process. Calling it later means the first Placement request may fire before the SDK is configured.
Set the user identifier before the first paywall is displayed, either via appUserId / userId in the builder if you already know it, or via Purchasely.userLogin() as soon as your session is restored.
📚 SDK initialization · Identifying users
What happens if the SDK fails to start (no network, timeout)?
start() returns an error in its completion block and the SDK retries in the background. Practical consequences:
- Do not gate your app launch on a successful
start(). Render your UI, and let paywall display fail gracefully. - Always handle the error branch of the fetch/display call and provide a fallback path (skip the paywall, or show a native screen).
- Because Screens are fetched from our servers, pre-fetching at launch removes most of the perceived latency on the first display.
What log level should I ship in production?
warn is the recommended production level. debug is verbose by design — internal SDK state, network requests, presentation lifecycle — and belongs to development builds only.
Purchasely
.apiKey("X-API-KEY")
.logLevel(.warn)
.start { _ in }Purchasely {
context(applicationContext)
apiKey("X-API-KEY")
logLevel(LogLevel.WARN)
}On native SDK 6 the default is already error, the quietest level. On Android, Purchasely.logcatEnabled controls Logcat output independently of logLevel, and a custom logger receives every message regardless of level — so gate it on your own build flag rather than assuming logLevel filters it.
When you need to act on a failure rather than just log it, decode the PLYError you get in the completion or error callback: it distinguishes a missing store configuration (NoStoreConfigured), a network failure, a user cancellation and a store-side rejection, which is the difference between retrying, staying silent and showing an alert.
Do I need special ProGuard / R8 rules on Android?
If your release build minifies, yes — otherwise the SDK's classes can be stripped and you get a ClassNotFoundException or NoSuchMethodError on initialization or on the first paywall, in release builds only.
-keep class io.purchasely.** { *; }
-keep class io.purchasely.ext.** { *; }
# Google Play Billing
-keep class com.android.vending.billing.** { *; }
# Huawei IAP (only if you ship the Huawei store)
-keep class com.huawei.hms.iap.** { *; }The tell-tale sign is a crash that reproduces on a release/minified build and never on debug. Always validate a release build before submitting, not only the debug one.
Do I have to resolve every action interceptor?
Yes. Every interceptor must return a result telling the SDK how your app handled the action:
| Result | Effect |
|---|---|
SUCCESS | The action succeeded. The chain continues to the next action on that button. In Observer mode, purchase and restore also trigger synchronization automatically. |
FAILED | Stops the chain, the remaining actions are skipped, and the button leaves its loading state. |
NOT_HANDLED | You did not handle it — the SDK performs its own default behavior for that action. |
Purchasely.interceptAction(.purchase) { info, params, completion in
guard let productId = params?.plan?.appleProductId else {
return completion(.notHandled)
}
myBillingStack.purchase(productId) { ok in
completion(ok ? .success : .failed)
}
}Purchasely.interceptAction<PLYPresentationAction.Purchase> { info, purchase ->
val activity = info?.activity ?: return@interceptAction PLYInterceptResult.NOT_HANDLED
when (myBillingStack.launch(activity, purchase)) {
BillingResult.SUCCESS -> PLYInterceptResult.SUCCESS
else -> PLYInterceptResult.FAILED
}
}Notes that matter in practice:
- On Kotlin, the reified form above is a suspend lambda — you return the result. There is also a
Class-based overload for non-coroutine call sites,Purchasely.interceptAction(PLYPresentationAction.Purchase::class.java) { info, action, result -> … }, where you must callresultexactly once, synchronously or after your async work. - An action with no registered interceptor is performed by the SDK, so a button never stays stuck spinning. The risk is the opposite: registering an interceptor and then never resolving it.
- Actions run in order and only advance if the previous one succeeded. A
purchasethat fails will not run theopen_screenconfigured after it. - Do not intercept
open_presentationoropen_placementunless you have discussed it with us — overriding them breaks A/B test, Audience and Campaign tracking.
📚 Paywall action interceptor · Processing transactions with the interceptor · Action types
Which parameters do I get in the interceptor, and which can be null?
The payload differs by platform, and this is where most integration surprises come from.
iOS — the action carries a params object and an info object:
| What you need | Where it is |
|---|---|
| The store product to purchase | params?.plan?.appleProductId — optional, guard it and return .notHandled |
| A promotional offer | params?.promoOffer?.storeOfferId |
| 12-month commitment billing plan | params?.billingPlanType — upFront, monthly or unspecified |
| Web checkout data | params?.url, params?.clientReferenceId, params?.queryParameterKey, params?.webCheckoutProvider |
| The presenting controller | info?.controller |
| Targeting context | info?.presentation?.placementId / .audienceId / .abTestId / .abTestVariantId / .campaignId, and info?.contentId |
Android — there is no wrapper parameters object any more: the fields live on the action subclass itself.
| Action | Fields |
|---|---|
Purchase | plan, subscriptionOffer (nullable), offer |
Close / CloseAll | closeReason |
Navigate | url, title |
OpenPresentation | presentationId |
OpenPlacement | placementId |
WebCheckout | url, clientReferenceId, queryParameterKey, webCheckoutProvider |
Restore / Login / PromoCode | no parameters |
For a Google Play purchase you typically read purchase.subscriptionOffer?.subscriptionId, ?.basePlanId, ?.offerId and ?.offerToken. Because subscriptionOffer is nullable, always handle the null branch rather than force-unwrapping — that is the usual cause of a paywall button that does nothing on a plan without an offer.
planVendorIdis not part of the interceptor payloadIt is a Dynamic Offering input and a field of
PLYPresentationPlan. If you need the Purchasely-side plan reference in the interceptor, read it from theplanobject you are given.
Migrating from v5? The proceed closure is gone: processAction(false) becomes .success and processAction(true) becomes .notHandled. To detach an interceptor, use removeActionInterceptor(...) or removeAllActionInterceptors().
In Observer mode, when do I have to call synchronize()?
synchronize()?Less often than most integrations assume.
| Situation | What to do |
|---|---|
| Purchase or restore made through the paywall, bridged via the interceptor | Nothing. Returning the success result already triggers synchronization for you. |
| Purchase made outside the paywall — your own store screen, a BYOS Custom Screen | Call Purchasely.synchronize() yourself once your billing flow completes. |
Purchasely.synchronize(success: { }, failure: { error in })Purchasely.synchronize(
onSuccess = { plan -> /* plan is the validated PLYPlan, or null */ },
onError = { error -> }
)On Android the subscriptions cache is refreshed before onSuccess fires, so a normal cached userSubscriptions(...) read from that callback already returns the fresh entitlements — no polling with an arbitrary delay.
NOT_HANDLEDdoes nothing useful on purchase / restore in Observer modePurchasely never processes those actions in Observer mode, so returning
.notHandledlogs a warning and skips. Return the success or failure result instead.
Finishing the transaction stays your responsibilityIn Observer mode the SDK observes the receipt for analytics but does not finish the transaction (App Store) or acknowledge it (Google Play). Your own billing code must still do that.
📚 Observer mode · Checking your integration in Observer mode
Do I need to ship a new app version every time I change a paywall?
No. Screens, Placements, Audiences, A/B tests, Flows, Campaigns and localizations are all resolved server-side. You publish in the Console and the change reaches your users without a release.
An app release is only required when:
- you upgrade the SDK itself (new major features, new component types),
- you add a new Placement identifier that does not exist in your code yet,
- you change your own native code (interceptor logic, entitlement gating, deeplink handling).
Does a new Screen Composer component or layout require an SDK update?
In most cases, no — new components and layouts are rendered by the existing SDK. An SDK update is only needed when a component brings a genuinely new capability or a new rendering primitive (video is the classic example).
The Console warns you when a Screen uses a component that requires a minimum SDK version, and you can configure a legacy Screen as a fallback for older SDK versions still in the wild.
📚 SDK versions dashboard — to know what SDK versions your installed base actually runs.
How large is the SDK?
Under 2 MB on iOS and under 1 MB on Android. Both are native (Swift / Kotlin), with no heavyweight runtime dependency.
Which platforms are supported?
| Platform | Language | Package |
|---|---|---|
| iOS | Swift / Objective-C | pod 'Purchasely' or Swift Package Manager — guide |
| Android | Kotlin / Java | io.purchasely:core (Maven) — guide |
| React Native | TypeScript | react-native-purchasely — guide |
| Flutter | Dart | purchasely_flutter — guide |
| Cordova | JavaScript | @purchasely/cordova-plugin-purchasely — guide |
On React Native, Flutter and Cordova, why do I need a second package for Google Play?
The main cross-platform package does not embed Google Play Billing, so that iOS-only apps do not carry it. Add the store package explicitly:
| Platform | Main package | Google Play Billing |
|---|---|---|
| React Native | react-native-purchasely | @purchasely/react-native-purchasely-google |
| Flutter | purchasely_flutter | purchasely_google |
| Cordova | @purchasely/cordova-plugin-purchasely | @purchasely/cordova-plugin-purchasely-google |
Keep both packages on the exact same versionA version mismatch between the main package and the store package is one of the most common causes of prices not resolving on Android.
Can I run Purchasely alongside RevenueCat or my own IAP code?
Yes — that is exactly what Observer mode is for. Purchasely observes transactions without processing them, and you keep your receipt validation and entitlement logic. You still get the Screen Composer, Placements, Audiences, A/B tests and analytics.
📚 Checking your integration in Observer mode · Using Purchasely in existing apps
How do I test before going live?
- Enable Debug Mode on your test device by scanning the Preview QR code from the Console. You then see draft Screens, plus the SDK version, user IDs, Placement, Audience, A/B test and variant actually resolved for that device.
- Target the built-in
Internal TestersAudience with the highest priority so only debug devices see the experience under test. - Use the Preview in the Console for layout checks, and a real device for anything store-related (prices, eligibility, purchase flow).
📚 Checking your Purchasely integration
The SDK used to display a subscriptions screen — where did it go in SDK 6?
The Purchasely-rendered subscriptions screen was removed in SDK 6, not deprecated to a no-op: Purchasely.subscriptionsFragment() (Android), presentSubscriptions() (Flutter, React Native) and displaySubscriptionCancellationInstruction() (Android, Flutter) no longer exist. iOS never shipped an equivalent.
The replacement is a screen you own:
- Build the list from
Purchasely.userSubscriptions()(anduserSubscriptionsHistory()for past ones). - Send cancel / upgrade / downgrade actions to the native store page —
AppStore.showManageSubscriptions(in:)on iOS 15+, orhttps://apps.apple.com/account/subscriptions, andhttps://play.google.com/store/account/subscriptions?sku=…&package=…on Android.
That is also what Apple and Google require: subscription management has to happen on the store side, not inside a custom in-app flow.
I am on SDK 5 — how do I move to SDK 6?
Follow the migration guide for your platform:
The two changes that catch teams out most often: the default running mode is now Observer, and Google Play Billing is integrated directly by the Android SDK (see Google Play Billing v8 if you are still on 5.x).
Updated 6 minutes ago

