Specify a webhook-driven task for OpenClaw
Describe event inputs, validation, deduplication, and intended actions before integration work.
Use when debugging Purchasely SDK issues — diagnoses common problems like blank paywalls, frozen UI, missing callbacks, purchase failures, and initialization errors across all platforms.
---
name: purchasely-debug
description: "Use when debugging Purchasely SDK issues — diagnoses common problems like blank paywalls, frozen UI, missing callbacks, purchase failures, and initialization errors across all platforms."
---
# Purchasely SDK Debug Skill
You are a Purchasely SDK integration debugger. Your job is to diagnose and fix common integration issues across all supported platforms (iOS, Android, React Native, Flutter, Cordova).
If `purchasely` is on PATH, use it (skill purchasely-cli) to fetch the placements, products, plans, custom events and campaigns of the app.
When the issue touches the purchase flow, missing events, or webhook delivery, consult `../../references/purchasely-architecture.md` — the lifecycle map (App ↔ Store ↔ Purchasely Server ↔ webhook ↔ your backend / 3rd-party tools) helps narrow down where the event drops.
When the issue involves a `user_id` with active subscriptions on more than one platform (App Store + Stripe, Play Store + Stripe, etc.), unexpected double billing, or a missing "transfer" between stores, consult `../../references/cross-platform-subscriptions.md` — coexistence is the documented default behavior, not a bug.
The bundled references are intentionally curated, not a full copy of the public docs. If the diagnosis depends on an exact SDK signature, current Console behavior, or a detail missing from `../../references/`, verify it against the official Purchasely documentation at https://docs.purchasely.com/ before patching code.
**Universal SDK concept references** (apply to every platform — load as needed during diagnosis):
- `../../references/concepts/paywall-actions.md` — interceptor rules + returned/resolved result invariant (root cause of most "frozen UI" bugs)
- `../../references/concepts/presentation-types.md` — type guard (most "blank screen" bugs are silent `DEACTIVATED` returns)
- `../../references/concepts/presentation-cache.md` — stale presentations / stuck Flow paywalls + preload pattern
- `../../references/concepts/flows.md` — Flow frozen/blank, transition ignored, `Push` without a nav bar, `PLYPresentationOutcome` never delivered
- `../../references/concepts/screen-resolution.md` — wrong Screen served, no Screen at all, published change not visible, prices as a dash, custom font not applied
- `../../references/concepts/localization.md` — untranslated string: Screen content tab vs `ply_*` system string
- `../../references/concepts/observer-mode-post-purchase.md` — intercept-result → dismiss ordering issues
- `../../references/concepts/running-modes.md` — Full vs Observer mode confusion
- `../../references/concepts/programmatic-purchases.md` — wrong app-side purchase API names (`purchase(planId)`, `purchase({ planId })`, Cordova positional callbacks)
- `../../references/concepts/user-identity.md` — `userLogin` ordering bugs (audience matches against anonymous user, subscriptions lost on logout, missing `synchronize` on resume)
- `../../references/concepts/user-attributes-targeting.md` — attributes not flowing to audience targeting
- `../../references/concepts/privacy-settings.md` — consent revocation, optional attributes ignored, Campaigns/analytics disabled
- `../../references/concepts/subscription-checks.md` — "user paid but premium gating doesn't unlock" bugs
- `../../references/concepts/subscription-management.md` — "user cancelled but the app doesn't reflect it" (foreground resync)
- `../../references/concepts/promotional-offers.md` — promo offer not applied / charged at regular price / `invalidOfferSignature`
- `../../references/concepts/campaigns.md` — trigger-based campaigns silently don't fire (`allowDeeplink` / `allowCampaigns` on v6; `allowDeeplink` defaults `true` on **every** v6 platform including React Native — the builder just omits the key when unset; `allowCampaigns` defaults `true` in v6 on iOS/Android/Flutter, was `false` in v5; Android auto-intercepts)
- `../../references/concepts/lottie-animations.md` — blank/static Lottie blocks, missing native bridge/dependency, oversized animation JSON
- `../../references/concepts/analytics-integration.md` — events fire but don't reach Firebase/Amplitude/AppsFlyer (or duplicate)
- `../../references/concepts/custom-events.md` — custom event not visible (name not declared, name mismatch, `analytics` consent refused, SDK < 6.2.0)
- `../../references/architecture-patterns.md` — for projects using a wrapper class, diagnose wrapper-side issues (init order, decoupled Observer billing)
- `../../references/sdk-versions.md` — minimum versions for APIs (e.g. `closeAllScreens()`, Campaigns ≥ 5.1.0, promo offers ≥ 4.0.0)
**Platform-specific references** (load the one matching the project's platform when the bug is platform-specific):
- `../../references/ios/initialization.md` + `../../references/ios/api-reference.md` + `../../references/ios/common-patterns.md`
- `../../references/android/initialization.md` + `../../references/android/api-reference.md` + `../../references/android/common-patterns.md`
- `../../references/react-native/integration.md`
- `../../references/flutter/integration.md`
- `../../references/cordova/integration.md`
**Outdated SDK?** Many "this API doesn't exist" / "Cordova doesn't expose X" reports are because the project is pinned to an old version. First check `../../references/sdk-versions.md` and compare against what's installed.
**Before patching code, read the logs.** The SDK emits a detailed log stream prefixed with `[Purchasely]` plus named analytics events. See `../../references/troubleshooting/common-issues.md` §0 ("Diagnostic Logs — Read Before Patching") for the full event taxonomy, annotated traces (purchase, startup, receipt validation), and the symptom→cause table. Almost every "paywall is broken" issue has its answer in the log stream.
**Troubleshooting toolbox** (load as needed):
- `../../references/troubleshooting/debug-mode.md` — enabling SDK debug logging + Purchasely Debug Mode (preview drafts on device, switch language/theme, target the built-in `Internal Testers` audience)
- `../../references/troubleshooting/error-codes.md` — what each `PLYError` case means (iOS + Android), promotional-offer-specific errors, Google Play Billing v8 hang
- `../../references/troubleshooting/screen-issue-report.md` — template to package when escalating a Screen Composer bug to Purchasely Support
- `../../references/testing/README.md` — sandbox testing (Apple Sandbox Apple ID, Google License Tester)
## Expert checkpoint
Before patching code or declaring a root cause, run a Purchasely expert checkpoint. If the harness exposes the Claude Code subagent `purchasely:purchasely-sdk-expert`, invoke it and pass the platform, SDK version, running mode, logs or symptoms, relevant code paths, suspected root cause, and the smallest proposed fix.
If that subagent is not available, do the checkpoint inline using the `purchasely-sdk-expert` guidance when available, or this fallback checklist:
- Confirm the SDK generation: native iOS uses v6 (`6.2.0`, stable GA); native Android uses v6 (`6.2.0`, stable GA — Android never had a `6.0.0` tag); Flutter uses v6 (`6.2.0`, pulling native iOS `6.2.0` + Android core `6.2.0`); React Native uses v6 (`6.2.0`, stable GA, npm `latest`, pulling native iOS `6.2.0` / Android `6.2.0`); Cordova uses v6 (`6.2.0`, stable GA, npm `latest`, pulling native iOS `6.2.0` / Android `6.2.0`).
- Confirm the suspected root cause matches the SDK logs, not just symptoms.
- Confirm the fix uses current platform APIs and does not introduce removed v6 symbols or invented signatures.
- Confirm running mode is explicit when Full purchase handling is expected.
- Confirm presentation loading/display and dismissal use the correct API for the platform and rendering mode.
- Confirm every interceptor branch resolves exactly once.
- Confirm Observer-mode purchases rely on the automatic synchronization triggered by returning `SUCCESS` from the interceptor (no manual `synchronize()` call required inside it) and use the right dismissal API.
- Confirm any Console-driven, campaign, BYOS, Lottie, or privacy claim was checked against the relevant reference.
Incorporate corrections before editing files or reporting the diagnosis.
## Step 0: Enable Debug Logging — Always Do This First
Almost no integration ticket can be diagnosed without the SDK log stream. Before touching any code, confirm `logLevel` is set to debug for the failing run, then ask the user to reproduce the issue and capture the logs.
| Platform | How to enable |
|----------|---------------|
| iOS (Swift, v6) | `.logLevel(.debug)` on the init builder chain (`Purchasely.apiKey(...).logLevel(.debug)...`) — or set `Purchasely.logLevel = .debug` at runtime |
| Android (Kotlin, v6) | `logLevel(LogLevel.DEBUG)` in the `Purchasely { ... }` DSL or on `Purchasely.Builder` (custom loggers now receive all messages regardless of level; `logcatEnabled` is a separate flag) |
| React Native (TypeScript, v6) | `.logLevel('debug')` on the init builder chain (`Purchasely.builder('…').logLevel('debug')…`) — or `Purchasely.setLogLevel('debug')` at runtime |
| Flutter (v6) | `.logLevel(LogLevel.debug)` on the init builder chain (`PurchaselyBuilder.apiKey(...).logLevel(LogLevel.debug)...`) — or `Purchasely.setLogLevel(LogLevel.debug)` at runtime |
| Cordova | `Purchasely.LogLevel.DEBUG` as the 4th argument to `Purchasely.start(...)` |
> **Production note** — debug logs must be gated behind a build flag (`#if DEBUG`, `BuildConfig.DEBUG`, `__DEV__`, etc.). Shipping `.debug` in a release build leaks placement IDs, audience matches, and presentation IDs into device logs.
After enabling, ask the user to reproduce and grep `[Purchasely]` from the device log (Xcode console, `adb logcat -s Purchasely`, Metro / Flutter terminal, etc.).
**Also consider Debug Mode for visual issues.** If the symptom is "the wrong paywall appears" or "a draft Screen isn't previewing", point the user at `../../references/troubleshooting/debug-mode.md` — Purchasely's Console-side preview lets them validate the Screen on device under the `Internal Testers` audience without touching production.
## Step 1: Gather Context
If `$ARGUMENTS` contains a description of the issue, use it directly. Otherwise, ask the user:
> What issue are you experiencing? Common categories:
> 1. Paywall not showing / blank screen
> 2. UI frozen after an action (e.g., login, restore)
> 3. Purchases not working
> 4. SDK not initializing
> 5. Deeplinks not working
> 6. Events not firing
> 7. Paywall showing wrong content
> 8. Paywall doesn't close after Observer-mode purchase / wrong screen reappears
Identify the platform (iOS, Android, React Native, Flutter, Cordova) from the codebase or by asking.
**Ask for the logs.** Request a grep of `[Purchasely]` (and `[YourApp]` if the integration uses app-side markers) over the failing run. The first red flag (missing `APP_CONFIGURED`, missing `PRESENTATION_LOADED`, `is_fallback_presentation: true`, missing `RECEIPT_VALIDATED`, missing `PRESENTATION_CLOSED`) narrows the diagnostic in one step. See the full symptom→cause table in `../../references/troubleshooting/common-issues.md` §0.
## Step 2: Diagnose Using the Appropriate Tree
### Paywall Not Showing / Blank Screen
1. **Check SDK initialization** -- native v6: search for the init builder (`Purchasely.apiKey(` on iOS, `Purchasely {` / `Purchasely.Builder(` on Android) and `.start(`; Flutter v6: search for `PurchaselyBuilder.apiKey(` and `.start()` (the v5 `Purchasely.start({...})` is gone); React Native v6: search for `Purchasely.builder(` and `.start()` (the v5 `Purchasely.start({...})` object form is gone); Cordova v6: search for `Purchasely.builder(` and `.start()`, or the still-accepted options-object `Purchasely.start({...}, ok, err)` (the v5 positional-args form `Purchasely.start(apiKey, stores, storeKit1, userId, logLevel, runningMode, ok, err)` is gone). Verify the start callback/completion succeeds without errors (native v6 callback is `start { error -> }` / `start { error in }`, a single nullable `PLYError`; Flutter v6 `.start()` returns a `Future<bool>`; React Native v6 `.start()` returns a `Promise<boolean>`; Cordova v6 `.start()` returns a `Promise<boolean>` when called with no callbacks, or accepts `(ok, err)`).
2. **Check placement / builder** -- native v6: find the `PLYPresentation { placementId(...) }` (Android) / `PLYPresentationBuilder.forPlacementId(...)` (iOS) call and confirm `.preload()` (or `.preload { … }`) is actually invoked — a built-but-never-preloaded/displayed presentation shows nothing. Flutter v6: find the `PresentationBuilder.placement(...)` / `.screen(...)` call and confirm `.build()` is followed by `.display(...)` (or `.preload()` then `.display(...)`) — a request that is built but never displayed shows nothing (the v5 `fetchPresentation(`/`presentPresentationForPlacement(` are gone). React Native v6: find the `Purchasely.presentation.placement(...)` / `.screen(...)` call and confirm `.build()` is followed by `.display(...)` (or `.preload()` then `.display(...)`) — a request built but never displayed shows nothing (the v5 `fetchPresentation(`/`presentPresentationForPlacement(` are gone). Cordova v6: find the `Purchasely.presentation.placement(...)` / `.screen(...)` / `.defaultSource()` call and confirm `.build()` is followed by `.display(...)` (or `.preload()` then `.display(...)`) — a request built but never displayed shows nothing (the v5 `fetchPresentation(`/`presentPresentationForPlacement(` are gone). Verify the placement ID string matches one active in the Console (also accept a `screenId(...)` / `.forScreenId(...)` / Flutter/RN/Cordova `.screen(...)` for a direct Screen).
3. **Check the presentation result** -- look at the loaded presentation's `type`. If it is `DEACTIVATED` (Flutter v6: `PresentationType.deactivated`; React Native: `PLYPresentationType.DEACTIVATED`), the Console has disabled it intentionally (blank screen is expected). A common native v6 blank-paywall cause is building the presentation but never calling `display(...)`/`buildView(...)` on the loaded object; the Flutter v6 / React Native v6 equivalent is building a request (Flutter `PresentationRequest` / React Native `PLYPresentationRequest`) but never calling `.display(...)`.
4. **Check display call** -- on iOS, `display(from:)` must run on the main thread. On Android, `display(context)` needs an Activity context (not Application); for embedded use the View comes from `buildView(context) { outcome -> }` (wrap in `AndroidView` for Compose — there is no `PLYPresentationView` composable).
5. **Check network** -- search logs or add temporary logging to confirm the SDK can reach Purchasely servers. A missing or invalid API key will also cause silent failures here (native v6 surfaces `PLYError.Configuration` / `PLYError.configuration` when the key is blank).
6. **Check for nil/null guards** -- a common mistake is silently discarding the presentation (or the `.preload { loaded, error -> }` error) instead of logging it.
### UI Frozen After Paywall Action
The cause differs by platform:
- **Native iOS/Android (v6):** the per-action handler did **not return a `PLYInterceptResult`** on some path (or an `async` handler never resumed). There is no `processAction`/`proceed` callback in v6 — the handler's return value (`.success` / `.failed` / `.notHandled`) is the signal. A handler that throws, hangs on an unawaited async call, or falls through without returning will freeze the paywall.
- **Flutter (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return an `InterceptResult`** on some path (or the `async` handler never completed). There is no `onProcessAction` in Flutter v6 — the handler's returned `InterceptResult` (`success` / `failed` / `notHandled`) is the signal. A handler that throws or falls through without returning will freeze the paywall.
- **React Native (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return a string result** on some path (or the `async` handler never resolved). There is no `onProcessAction` in React Native v6 — the handler's returned string (`'success'` / `'failed'` / `'notHandled'`) is the signal. A handler that throws or falls through without returning will freeze the paywall.
- **Cordova (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return/resolve a `Purchasely.InterceptResult`** on some path (or the returned `Promise` never resolved). There is no `onProcessAction` in Cordova v6 — the handler's returned/resolved result (`Purchasely.InterceptResult.success` / `.failed` / `.notHandled`) is the signal. A handler that throws or returns a `Promise` that never settles will freeze the paywall.
- **Android — second cause, a custom `PLYUIHandler`:** even with a correct interceptor, an `onAlert` branch that displays the app's own dialog and calls neither `proceed()` nor `alert.onDismiss()` freezes the Screen the same way. The alert is the last step of the action that raised it (purchase, restore, plan change) and the SDK keeps that action open until the alert is dismissed. Check this **first** when the freeze happens right after a dialog would have appeared, and when the app migrated from v5 with an existing handler — early v5 releases did not wait for the dismissal, so the missing call was silent there.
1. **Find the interceptor** -- native v6 / Flutter v6 / React Native v6 / Cordova v6: search for `Purchasely.interceptAction`. Older native code may still reference the removed `setPaywallActionsInterceptor` — that won't compile against v6. Older Cordova code may still reference the removed `setPaywallActionInterceptor` / `onProcessAction`.
2. **Audit every code path** -- native v6: every branch (success, failure, cancellation, timeout) MUST return a `PLYInterceptResult`. Flutter v6: every branch MUST return an `InterceptResult`. React Native v6: every branch MUST return a string (`'success'` / `'failed'` / `'notHandled'`). Cordova v6: every branch MUST return or resolve a `Purchasely.InterceptResult`. A missing return / unresolved promise freezes the paywall.
3. **Check async operations** -- if the handler makes an API call (login, server validation), verify it always resolves. Native v6 `async` handlers must reach a `return`; the completion-based form must always invoke the completion. Flutter v6 / React Native v6 `async` handlers must reach a `return`. Cordova v6 handlers returning a `Promise` must always `resolve(...)` (never leave it pending). Look for missing error handlers, timeouts, or network failures that skip it.
4. **Check try/catch blocks** -- native v6: a caught exception must still `return .failed` (or `.notHandled`). Flutter v6: a caught exception must still `return InterceptResult.failed` (or `.notHandled`). React Native v6: a caught exception must still `return 'failed'` (or `'notHandled'`). Cordova v6: a caught exception must still resolve `Purchasely.InterceptResult.failed` (or `.notHandled`).
5. **Audit the UI handler too (Android)** -- search for `Purchasely.uiHandler` / `setUIHandler(`. If `onAlert` is overridden, every branch (each `when` arm, the `else`, early returns, the null-`activity` path, `catch` blocks) must end with exactly one of `proceed()` or `alert.onDismiss()`. Fix by calling `alert.onDismiss()` from the dismiss callback of the custom dialog — after it closes, not before (on a success alert the SDK resumes the flow and closes the Screen) — and never alongside `proceed()`, which would stack the SDK dialog on top. `onDismiss()` is on the `PLYAlertMessage` base class, so a single call covers every alert type. See `../../references/android/api-reference.md` § UI Handler — Alerts.
6. **Fix**: ensure every exit path produces a result. Native v6: wrap in `do/catch` (Swift) / `try/finally` (Kotlin) and return `.failed` on error. Flutter v6: wrap in `try/catch` and `return InterceptResult.failed` on error. React Native v6: wrap in `try/catch` and `return 'failed'` on error. Cordova v6: wrap in `try/catch` (or a `Promise` `.catch(...)`) and resolve `Purchasely.InterceptResult.failed` on error.
### Purchases Not Working
1. **Check running mode** -- search for `runningMode`, `.full`, `.observer`/`PLYRunningMode.Observer`, or `PLYRunningMode`. ⚠️ **On native iOS/Android v6 the default changed from Full to Observer, silently.** If the init does NOT call `.runningMode(.full)` (iOS) / `runningMode(PLYRunningMode.Full)` (Android), the SDK is in Observer mode and will NOT process or validate purchases — this is the #1 "purchases stopped working after upgrading to v6" cause. (`PLYRunningMode.PaywallObserver` was also renamed to `PLYRunningMode.Observer`.)
2. **Full mode**: the SDK handles the purchase flow. Check that store products are correctly configured in the Console and that the store sandbox account is set up. On native v6, a Full-mode purchase with no store configured returns `PLYError.NoStoreConfigured`.
3. **Observer mode**: the app handles purchases itself. Returning `PLYInterceptResult.SUCCESS` from the `purchase`/`restore` interceptor already triggers Purchasely's synchronization automatically — a manual `Purchasely.synchronize()` call inside that interceptor is redundant, not required (it's still needed for purchases made **outside** the interceptor, e.g. a custom sell screen or BYOS). Native v6 Observer mode also does NOT auto-close the paywall (the implicit `close_all` is Full-only) — return `PLYInterceptResult.SUCCESS` to resolve the interceptor, then dismiss with `Purchasely.closeAllScreens()` from your billing-result handler (after the interceptor has resolved), unless a `close` / `close_all` action is configured on the button in the Console.
4. **Check store configuration** -- verify product IDs in Console match the store exactly (case-sensitive). Check that subscriptions/products are approved and available in sandbox.
5. **Check sandbox/test accounts** -- on iOS, verify a Sandbox Apple ID is signed in under Settings > App Store. On Android, verify the test account is in the license testers list.
### SDK Not Initializing
1. **Check the API key** -- find the `start()` call and verify the API key string. A typo or expired key will cause silent failure.
2. **Check the start callback** -- the `start()` method has a completion/callback. Search for it and check if errors are logged or swallowed.
3. **Check network** -- the SDK must reach Purchasely servers during init. Firewalls, VPNs, or lack of connectivity will cause failure.
4. **Android-specific** -- check that stores are correctly listed in the `Builder` (e.g., `GoogleStore`, `HuaweiStore`, `AmazonStore`). A misconfigured store array causes init failure.
5. **iOS-specific** -- check the StoreKit configuration. If using StoreKit Configuration files for testing, ensure they are set in the scheme. Check that the app has the In-App Purchase capability.
6. **React Native / Flutter / Cordova** -- check that the native module is correctly linked. Run `pod install` (iOS) or verify the Gradle dependency (Android).
### Deeplinks Not Working
1. **Check handler method** -- all v6 SDKs (native iOS/Android, Flutter, React Native, Cordova): `handleDeeplink(...)`. The v5 `isDeeplinkHandled(...)` is **removed with no alias** on every v6 SDK — if v6 code still calls `isDeeplinkHandled`, that's the bug; rewrite it to `handleDeeplink`.
2. **Check the deeplink display flag** -- v6 renamed `readyToOpenDeeplink` → `allowDeeplink`. It defaults to **true** on **every** v6 platform, including React Native — there is no RN-specific exception; the RN builder simply omits the key when `.allowDeeplink(...)` isn't called and the native default applies (v6 displays deeplinks/campaigns immediately by default). If an app explicitly set `allowDeeplink(false)`, deeplinks won't display.
3. **Android v6 auto-interception** -- Android v6 reads the foreground activity intent automatically (zero code). **Pitfall:** a `singleTask`/`singleTop` activity that receives the deeplink in `onNewIntent` WITHOUT calling `setIntent(intent)` hides the URI — the SDK never sees it. Verify `setIntent(intent)` is called, or fall back to a manual `handleDeeplink(uri, activity)`. iOS does NOT auto-intercept — `handleDeeplink(url)` must be wired from AppDelegate/SceneDelegate.
4. **Check default presentation dismiss handler** -- native iOS/Android v6, Flutter v6, and React Native v6 all use `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` (Android has always used this name — `setDefaultPresentationResultHandler` never existed there; iOS renamed to it in v6). It must be configured, or the SDK has nowhere to send deeplink/campaign paywall results (`outcome.presentation` is always populated, identifying the closed screen). Since Android `6.0.1`, the handler parameter is nullable — passing `null` unregisters it.
5. **Check URL scheme / universal links** -- verify the app's URL scheme or associated domains are correctly configured and match what the Console generates.
6. **Check timing** -- if `handleDeeplink` is called before `start()` completes, it will silently fail. For a cold-start deeplink, pass it on the init builder (`.handleDeeplink(url)` / `.handleDeeplink(intent.data)`).
### Events Not Firing
1. **Check listener registration timing** -- the event listener must be set AFTER `start()` is called, ideally in the same initialization block or in the start callback.
2. **Check delegate/listener implementation** -- verify the class conforms to the correct protocol/interface and all required methods are implemented (not just optional ones).
3. **Check event names** -- verify the event names being listened for match the ones the Console is configured to send.
4. **Check for multiple registrations** -- if the listener is registered in `onResume`/`viewWillAppear` instead of `onCreate`/`viewDidLoad`, it may fire events multiple times. Search for duplicate registration calls.
### Paywall Showing Wrong Content
1. **Check placement vs presentation** -- a placement can have multiple presentations with audience targeting and A/B tests. The "wrong" content may be the correct one for the current audience.
2. **Check audience targeting** -- verify user attributes are set correctly before building/fetching the presentation. Use `Purchasely.setUserAttribute()` calls and verify they happen before the presentation resolves (native v6 `.preload()` / Flutter v6 `request.preload()` or `request.display(...)` / React Native v6 `request.preload()` or `request.display(...)` / Cordova v6 `request.preload()` or `request.display(...)`). Note: **Android** v6 user-attribute setters return `Deferred<Boolean>` and can be awaited if you need to guarantee ordering (iOS setters return no value).
3. **Check A/B test configuration** -- in the Console, check if an A/B test is active on the placement. The user may be seeing the variant, not the control.
4. **Check caching** -- the SDK caches presentations. During development, try clearing the app data/cache or reinstalling.
5. **Check presentationId vs placementId** -- using `presentationId` directly bypasses placement logic (audiences, A/B tests). Verify the correct method is called.
## Step 3: Take Diagnostic Actions
After identifying the likely issue category:
1. **Search the codebase** for relevant Purchasely integration code using `rg`, `ast-grep`, or `fd`.
2. **Identify the root cause** by tracing the code flow against the diagnostic tree above.
3. **Propose a fix** with concrete code changes. Show the before and after.
4. **If the cause is unclear**, suggest adding temporary debug logging at key points:
- Before and after `start()`
- Where the presentation finishes loading (native v6: the builder `onPresented` / `.preload` result — log type + error; Flutter v6: the builder `.onPresented` / `request.preload()` result — log `presentation.type` + `outcome.error`; React Native v6: the builder `.onPresented()` / `request.preload()` result — log `presentation.type` + `outcome.error`; Cordova v6: the builder `.onLoaded(...)` / `request.preload()` result — log `presentation.type` + error)
- In every branch of the action interceptor
- At each interceptor exit (native v6: log the returned `PLYInterceptResult`; Flutter v6: log the returned `InterceptResult`; React Native v6: log the returned string result; Cordova v6: log the returned/resolved `Purchasely.InterceptResult`)
## Step 4: Common Fixes Database
When you identify one of these patterns, apply the known fix immediately:
| Symptom | Root Cause | Fix |
|---------|-----------|-----|
| Paywall shows briefly then disappears | Fragment/View lifecycle issue; no strong reference kept to the presentation controller | Store the controller/fragment in a property that outlives the current scope |
| Interceptor result has the opposite effect of what's intended | Wrong `PLYInterceptResult` returned (native v6) / wrong `InterceptResult` returned (Flutter v6) / wrong string returned (React Native v6) / wrong `Purchasely.InterceptResult` returned (Cordova v6) | Native v6: `.notHandled` = "SDK, you do it"; `.success` = "I handled it, chain advances"; `.failed` = "I tried and failed, skip the rest". Flutter v6: same semantics with `InterceptResult.notHandled` / `.success` / `.failed`. React Native v6: same semantics with the strings `'notHandled'` / `'success'` / `'failed'`. Cordova v6: same semantics with `Purchasely.InterceptResult.notHandled` / `.success` / `.failed` -- verify the intent matches |
| Events fire twice | Listener registered in `onResume`/`viewWillAppear` instead of `onCreate`/`viewDidLoad` | Move registration to a lifecycle method that runs only once, or guard with a flag |
| User attributes not syncing | `setAttribute` called before `start()` completes | Move `setAttribute` calls into the `start()` completion handler or after it resolves |
| Wrong paywall showing | Confusion between `placementId` and `presentationId`, or audience not matching | Use `placementId` for production flows (respects targeting); `presentationId` only for testing a specific screen |
| Purchase succeeds but status not updated | Observer mode purchase not going through the interceptor at all (or the interceptor never returns `SUCCESS`) | Returning `SUCCESS`/`success` from the `purchase`/`restore` interceptor already triggers synchronization automatically — verify the interceptor is registered and actually resolves with `SUCCESS`. Only add a manual `Purchasely.synchronize()` call for purchases made **outside** the interceptor (a custom sell screen, BYOS) |
| Observer purchase works but paywall freezes | The interceptor never signalled completion after the native purchase finished | Native v6: the `.purchase` handler must `return PLYInterceptResult.SUCCESS` (or `.FAILED`) for every outcome (success, cancel, error) -- a hung/unawaited billing call leaves it unsignalled. Flutter v6: the `PresentationActionKind.purchase` handler must `return InterceptResult.success` (or `.failed`) for every outcome. React Native v6: the `'purchase'` handler must `return 'success'` (or `'failed'`) for every outcome. Cordova v6: return or resolve `Purchasely.InterceptResult.success` (or `.failed`) for every outcome. In decoupled (reactive) architectures, make sure the billing result is mapped back to a returned result / completion for every branch |
| Paywall loads but buttons do nothing | `PLYUIDelegate` / `UIDelegate` not set or not retained | Set the delegate and store a strong reference to the delegate object |
| Android: Screen ignores every tap (close button included) right after a purchase / restore / error dialog | A custom `PLYUIHandler.onAlert` displayed the app's own dialog and called neither `proceed()` nor `alert.onDismiss()`, so the paywall action that raised the alert never completed | End every `onAlert` branch with exactly one of `proceed()` or `alert.onDismiss()`; call `onDismiss()` from the custom dialog's dismiss callback, after it closes. Common after a v5 → v6 migration — early v5 releases did not wait for the dismissal |
| Crash on paywall display (Android) | Application context passed instead of Activity context | Pass the current Activity, not `applicationContext` |
| App freezes after closing a flow paywall (touches don't register) | The X button fires `.close` (back navigation) instead of `.closeAll` (full exit); `PLYWindow` stays alive waiting for a next step that never comes | Fix the paywall in Purchasely Console: change X button action from `close` to `closeAll`. Fallback: map `.close` → `closeAllScreens()` in interceptor. See `../../references/troubleshooting/common-issues.md` §11 |
| Paywall doesn't dismiss after Observer-mode purchase | Observer mode does **not** auto-close (the implicit `close_all` is Full-only), so the app must dismiss itself | Native iOS/Android v6: inside the `.purchase` handler, `return PLYInterceptResult.SUCCESS` (auto-triggers synchronization), then call `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved (do not call it inside the interceptor closure before returning — that races the SDK). Flutter v6: `return InterceptResult.success`, then dismiss with `presentation.close()`. React Native v6: `return 'success'`, then dismiss with `request.close()`. Cordova v6: resolve `Purchasely.InterceptResult.success`, then dismiss with `request.close()` on the held presentation request after the handler resolves (`closePresentation()` is kept as a deprecated alias). |
| Wrong screen reappears after Observer-mode purchase (e.g. the onboarding paywall replays) | The flow hosting the placement chains a post-purchase step to the wrong paywall on the Console | Inspect `flow_id` + `displayed_presentation` in the `PRESENTATION_LOADED` event after purchase. Dashboard → Flows → fix the post-purchase branch |
| Chained follow-up placement shows the wrong/fallback screen | The follow-up `fetchPresentation` resolved against stale subscription state | iOS: await `synchronize()` via `withCheckedThrowingContinuation` BEFORE fetching the next placement. Android: fire-and-forget — accept brief stale-state risk |
| Paywall not updating after Console changes | SDK presentation cache | Clear app data, force kill, or invalidate any app-side cache via an attribute change (iOS `PLYUserAttributeDelegate`) or an explicit `Purchasely.synchronize()` (Android) |
| iOS compile error: *"Call to main actor-isolated class method 'closeAllScreens()' in a synchronous nonisolated context."* | Calling `closeAllScreens()` from a `DispatchQueue.main.async` block, a `synchronize(success:)` callback, or a `nonisolated` delegate | Wrap in `Task { @MainActor in Purchasely.closeAllScreens() }` |
| iOS: presentation re-fetches on every `.onAppear` (and Flow paywalls get stuck) | SDK has no native placement-level cache; repeated fetches accumulate `flowSteps` entries in `FlowsManager` | Add an app-side `PresentationCache` keyed by `placementId[/contentId]`. Invalidate on user-attribute changes and `synchronize()`. See `../../references/ios/common-patterns.md` |
| Cordova (v6): repeated re-fetch / stuck Flow paywall | Re-displaying via a fresh `Purchasely.presentation.placement(id).build().display()` on every navigation refetches from the network | Build the request once, `await request.preload()` to fetch it, then `request.display(transition?)` the **same** request when ready; close programmatically with `request.close()` on the held request. See the preload pattern in `../../references/concepts/presentation-cache.md` |
| Flutter (v6): repeated re-fetch / stuck Flow paywall | Re-displaying via a fresh `PresentationBuilder...build().display()` on every navigation refetches from the network | Build the `PresentationRequest` once, `await request.preload()` to fetch it, then `request.display([Transition])` the **same** request when ready; close programmatically with `presentation.close()` on the loaded `Presentation`. See the preload pattern in `../../references/concepts/presentation-cache.md` |
| React Native (v6): repeated re-fetch / stuck Flow paywall | Re-displaying via a fresh `Purchasely.presentation.placement(id).build().display()` on every navigation refetches from the network | Build the `PLYPresentationRequest` once, `await request.preload()` to fetch it, then `request.display(transition?)` the **same** request when ready; close programmatically with `request.close()` on the held request. See the preload pattern in `../../references/concepts/presentation-cache.md` |
| Cordova (v6): choosing a dismissal method | Cordova exports `Purchasely.closeAllScreens()` as a JavaScript API, and a built presentation request also exposes `request.close()`; both invoke the native `closeAllScreens` action and dismiss all displayed screens | Use `request.close()` as a convenience when you hold the presentation request, or call `Purchasely.closeAllScreens()` directly. `Purchasely.closePresentation()` remains a deprecated alias for the top-level method |
| Flutter (v6): can't find `closePresentation()` / `closeAllScreens()` | Those methods do not exist in Flutter v6 | Dismiss via `presentation.close()` on the loaded `Presentation` (from `request.preload()` or `outcome.presentation`). `presentation.back()` navigates back inside a multi-step (Flow) presentation |
| React Native (v6): can't find `closePresentation()` / `closeAllScreens()` | Those methods do not exist in React Native v6 | Dismiss via `request.close()` on the held `PLYPresentationRequest` (on Android `close()` dismisses all displayed presentations; on iOS it closes the targeted one). `request.back()` navigates back inside a multi-step (Flow) presentation |
| Cordova (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired (the v5 `presentPresentationForPlacement` shorthand that could bypass Flow ownership no longer exists in v6) | Display Flows via `Purchasely.presentation.placement(id).build().display()` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `request.close()` and step back with `request.back()`. `presentPresentationForPlacement` / `fetchPresentation` no longer exist in Cordova v6 |
| Flutter (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired | Display Flows via `PresentationBuilder.placement(id).build().display([Transition])` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `presentation.close()` and step back with `presentation.back()`. `presentPresentationForPlacement`/`fetchPresentation` no longer exist in Flutter v6 |
| React Native (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired | Display Flows via `Purchasely.presentation.placement(id).build().display(transition?)` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `request.close()` and step back with `request.back()`. `presentPresentationForPlacement`/`fetchPresentation` no longer exist in React Native v6 |
| Lottie block is blank, static, or crashes while loading | Lottie is a weak dependency: the app is missing Airbnb Lottie, the iOS `PLYLottieBridge`, the Android `PLYLottieInterface` / `Purchasely.lottieView` registration, or the JSON is too large/unsupported | Add the native bridge and dependency from `../../references/concepts/lottie-animations.md`; keep JSON under 2 MB and validate it in LottieFiles Preview |
| Cordova (v6): native crash on init or missing API | Plugin packages out of alignment | Pin all `@purchasely/cordova-plugin-*` packages to the same `6.2.0` (pulling native iOS `Purchasely 6.2.0` and Android `io.purchasely:core 6.2.0`). See `../../references/sdk-versions.md` |
| Flutter (v6): native crash on init or missing API | Plugin packages out of alignment | Pin `purchasely_flutter`, `purchasely_google` and `purchasely_android_player` to the same `6.2.0` (the stable GA release pulling native iOS `Purchasely 6.2.0` and Android `io.purchasely:core 6.2.0`). See `../../references/sdk-versions.md` |
| React Native (v6): native crash on init or missing API | Plugin packages out of alignment (e.g. `react-native-purchasely 6.2.0` + `@purchasely/react-native-purchasely-google 6.0.0-beta.12`) | Pin all `react-native-purchasely*` packages to the **exact** `6.2.0` (the stable GA pulling native iOS `Purchasely 6.2.0` and Android `io.purchasely:core 6.2.0`). See `../../references/sdk-versions.md` |
| iOS: Lottie block invisible, no crash, no SDK error | The SDK never links `lottie-ios` — it resolves `NSClassFromString("PLYLottieBridge")` at runtime. If the host app doesn't depend on `lottie-ios` and expose that bridge class, nothing renders | Add the `lottie-ios` dependency and an `@objc(PLYLottieBridge)` conformance to the iOS host target. See `../../references/concepts/lottie-animations.md` |
| Purchase spinner stuck after cancelling | The `purchase` action is wired on **both** a container and a child label inside it — one tap fires two `PURCHASE_TAPPED` events; the SDK only restores the node that actually launched StoreKit | Screen Composer fix: keep the `purchase` action on a single element — the outer container that owns the loader — and remove it from the child label/text |
| Flutter: `await request.display(...)` never resolves | The `Future` resolves on the native `onDismissed` event, not on the MethodChannel call's own response. If the native side never emits that event, the `Future` hangs indefinitely with no timeout | Verify the native side dismisses and emits `onDismissed` for every exit path; add an app-side timeout/guard around `display()` if a hard upper bound is needed |
| Flutter: interceptor "never seems to run" | An exception thrown inside the `Purchasely.interceptAction(kind, handler)` callback is caught and swallowed by the bridge, which silently resolves `InterceptResult.failed` — no crash, no visible log | Wrap the handler body in `try/catch` and log inside it; don't rely on an uncaught exception surfacing on its own |
| Android: deeplink works on cold start but not in an already-running app | Activity is `singleTask`/`singleTop` and `onNewIntent` doesn't call `setIntent(intent)`, so the SDK's auto-interception reads the activity's stale intent | Call `setIntent(intent)` inside `onNewIntent`, or forward the URI manually with `Purchasely.handleDeeplink(uri, activity)` |
| Paywall not translated on Indonesian devices (builds < 6.0.1) | Android resolves Indonesian locales against the legacy qualifier `values-in`, but translations shipped under `values-id` | Upgrade to SDK **6.0.1**, which ships resources under the correct qualifier; don't patch app-side resources |
| `PRESENTATION_VIEWED` missing on high-volume Flows (builds < 6.0.0-rc.3) | An internal FIFO buffer tracking already-viewed presentations evicted entries once full (cap of 100), silently dropping the event for the earliest ones in a long session | Upgrade to **6.0.0-rc.3+**, which raised the cap to 200; don't build app-side dedup/backfill logic |
| iOS: app freezes on iPad after closing a campaign | Key-window restoration issue after the campaign's window was torn down; fixed in SDK **6.0.0** | Upgrade rather than adding app-side `makeKeyAndVisible()` workarounds, which don't reliably fix it |
| iOS: callbacks on a preloaded presentation never fire | The preloaded presentation was silently deallocated between preload and display when the app kept no strong reference to it (or to the request that produced it); fixed as of SDK **6.0.0-rc.2** | Keep a reference to the built request / loaded presentation regardless of the fix. See `../../references/concepts/presentation-cache.md` |
| iOS 18.4/18.5 DEBUG build: paywall images re-download on every appearance | An OS-level `URLSession` change makes DEBUG-configuration sessions ephemeral, silently bypassing the on-disk image cache — not a Purchasely regression | Confirm the symptom disappears in a RELEASE/TestFlight build before treating it as an app or SDK cache bug |
| Video `autoplay: false` ignored — video always plays | Open bug in SDK **6.0.0**; the video component has no pause-on-appear hook | No reliable app-side workaround yet; track the fix in a future release rather than patching locally |
## Step 5: Escalate to Purchasely Support (when the root cause is in the Screen / Console)
If the diagnosis points at a **Screen built with the Purchasely Screen Composer** (layout misalignment, missing component, wrong offer displayed even with correct integration code, draft preview that doesn't render, Flow that won't transition), the issue lives outside the codebase. Don't keep patching app code.
Walk the user through the `../../references/troubleshooting/screen-issue-report.md` template:
1. Run the self-checks at the top of the report — enable `LogLevel.DEBUG`, read the logs, activate Debug Mode, try a sandbox tester. Most "Screen bugs" turn out to be integration, targeting, or sandbox issues.
2. If the bug still reproduces, fill in **every field** of the template (Screen URL, observed vs expected, repro steps, screenshots, display method, SDK version + plugin alignment, device, OS, user context, log grep, environment, recent changes).
3. Send the completed report to Purchasely Support.
The template's structure mirrors what Support needs to triage on the first round-trip. Don't compress it — empty fields force back-and-forth.
## Step 6: Decode `PLYError` Cases
When the log contains a `PLYError` case you don't immediately recognize (e.g. `invalidOfferSignature`, `cloudServiceRevoked`, `GoogleDeveloperError`, `InvalidStoreVersion`), look it up in `../../references/troubleshooting/error-codes.md`. That file maps every iOS and Android case to its typical cause and fix, plus the promotional-offer-specific errors and the Google Play Billing v8 hang.
## Completion Build Gate
Before declaring a Purchasely issue fixed, build the user's app with the project's canonical command (prefer the existing CI/build script). If the build fails, fix the error, rerun the build, and run relevant tests again until the app builds successfully. Do not report the bug as resolved from a patch, log check, or manual reasoning alone; include the exact build/test commands and outcomes in the final response.
If the task was diagnosis-only and no code was changed, still run the local app build when available; report any failing build as a blocker rather than claiming the integration is healthy.
## Guidelines
- Always verify your diagnosis by reading the actual code, not by assuming.
- Prefer targeted searches (`rg "Purchasely"`, `ast-grep` for method calls) over reading entire files.
- When multiple issues could explain the symptom, list them ranked by likelihood and check the most likely first.
- If the issue spans native and cross-platform layers (e.g., React Native bridge), check both sides.
- Reference the Purchasely documentation at docs.purchasely.com for the latest API surface if needed.
---
name: purchasely-debug
description: "Use when debugging Purchasely SDK issues — diagnoses common problems like blank paywalls, frozen UI, missing callbacks, purchase failures, and initialization errors across all platforms."
---
# Purchasely SDK Debug Skill
You are a Purchasely SDK integration debugger. Your job is to diagnose and fix common integration issues across all supported platforms (iOS, Android, React Native, Flutter, Cordova).
If `purchasely` is on PATH, use it (skill purchasely-cli) to fetch the placements, products, plans, custom events and campaigns of the app.
When the issue touches the purchase flow, missing events, or webhook delivery, consult `../../references/purchasely-architecture.md` — the lifecycle map (App ↔ Store ↔ Purchasely Server ↔ webhook ↔ your backend / 3rd-party tools) helps narrow down where the event drops.
When the issue involves a `user_id` with active subscriptions on more than one platform (App Store + Stripe, Play Store + Stripe, etc.), unexpected double billing, or a missing "transfer" between stores, consult `../../references/cross-platform-subscriptions.md` — coexistence is the documented default behavior, not a bug.
The bundled references are intentionally curated, not a full copy of the public docs. If the diagnosis depends on an exact SDK signature, current Console behavior, or a detail missing from `../../references/`, verify it against the official Purchasely documentation at https://docs.purchasely.com/ before patching code.
**Universal SDK concept references** (apply to every platform — load as needed during diagnosis):
- `../../references/concepts/paywall-actions.md` — interceptor rules + returned/resolved result invariant (root cause of most "frozen UI" bugs)
- `../../references/concepts/presentation-types.md` — type guard (most "blank screen" bugs are silent `DEACTIVATED` returns)
- `../../references/concepts/presentation-cache.md` — stale presentations / stuck Flow paywalls + preload pattern
- `../../references/concepts/flows.md` — Flow frozen/blank, transition ignored, `Push` without a nav bar, `PLYPresentationOutcome` never delivered
- `../../references/concepts/screen-resolution.md` — wrong Screen served, no Screen at all, published change not visible, prices as a dash, custom font not applied
- `../../references/concepts/localization.md` — untranslated string: Screen content tab vs `ply_*` system string
- `../../references/concepts/observer-mode-post-purchase.md` — intercept-result → dismiss ordering issues
- `../../references/concepts/running-modes.md` — Full vs Observer mode confusion
- `../../references/concepts/programmatic-purchases.md` — wrong app-side purchase API names (`purchase(planId)`, `purchase({ planId })`, Cordova positional callbacks)
- `../../references/concepts/user-identity.md` — `userLogin` ordering bugs (audience matches against anonymous user, subscriptions lost on logout, missing `synchronize` on resume)
- `../../references/concepts/user-attributes-targeting.md` — attributes not flowing to audience targeting
- `../../references/concepts/privacy-settings.md` — consent revocation, optional attributes ignored, Campaigns/analytics disabled
- `../../references/concepts/subscription-checks.md` — "user paid but premium gating doesn't unlock" bugs
- `../../references/concepts/subscription-management.md` — "user cancelled but the app doesn't reflect it" (foreground resync)
- `../../references/concepts/promotional-offers.md` — promo offer not applied / charged at regular price / `invalidOfferSignature`
- `../../references/concepts/campaigns.md` — trigger-based campaigns silently don't fire (`allowDeeplink` / `allowCampaigns` on v6; `allowDeeplink` defaults `true` on **every** v6 platform including React Native — the builder just omits the key when unset; `allowCampaigns` defaults `true` in v6 on iOS/Android/Flutter, was `false` in v5; Android auto-intercepts)
- `../../references/concepts/lottie-animations.md` — blank/static Lottie blocks, missing native bridge/dependency, oversized animation JSON
- `../../references/concepts/analytics-integration.md` — events fire but don't reach Firebase/Amplitude/AppsFlyer (or duplicate)
- `../../references/concepts/custom-events.md` — custom event not visible (name not declared, name mismatch, `analytics` consent refused, SDK < 6.2.0)
- `../../references/architecture-patterns.md` — for projects using a wrapper class, diagnose wrapper-side issues (init order, decoupled Observer billing)
- `../../references/sdk-versions.md` — minimum versions for APIs (e.g. `closeAllScreens()`, Campaigns ≥ 5.1.0, promo offers ≥ 4.0.0)
**Platform-specific references** (load the one matching the project's platform when the bug is platform-specific):
- `../../references/ios/initialization.md` + `../../references/ios/api-reference.md` + `../../references/ios/common-patterns.md`
- `../../references/android/initialization.md` + `../../references/android/api-reference.md` + `../../references/android/common-patterns.md`
- `../../references/react-native/integration.md`
- `../../references/flutter/integration.md`
- `../../references/cordova/integration.md`
**Outdated SDK?** Many "this API doesn't exist" / "Cordova doesn't expose X" reports are because the project is pinned to an old version. First check `../../references/sdk-versions.md` and compare against what's installed.
**Before patching code, read the logs.** The SDK emits a detailed log stream prefixed with `[Purchasely]` plus named analytics events. See `../../references/troubleshooting/common-issues.md` §0 ("Diagnostic Logs — Read Before Patching") for the full event taxonomy, annotated traces (purchase, startup, receipt validation), and the symptom→cause table. Almost every "paywall is broken" issue has its answer in the log stream.
**Troubleshooting toolbox** (load as needed):
- `../../references/troubleshooting/debug-mode.md` — enabling SDK debug logging + Purchasely Debug Mode (preview drafts on device, switch language/theme, target the built-in `Internal Testers` audience)
- `../../references/troubleshooting/error-codes.md` — what each `PLYError` case means (iOS + Android), promotional-offer-specific errors, Google Play Billing v8 hang
- `../../references/troubleshooting/screen-issue-report.md` — template to package when escalating a Screen Composer bug to Purchasely Support
- `../../references/testing/README.md` — sandbox testing (Apple Sandbox Apple ID, Google License Tester)
## Expert checkpoint
Before patching code or declaring a root cause, run a Purchasely expert checkpoint. If the harness exposes the Claude Code subagent `purchasely:purchasely-sdk-expert`, invoke it and pass the platform, SDK version, running mode, logs or symptoms, relevant code paths, suspected root cause, and the smallest proposed fix.
If that subagent is not available, do the checkpoint inline using the `purchasely-sdk-expert` guidance when available, or this fallback checklist:
- Confirm the SDK generation: native iOS uses v6 (`6.2.0`, stable GA); native Android uses v6 (`6.2.0`, stable GA — Android never had a `6.0.0` tag); Flutter uses v6 (`6.2.0`, pulling native iOS `6.2.0` + Android core `6.2.0`); React Native uses v6 (`6.2.0`, stable GA, npm `latest`, pulling native iOS `6.2.0` / Android `6.2.0`); Cordova uses v6 (`6.2.0`, stable GA, npm `latest`, pulling native iOS `6.2.0` / Android `6.2.0`).
- Confirm the suspected root cause matches the SDK logs, not just symptoms.
- Confirm the fix uses current platform APIs and does not introduce removed v6 symbols or invented signatures.
- Confirm running mode is explicit when Full purchase handling is expected.
- Confirm presentation loading/display and dismissal use the correct API for the platform and rendering mode.
- Confirm every interceptor branch resolves exactly once.
- Confirm Observer-mode purchases rely on the automatic synchronization triggered by returning `SUCCESS` from the interceptor (no manual `synchronize()` call required inside it) and use the right dismissal API.
- Confirm any Console-driven, campaign, BYOS, Lottie, or privacy claim was checked against the relevant reference.
Incorporate corrections before editing files or reporting the diagnosis.
## Step 0: Enable Debug Logging — Always Do This First
Almost no integration ticket can be diagnosed without the SDK log stream. Before touching any code, confirm `logLevel` is set to debug for the failing run, then ask the user to reproduce the issue and capture the logs.
| Platform | How to enable |
|----------|---------------|
| iOS (Swift, v6) | `.logLevel(.debug)` on the init builder chain (`Purchasely.apiKey(...).logLevel(.debug)...`) — or set `Purchasely.logLevel = .debug` at runtime |
| Android (Kotlin, v6) | `logLevel(LogLevel.DEBUG)` in the `Purchasely { ... }` DSL or on `Purchasely.Builder` (custom loggers now receive all messages regardless of level; `logcatEnabled` is a separate flag) |
| React Native (TypeScript, v6) | `.logLevel('debug')` on the init builder chain (`Purchasely.builder('…').logLevel('debug')…`) — or `Purchasely.setLogLevel('debug')` at runtime |
| Flutter (v6) | `.logLevel(LogLevel.debug)` on the init builder chain (`PurchaselyBuilder.apiKey(...).logLevel(LogLevel.debug)...`) — or `Purchasely.setLogLevel(LogLevel.debug)` at runtime |
| Cordova | `Purchasely.LogLevel.DEBUG` as the 4th argument to `Purchasely.start(...)` |
> **Production note** — debug logs must be gated behind a build flag (`#if DEBUG`, `BuildConfig.DEBUG`, `__DEV__`, etc.). Shipping `.debug` in a release build leaks placement IDs, audience matches, and presentation IDs into device logs.
After enabling, ask the user to reproduce and grep `[Purchasely]` from the device log (Xcode console, `adb logcat -s Purchasely`, Metro / Flutter terminal, etc.).
**Also consider Debug Mode for visual issues.** If the symptom is "the wrong paywall appears" or "a draft Screen isn't previewing", point the user at `../../references/troubleshooting/debug-mode.md` — Purchasely's Console-side preview lets them validate the Screen on device under the `Internal Testers` audience without touching production.
## Step 1: Gather Context
If `$ARGUMENTS` contains a description of the issue, use it directly. Otherwise, ask the user:
> What issue are you experiencing? Common categories:
> 1. Paywall not showing / blank screen
> 2. UI frozen after an action (e.g., login, restore)
> 3. Purchases not working
> 4. SDK not initializing
> 5. Deeplinks not working
> 6. Events not firing
> 7. Paywall showing wrong content
> 8. Paywall doesn't close after Observer-mode purchase / wrong screen reappears
Identify the platform (iOS, Android, React Native, Flutter, Cordova) from the codebase or by asking.
**Ask for the logs.** Request a grep of `[Purchasely]` (and `[YourApp]` if the integration uses app-side markers) over the failing run. The first red flag (missing `APP_CONFIGURED`, missing `PRESENTATION_LOADED`, `is_fallback_presentation: true`, missing `RECEIPT_VALIDATED`, missing `PRESENTATION_CLOSED`) narrows the diagnostic in one step. See the full symptom→cause table in `../../references/troubleshooting/common-issues.md` §0.
## Step 2: Diagnose Using the Appropriate Tree
### Paywall Not Showing / Blank Screen
1. **Check SDK initialization** -- native v6: search for the init builder (`Purchasely.apiKey(` on iOS, `Purchasely {` / `Purchasely.Builder(` on Android) and `.start(`; Flutter v6: search for `PurchaselyBuilder.apiKey(` and `.start()` (the v5 `Purchasely.start({...})` is gone); React Native v6: search for `Purchasely.builder(` and `.start()` (the v5 `Purchasely.start({...})` object form is gone); Cordova v6: search for `Purchasely.builder(` and `.start()`, or the still-accepted options-object `Purchasely.start({...}, ok, err)` (the v5 positional-args form `Purchasely.start(apiKey, stores, storeKit1, userId, logLevel, runningMode, ok, err)` is gone). Verify the start callback/completion succeeds without errors (native v6 callback is `start { error -> }` / `start { error in }`, a single nullable `PLYError`; Flutter v6 `.start()` returns a `Future<bool>`; React Native v6 `.start()` returns a `Promise<boolean>`; Cordova v6 `.start()` returns a `Promise<boolean>` when called with no callbacks, or accepts `(ok, err)`).
2. **Check placement / builder** -- native v6: find the `PLYPresentation { placementId(...) }` (Android) / `PLYPresentationBuilder.forPlacementId(...)` (iOS) call and confirm `.preload()` (or `.preload { … }`) is actually invoked — a built-but-never-preloaded/displayed presentation shows nothing. Flutter v6: find the `PresentationBuilder.placement(...)` / `.screen(...)` call and confirm `.build()` is followed by `.display(...)` (or `.preload()` then `.display(...)`) — a request that is built but never displayed shows nothing (the v5 `fetchPresentation(`/`presentPresentationForPlacement(` are gone). React Native v6: find the `Purchasely.presentation.placement(...)` / `.screen(...)` call and confirm `.build()` is followed by `.display(...)` (or `.preload()` then `.display(...)`) — a request built but never displayed shows nothing (the v5 `fetchPresentation(`/`presentPresentationForPlacement(` are gone). Cordova v6: find the `Purchasely.presentation.placement(...)` / `.screen(...)` / `.defaultSource()` call and confirm `.build()` is followed by `.display(...)` (or `.preload()` then `.display(...)`) — a request built but never displayed shows nothing (the v5 `fetchPresentation(`/`presentPresentationForPlacement(` are gone). Verify the placement ID string matches one active in the Console (also accept a `screenId(...)` / `.forScreenId(...)` / Flutter/RN/Cordova `.screen(...)` for a direct Screen).
3. **Check the presentation result** -- look at the loaded presentation's `type`. If it is `DEACTIVATED` (Flutter v6: `PresentationType.deactivated`; React Native: `PLYPresentationType.DEACTIVATED`), the Console has disabled it intentionally (blank screen is expected). A common native v6 blank-paywall cause is building the presentation but never calling `display(...)`/`buildView(...)` on the loaded object; the Flutter v6 / React Native v6 equivalent is building a request (Flutter `PresentationRequest` / React Native `PLYPresentationRequest`) but never calling `.display(...)`.
4. **Check display call** -- on iOS, `display(from:)` must run on the main thread. On Android, `display(context)` needs an Activity context (not Application); for embedded use the View comes from `buildView(context) { outcome -> }` (wrap in `AndroidView` for Compose — there is no `PLYPresentationView` composable).
5. **Check network** -- search logs or add temporary logging to confirm the SDK can reach Purchasely servers. A missing or invalid API key will also cause silent failures here (native v6 surfaces `PLYError.Configuration` / `PLYError.configuration` when the key is blank).
6. **Check for nil/null guards** -- a common mistake is silently discarding the presentation (or the `.preload { loaded, error -> }` error) instead of logging it.
### UI Frozen After Paywall Action
The cause differs by platform:
- **Native iOS/Android (v6):** the per-action handler did **not return a `PLYInterceptResult`** on some path (or an `async` handler never resumed). There is no `processAction`/`proceed` callback in v6 — the handler's return value (`.success` / `.failed` / `.notHandled`) is the signal. A handler that throws, hangs on an unawaited async call, or falls through without returning will freeze the paywall.
- **Flutter (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return an `InterceptResult`** on some path (or the `async` handler never completed). There is no `onProcessAction` in Flutter v6 — the handler's returned `InterceptResult` (`success` / `failed` / `notHandled`) is the signal. A handler that throws or falls through without returning will freeze the paywall.
- **React Native (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return a string result** on some path (or the `async` handler never resolved). There is no `onProcessAction` in React Native v6 — the handler's returned string (`'success'` / `'failed'` / `'notHandled'`) is the signal. A handler that throws or falls through without returning will freeze the paywall.
- **Cordova (v6):** the per-action handler passed to `Purchasely.interceptAction(kind, handler)` did **not return/resolve a `Purchasely.InterceptResult`** on some path (or the returned `Promise` never resolved). There is no `onProcessAction` in Cordova v6 — the handler's returned/resolved result (`Purchasely.InterceptResult.success` / `.failed` / `.notHandled`) is the signal. A handler that throws or returns a `Promise` that never settles will freeze the paywall.
- **Android — second cause, a custom `PLYUIHandler`:** even with a correct interceptor, an `onAlert` branch that displays the app's own dialog and calls neither `proceed()` nor `alert.onDismiss()` freezes the Screen the same way. The alert is the last step of the action that raised it (purchase, restore, plan change) and the SDK keeps that action open until the alert is dismissed. Check this **first** when the freeze happens right after a dialog would have appeared, and when the app migrated from v5 with an existing handler — early v5 releases did not wait for the dismissal, so the missing call was silent there.
1. **Find the interceptor** -- native v6 / Flutter v6 / React Native v6 / Cordova v6: search for `Purchasely.interceptAction`. Older native code may still reference the removed `setPaywallActionsInterceptor` — that won't compile against v6. Older Cordova code may still reference the removed `setPaywallActionInterceptor` / `onProcessAction`.
2. **Audit every code path** -- native v6: every branch (success, failure, cancellation, timeout) MUST return a `PLYInterceptResult`. Flutter v6: every branch MUST return an `InterceptResult`. React Native v6: every branch MUST return a string (`'success'` / `'failed'` / `'notHandled'`). Cordova v6: every branch MUST return or resolve a `Purchasely.InterceptResult`. A missing return / unresolved promise freezes the paywall.
3. **Check async operations** -- if the handler makes an API call (login, server validation), verify it always resolves. Native v6 `async` handlers must reach a `return`; the completion-based form must always invoke the completion. Flutter v6 / React Native v6 `async` handlers must reach a `return`. Cordova v6 handlers returning a `Promise` must always `resolve(...)` (never leave it pending). Look for missing error handlers, timeouts, or network failures that skip it.
4. **Check try/catch blocks** -- native v6: a caught exception must still `return .failed` (or `.notHandled`). Flutter v6: a caught exception must still `return InterceptResult.failed` (or `.notHandled`). React Native v6: a caught exception must still `return 'failed'` (or `'notHandled'`). Cordova v6: a caught exception must still resolve `Purchasely.InterceptResult.failed` (or `.notHandled`).
5. **Audit the UI handler too (Android)** -- search for `Purchasely.uiHandler` / `setUIHandler(`. If `onAlert` is overridden, every branch (each `when` arm, the `else`, early returns, the null-`activity` path, `catch` blocks) must end with exactly one of `proceed()` or `alert.onDismiss()`. Fix by calling `alert.onDismiss()` from the dismiss callback of the custom dialog — after it closes, not before (on a success alert the SDK resumes the flow and closes the Screen) — and never alongside `proceed()`, which would stack the SDK dialog on top. `onDismiss()` is on the `PLYAlertMessage` base class, so a single call covers every alert type. See `../../references/android/api-reference.md` § UI Handler — Alerts.
6. **Fix**: ensure every exit path produces a result. Native v6: wrap in `do/catch` (Swift) / `try/finally` (Kotlin) and return `.failed` on error. Flutter v6: wrap in `try/catch` and `return InterceptResult.failed` on error. React Native v6: wrap in `try/catch` and `return 'failed'` on error. Cordova v6: wrap in `try/catch` (or a `Promise` `.catch(...)`) and resolve `Purchasely.InterceptResult.failed` on error.
### Purchases Not Working
1. **Check running mode** -- search for `runningMode`, `.full`, `.observer`/`PLYRunningMode.Observer`, or `PLYRunningMode`. ⚠️ **On native iOS/Android v6 the default changed from Full to Observer, silently.** If the init does NOT call `.runningMode(.full)` (iOS) / `runningMode(PLYRunningMode.Full)` (Android), the SDK is in Observer mode and will NOT process or validate purchases — this is the #1 "purchases stopped working after upgrading to v6" cause. (`PLYRunningMode.PaywallObserver` was also renamed to `PLYRunningMode.Observer`.)
2. **Full mode**: the SDK handles the purchase flow. Check that store products are correctly configured in the Console and that the store sandbox account is set up. On native v6, a Full-mode purchase with no store configured returns `PLYError.NoStoreConfigured`.
3. **Observer mode**: the app handles purchases itself. Returning `PLYInterceptResult.SUCCESS` from the `purchase`/`restore` interceptor already triggers Purchasely's synchronization automatically — a manual `Purchasely.synchronize()` call inside that interceptor is redundant, not required (it's still needed for purchases made **outside** the interceptor, e.g. a custom sell screen or BYOS). Native v6 Observer mode also does NOT auto-close the paywall (the implicit `close_all` is Full-only) — return `PLYInterceptResult.SUCCESS` to resolve the interceptor, then dismiss with `Purchasely.closeAllScreens()` from your billing-result handler (after the interceptor has resolved), unless a `close` / `close_all` action is configured on the button in the Console.
4. **Check store configuration** -- verify product IDs in Console match the store exactly (case-sensitive). Check that subscriptions/products are approved and available in sandbox.
5. **Check sandbox/test accounts** -- on iOS, verify a Sandbox Apple ID is signed in under Settings > App Store. On Android, verify the test account is in the license testers list.
### SDK Not Initializing
1. **Check the API key** -- find the `start()` call and verify the API key string. A typo or expired key will cause silent failure.
2. **Check the start callback** -- the `start()` method has a completion/callback. Search for it and check if errors are logged or swallowed.
3. **Check network** -- the SDK must reach Purchasely servers during init. Firewalls, VPNs, or lack of connectivity will cause failure.
4. **Android-specific** -- check that stores are correctly listed in the `Builder` (e.g., `GoogleStore`, `HuaweiStore`, `AmazonStore`). A misconfigured store array causes init failure.
5. **iOS-specific** -- check the StoreKit configuration. If using StoreKit Configuration files for testing, ensure they are set in the scheme. Check that the app has the In-App Purchase capability.
6. **React Native / Flutter / Cordova** -- check that the native module is correctly linked. Run `pod install` (iOS) or verify the Gradle dependency (Android).
### Deeplinks Not Working
1. **Check handler method** -- all v6 SDKs (native iOS/Android, Flutter, React Native, Cordova): `handleDeeplink(...)`. The v5 `isDeeplinkHandled(...)` is **removed with no alias** on every v6 SDK — if v6 code still calls `isDeeplinkHandled`, that's the bug; rewrite it to `handleDeeplink`.
2. **Check the deeplink display flag** -- v6 renamed `readyToOpenDeeplink` → `allowDeeplink`. It defaults to **true** on **every** v6 platform, including React Native — there is no RN-specific exception; the RN builder simply omits the key when `.allowDeeplink(...)` isn't called and the native default applies (v6 displays deeplinks/campaigns immediately by default). If an app explicitly set `allowDeeplink(false)`, deeplinks won't display.
3. **Android v6 auto-interception** -- Android v6 reads the foreground activity intent automatically (zero code). **Pitfall:** a `singleTask`/`singleTop` activity that receives the deeplink in `onNewIntent` WITHOUT calling `setIntent(intent)` hides the URI — the SDK never sees it. Verify `setIntent(intent)` is called, or fall back to a manual `handleDeeplink(uri, activity)`. iOS does NOT auto-intercept — `handleDeeplink(url)` must be wired from AppDelegate/SceneDelegate.
4. **Check default presentation dismiss handler** -- native iOS/Android v6, Flutter v6, and React Native v6 all use `Purchasely.setDefaultPresentationDismissHandler((outcome) => …)` (Android has always used this name — `setDefaultPresentationResultHandler` never existed there; iOS renamed to it in v6). It must be configured, or the SDK has nowhere to send deeplink/campaign paywall results (`outcome.presentation` is always populated, identifying the closed screen). Since Android `6.0.1`, the handler parameter is nullable — passing `null` unregisters it.
5. **Check URL scheme / universal links** -- verify the app's URL scheme or associated domains are correctly configured and match what the Console generates.
6. **Check timing** -- if `handleDeeplink` is called before `start()` completes, it will silently fail. For a cold-start deeplink, pass it on the init builder (`.handleDeeplink(url)` / `.handleDeeplink(intent.data)`).
### Events Not Firing
1. **Check listener registration timing** -- the event listener must be set AFTER `start()` is called, ideally in the same initialization block or in the start callback.
2. **Check delegate/listener implementation** -- verify the class conforms to the correct protocol/interface and all required methods are implemented (not just optional ones).
3. **Check event names** -- verify the event names being listened for match the ones the Console is configured to send.
4. **Check for multiple registrations** -- if the listener is registered in `onResume`/`viewWillAppear` instead of `onCreate`/`viewDidLoad`, it may fire events multiple times. Search for duplicate registration calls.
### Paywall Showing Wrong Content
1. **Check placement vs presentation** -- a placement can have multiple presentations with audience targeting and A/B tests. The "wrong" content may be the correct one for the current audience.
2. **Check audience targeting** -- verify user attributes are set correctly before building/fetching the presentation. Use `Purchasely.setUserAttribute()` calls and verify they happen before the presentation resolves (native v6 `.preload()` / Flutter v6 `request.preload()` or `request.display(...)` / React Native v6 `request.preload()` or `request.display(...)` / Cordova v6 `request.preload()` or `request.display(...)`). Note: **Android** v6 user-attribute setters return `Deferred<Boolean>` and can be awaited if you need to guarantee ordering (iOS setters return no value).
3. **Check A/B test configuration** -- in the Console, check if an A/B test is active on the placement. The user may be seeing the variant, not the control.
4. **Check caching** -- the SDK caches presentations. During development, try clearing the app data/cache or reinstalling.
5. **Check presentationId vs placementId** -- using `presentationId` directly bypasses placement logic (audiences, A/B tests). Verify the correct method is called.
## Step 3: Take Diagnostic Actions
After identifying the likely issue category:
1. **Search the codebase** for relevant Purchasely integration code using `rg`, `ast-grep`, or `fd`.
2. **Identify the root cause** by tracing the code flow against the diagnostic tree above.
3. **Propose a fix** with concrete code changes. Show the before and after.
4. **If the cause is unclear**, suggest adding temporary debug logging at key points:
- Before and after `start()`
- Where the presentation finishes loading (native v6: the builder `onPresented` / `.preload` result — log type + error; Flutter v6: the builder `.onPresented` / `request.preload()` result — log `presentation.type` + `outcome.error`; React Native v6: the builder `.onPresented()` / `request.preload()` result — log `presentation.type` + `outcome.error`; Cordova v6: the builder `.onLoaded(...)` / `request.preload()` result — log `presentation.type` + error)
- In every branch of the action interceptor
- At each interceptor exit (native v6: log the returned `PLYInterceptResult`; Flutter v6: log the returned `InterceptResult`; React Native v6: log the returned string result; Cordova v6: log the returned/resolved `Purchasely.InterceptResult`)
## Step 4: Common Fixes Database
When you identify one of these patterns, apply the known fix immediately:
| Symptom | Root Cause | Fix |
|---------|-----------|-----|
| Paywall shows briefly then disappears | Fragment/View lifecycle issue; no strong reference kept to the presentation controller | Store the controller/fragment in a property that outlives the current scope |
| Interceptor result has the opposite effect of what's intended | Wrong `PLYInterceptResult` returned (native v6) / wrong `InterceptResult` returned (Flutter v6) / wrong string returned (React Native v6) / wrong `Purchasely.InterceptResult` returned (Cordova v6) | Native v6: `.notHandled` = "SDK, you do it"; `.success` = "I handled it, chain advances"; `.failed` = "I tried and failed, skip the rest". Flutter v6: same semantics with `InterceptResult.notHandled` / `.success` / `.failed`. React Native v6: same semantics with the strings `'notHandled'` / `'success'` / `'failed'`. Cordova v6: same semantics with `Purchasely.InterceptResult.notHandled` / `.success` / `.failed` -- verify the intent matches |
| Events fire twice | Listener registered in `onResume`/`viewWillAppear` instead of `onCreate`/`viewDidLoad` | Move registration to a lifecycle method that runs only once, or guard with a flag |
| User attributes not syncing | `setAttribute` called before `start()` completes | Move `setAttribute` calls into the `start()` completion handler or after it resolves |
| Wrong paywall showing | Confusion between `placementId` and `presentationId`, or audience not matching | Use `placementId` for production flows (respects targeting); `presentationId` only for testing a specific screen |
| Purchase succeeds but status not updated | Observer mode purchase not going through the interceptor at all (or the interceptor never returns `SUCCESS`) | Returning `SUCCESS`/`success` from the `purchase`/`restore` interceptor already triggers synchronization automatically — verify the interceptor is registered and actually resolves with `SUCCESS`. Only add a manual `Purchasely.synchronize()` call for purchases made **outside** the interceptor (a custom sell screen, BYOS) |
| Observer purchase works but paywall freezes | The interceptor never signalled completion after the native purchase finished | Native v6: the `.purchase` handler must `return PLYInterceptResult.SUCCESS` (or `.FAILED`) for every outcome (success, cancel, error) -- a hung/unawaited billing call leaves it unsignalled. Flutter v6: the `PresentationActionKind.purchase` handler must `return InterceptResult.success` (or `.failed`) for every outcome. React Native v6: the `'purchase'` handler must `return 'success'` (or `'failed'`) for every outcome. Cordova v6: return or resolve `Purchasely.InterceptResult.success` (or `.failed`) for every outcome. In decoupled (reactive) architectures, make sure the billing result is mapped back to a returned result / completion for every branch |
| Paywall loads but buttons do nothing | `PLYUIDelegate` / `UIDelegate` not set or not retained | Set the delegate and store a strong reference to the delegate object |
| Android: Screen ignores every tap (close button included) right after a purchase / restore / error dialog | A custom `PLYUIHandler.onAlert` displayed the app's own dialog and called neither `proceed()` nor `alert.onDismiss()`, so the paywall action that raised the alert never completed | End every `onAlert` branch with exactly one of `proceed()` or `alert.onDismiss()`; call `onDismiss()` from the custom dialog's dismiss callback, after it closes. Common after a v5 → v6 migration — early v5 releases did not wait for the dismissal |
| Crash on paywall display (Android) | Application context passed instead of Activity context | Pass the current Activity, not `applicationContext` |
| App freezes after closing a flow paywall (touches don't register) | The X button fires `.close` (back navigation) instead of `.closeAll` (full exit); `PLYWindow` stays alive waiting for a next step that never comes | Fix the paywall in Purchasely Console: change X button action from `close` to `closeAll`. Fallback: map `.close` → `closeAllScreens()` in interceptor. See `../../references/troubleshooting/common-issues.md` §11 |
| Paywall doesn't dismiss after Observer-mode purchase | Observer mode does **not** auto-close (the implicit `close_all` is Full-only), so the app must dismiss itself | Native iOS/Android v6: inside the `.purchase` handler, `return PLYInterceptResult.SUCCESS` (auto-triggers synchronization), then call `Purchasely.closeAllScreens()` from your billing-result handler **after** the interceptor has resolved (do not call it inside the interceptor closure before returning — that races the SDK). Flutter v6: `return InterceptResult.success`, then dismiss with `presentation.close()`. React Native v6: `return 'success'`, then dismiss with `request.close()`. Cordova v6: resolve `Purchasely.InterceptResult.success`, then dismiss with `request.close()` on the held presentation request after the handler resolves (`closePresentation()` is kept as a deprecated alias). |
| Wrong screen reappears after Observer-mode purchase (e.g. the onboarding paywall replays) | The flow hosting the placement chains a post-purchase step to the wrong paywall on the Console | Inspect `flow_id` + `displayed_presentation` in the `PRESENTATION_LOADED` event after purchase. Dashboard → Flows → fix the post-purchase branch |
| Chained follow-up placement shows the wrong/fallback screen | The follow-up `fetchPresentation` resolved against stale subscription state | iOS: await `synchronize()` via `withCheckedThrowingContinuation` BEFORE fetching the next placement. Android: fire-and-forget — accept brief stale-state risk |
| Paywall not updating after Console changes | SDK presentation cache | Clear app data, force kill, or invalidate any app-side cache via an attribute change (iOS `PLYUserAttributeDelegate`) or an explicit `Purchasely.synchronize()` (Android) |
| iOS compile error: *"Call to main actor-isolated class method 'closeAllScreens()' in a synchronous nonisolated context."* | Calling `closeAllScreens()` from a `DispatchQueue.main.async` block, a `synchronize(success:)` callback, or a `nonisolated` delegate | Wrap in `Task { @MainActor in Purchasely.closeAllScreens() }` |
| iOS: presentation re-fetches on every `.onAppear` (and Flow paywalls get stuck) | SDK has no native placement-level cache; repeated fetches accumulate `flowSteps` entries in `FlowsManager` | Add an app-side `PresentationCache` keyed by `placementId[/contentId]`. Invalidate on user-attribute changes and `synchronize()`. See `../../references/ios/common-patterns.md` |
| Cordova (v6): repeated re-fetch / stuck Flow paywall | Re-displaying via a fresh `Purchasely.presentation.placement(id).build().display()` on every navigation refetches from the network | Build the request once, `await request.preload()` to fetch it, then `request.display(transition?)` the **same** request when ready; close programmatically with `request.close()` on the held request. See the preload pattern in `../../references/concepts/presentation-cache.md` |
| Flutter (v6): repeated re-fetch / stuck Flow paywall | Re-displaying via a fresh `PresentationBuilder...build().display()` on every navigation refetches from the network | Build the `PresentationRequest` once, `await request.preload()` to fetch it, then `request.display([Transition])` the **same** request when ready; close programmatically with `presentation.close()` on the loaded `Presentation`. See the preload pattern in `../../references/concepts/presentation-cache.md` |
| React Native (v6): repeated re-fetch / stuck Flow paywall | Re-displaying via a fresh `Purchasely.presentation.placement(id).build().display()` on every navigation refetches from the network | Build the `PLYPresentationRequest` once, `await request.preload()` to fetch it, then `request.display(transition?)` the **same** request when ready; close programmatically with `request.close()` on the held request. See the preload pattern in `../../references/concepts/presentation-cache.md` |
| Cordova (v6): choosing a dismissal method | Cordova exports `Purchasely.closeAllScreens()` as a JavaScript API, and a built presentation request also exposes `request.close()`; both invoke the native `closeAllScreens` action and dismiss all displayed screens | Use `request.close()` as a convenience when you hold the presentation request, or call `Purchasely.closeAllScreens()` directly. `Purchasely.closePresentation()` remains a deprecated alias for the top-level method |
| Flutter (v6): can't find `closePresentation()` / `closeAllScreens()` | Those methods do not exist in Flutter v6 | Dismiss via `presentation.close()` on the loaded `Presentation` (from `request.preload()` or `outcome.presentation`). `presentation.back()` navigates back inside a multi-step (Flow) presentation |
| React Native (v6): can't find `closePresentation()` / `closeAllScreens()` | Those methods do not exist in React Native v6 | Dismiss via `request.close()` on the held `PLYPresentationRequest` (on Android `close()` dismisses all displayed presentations; on iOS it closes the targeted one). `request.back()` navigates back inside a multi-step (Flow) presentation |
| Cordova (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired (the v5 `presentPresentationForPlacement` shorthand that could bypass Flow ownership no longer exists in v6) | Display Flows via `Purchasely.presentation.placement(id).build().display()` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `request.close()` and step back with `request.back()`. `presentPresentationForPlacement` / `fetchPresentation` no longer exist in Cordova v6 |
| Flutter (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired | Display Flows via `PresentationBuilder.placement(id).build().display([Transition])` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `presentation.close()` and step back with `presentation.back()`. `presentPresentationForPlacement`/`fetchPresentation` no longer exist in Flutter v6 |
| React Native (v6): Flow paywall opens but cannot be closed | Wrong display entry point or no programmatic dismiss wired | Display Flows via `Purchasely.presentation.placement(id).build().display(transition?)` (or `preload()` then `display()`); the v6 request correctly owns the Flow window. Dismiss with `request.close()` and step back with `request.back()`. `presentPresentationForPlacement`/`fetchPresentation` no longer exist in React Native v6 |
| Lottie block is blank, static, or crashes while loading | Lottie is a weak dependency: the app is missing Airbnb Lottie, the iOS `PLYLottieBridge`, the Android `PLYLottieInterface` / `Purchasely.lottieView` registration, or the JSON is too large/unsupported | Add the native bridge and dependency from `../../references/concepts/lottie-animations.md`; keep JSON under 2 MB and validate it in LottieFiles Preview |
| Cordova (v6): native crash on init or missing API | Plugin packages out of alignment | Pin all `@purchasely/cordova-plugin-*` packages to the same `6.2.0` (pulling native iOS `Purchasely 6.2.0` and Android `io.purchasely:core 6.2.0`). See `../../references/sdk-versions.md` |
| Flutter (v6): native crash on init or missing API | Plugin packages out of alignment | Pin `purchasely_flutter`, `purchasely_google` and `purchasely_android_player` to the same `6.2.0` (the stable GA release pulling native iOS `Purchasely 6.2.0` and Android `io.purchasely:core 6.2.0`). See `../../references/sdk-versions.md` |
| React Native (v6): native crash on init or missing API | Plugin packages out of alignment (e.g. `react-native-purchasely 6.2.0` + `@purchasely/react-native-purchasely-google 6.0.0-beta.12`) | Pin all `react-native-purchasely*` packages to the **exact** `6.2.0` (the stable GA pulling native iOS `Purchasely 6.2.0` and Android `io.purchasely:core 6.2.0`). See `../../references/sdk-versions.md` |
| iOS: Lottie block invisible, no crash, no SDK error | The SDK never links `lottie-ios` — it resolves `NSClassFromString("PLYLottieBridge")` at runtime. If the host app doesn't depend on `lottie-ios` and expose that bridge class, nothing renders | Add the `lottie-ios` dependency and an `@objc(PLYLottieBridge)` conformance to the iOS host target. See `../../references/concepts/lottie-animations.md` |
| Purchase spinner stuck after cancelling | The `purchase` action is wired on **both** a container and a child label inside it — one tap fires two `PURCHASE_TAPPED` events; the SDK only restores the node that actually launched StoreKit | Screen Composer fix: keep the `purchase` action on a single element — the outer container that owns the loader — and remove it from the child label/text |
| Flutter: `await request.display(...)` never resolves | The `Future` resolves on the native `onDismissed` event, not on the MethodChannel call's own response. If the native side never emits that event, the `Future` hangs indefinitely with no timeout | Verify the native side dismisses and emits `onDismissed` for every exit path; add an app-side timeout/guard around `display()` if a hard upper bound is needed |
| Flutter: interceptor "never seems to run" | An exception thrown inside the `Purchasely.interceptAction(kind, handler)` callback is caught and swallowed by the bridge, which silently resolves `InterceptResult.failed` — no crash, no visible log | Wrap the handler body in `try/catch` and log inside it; don't rely on an uncaught exception surfacing on its own |
| Android: deeplink works on cold start but not in an already-running app | Activity is `singleTask`/`singleTop` and `onNewIntent` doesn't call `setIntent(intent)`, so the SDK's auto-interception reads the activity's stale intent | Call `setIntent(intent)` inside `onNewIntent`, or forward the URI manually with `Purchasely.handleDeeplink(uri, activity)` |
| Paywall not translated on Indonesian devices (builds < 6.0.1) | Android resolves Indonesian locales against the legacy qualifier `values-in`, but translations shipped under `values-id` | Upgrade to SDK **6.0.1**, which ships resources under the correct qualifier; don't patch app-side resources |
| `PRESENTATION_VIEWED` missing on high-volume Flows (builds < 6.0.0-rc.3) | An internal FIFO buffer tracking already-viewed presentations evicted entries once full (cap of 100), silently dropping the event for the earliest ones in a long session | Upgrade to **6.0.0-rc.3+**, which raised the cap to 200; don't build app-side dedup/backfill logic |
| iOS: app freezes on iPad after closing a campaign | Key-window restoration issue after the campaign's window was torn down; fixed in SDK **6.0.0** | Upgrade rather than adding app-side `makeKeyAndVisible()` workarounds, which don't reliably fix it |
| iOS: callbacks on a preloaded presentation never fire | The preloaded presentation was silently deallocated between preload and display when the app kept no strong reference to it (or to the request that produced it); fixed as of SDK **6.0.0-rc.2** | Keep a reference to the built request / loaded presentation regardless of the fix. See `../../references/concepts/presentation-cache.md` |
| iOS 18.4/18.5 DEBUG build: paywall images re-download on every appearance | An OS-level `URLSession` change makes DEBUG-configuration sessions ephemeral, silently bypassing the on-disk image cache — not a Purchasely regression | Confirm the symptom disappears in a RELEASE/TestFlight build before treating it as an app or SDK cache bug |
| Video `autoplay: false` ignored — video always plays | Open bug in SDK **6.0.0**; the video component has no pause-on-appear hook | No reliable app-side workaround yet; track the fix in a future release rather than patching locally |
## Step 5: Escalate to Purchasely Support (when the root cause is in the Screen / Console)
If the diagnosis points at a **Screen built with the Purchasely Screen Composer** (layout misalignment, missing component, wrong offer displayed even with correct integration code, draft preview that doesn't render, Flow that won't transition), the issue lives outside the codebase. Don't keep patching app code.
Walk the user through the `../../references/troubleshooting/screen-issue-report.md` template:
1. Run the self-checks at the top of the report — enable `LogLevel.DEBUG`, read the logs, activate Debug Mode, try a sandbox tester. Most "Screen bugs" turn out to be integration, targeting, or sandbox issues.
2. If the bug still reproduces, fill in **every field** of the template (Screen URL, observed vs expected, repro steps, screenshots, display method, SDK version + plugin alignment, device, OS, user context, log grep, environment, recent changes).
3. Send the completed report to Purchasely Support.
The template's structure mirrors what Support needs to triage on the first round-trip. Don't compress it — empty fields force back-and-forth.
## Step 6: Decode `PLYError` Cases
When the log contains a `PLYError` case you don't immediately recognize (e.g. `invalidOfferSignature`, `cloudServiceRevoked`, `GoogleDeveloperError`, `InvalidStoreVersion`), look it up in `../../references/troubleshooting/error-codes.md`. That file maps every iOS and Android case to its typical cause and fix, plus the promotional-offer-specific errors and the Google Play Billing v8 hang.
## Completion Build Gate
Before declaring a Purchasely issue fixed, build the user's app with the project's canonical command (prefer the existing CI/build script). If the build fails, fix the error, rerun the build, and run relevant tests again until the app builds successfully. Do not report the bug as resolved from a patch, log check, or manual reasoning alone; include the exact build/test commands and outcomes in the final response.
If the task was diagnosis-only and no code was changed, still run the local app build when available; report any failing build as a blocker rather than claiming the integration is healthy.
## Guidelines
- Always verify your diagnosis by reading the actual code, not by assuming.
- Prefer targeted searches (`rg "Purchasely"`, `ast-grep` for method calls) over reading entire files.
- When multiple issues could explain the symptom, list them ranked by likelihood and check the most likely first.
- If the issue spans native and cross-platform layers (e.g., React Native bridge), check both sides.
- Reference the Purchasely documentation at docs.purchasely.com for the latest API surface if needed.
Imported from the listed AgentSkill.sh category. Complete supplied package files and notices are retained. Required external tools and credentials are not installed. No bundled code was executed during import. Individual agent task behavior has not been tested.