Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

### Added

- `references/concepts/user-attributes-targeting.md` — new "When an attribute change takes effect (timing)" section: `setUserAttribute` saves the value (persisted across sessions) but does not re-evaluate placements/campaigns; the value is applied only on the next placement fetch or campaign trigger.
- `references/concepts/campaigns.md` — new "Custom-attribute audiences: set the attribute before campaigns are evaluated" section explaining why a custom-attribute campaign misses on first launch (matches from the next session once the value is persisted) and the `allowCampaigns(false)` → set attribute → `allowCampaigns(true)` recipe for reliable first-launch matching.

### Changed

- `purchasely-sdk-expert` and `purchasely-debug` now state the user-attribute → campaign timing rule: setting an attribute does not re-trigger targeting, and custom-attribute audiences are gated with `allowCampaigns` (ordering: start → set attributes → allow campaigns).
- `references/troubleshooting/common-issues.md` §7 now covers the "campaign on a custom-attribute audience is hit-or-miss on first launch" symptom and its fix.

## [2.0.0-rc.3] — 2026-06-17

Flutter joins the **v6 line**, and skills-only installs gain first-class expert Q&A. Flutter guidance moves off v5 to match native iOS & Android — the published Flutter SDK now targets **Purchasely v6.0.0-rc.1** with the builder API. A new portable `purchasely-sdk-expert` skill brings free-form SDK Q&A to harnesses without the Claude Code subagent. **React Native and Cordova stay on v5 (`5.7.3`).**
Expand Down
22 changes: 22 additions & 0 deletions purchasely/references/concepts/campaigns.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ await PurchaselyBuilder.apiKey('<YOUR_API_KEY>')
> **v6 native:** `allowCampaigns` and `allowDeeplink` are **independent** flags (in v5 a single flag governed both). Control campaign display with `allowCampaigns`; control deeplink presentations with `allowDeeplink` (defaults to `true`). Android also **auto-intercepts** deeplinks, so no manual `handleDeeplink` call is required for them.
> If you implement a [UI Handler](https://docs.purchasely.com/docs/ui-handler-deeplinks) to manage deeplink display yourself, **keep the presentation object returned** and do not refetch it — refetching loses the campaign context.

### Custom-attribute audiences: set the attribute before campaigns are evaluated

A trigger-based campaign's audience is evaluated **when the trigger resolves** — for the default `APP_STARTED` trigger, shortly after SDK start. The attributes used are whatever the SDK holds **at that moment**. Setting a [user attribute](user-attributes-targeting.md#when-an-attribute-change-takes-effect-timing) does **not** re-trigger or re-evaluate a campaign; it is saved (and persisted across sessions) and only applied on the *next* trigger resolution or placement fetch.

So if your audience is built on a custom attribute (e.g. an AppsFlyer campaign id) that the app sets after start, the campaign **won't match on the first launch**. Since the value is persisted in the SDK's disk cache, it is present at the next cold start, so the campaign matches **from the next session** — which makes the symptom look intermittent.

Two ways to handle it:

1. **Accept the first-launch miss.** Do nothing special; the campaign matches from the next session once the attribute is persisted.
2. **Gate campaigns until attributes are set (reliable on first launch).** Initialize with `allowCampaigns(false)`, set your attributes, then flip `allowCampaigns(true)` — the queued trigger resolves with the attribute present. Ordering: **start → set user attributes → `allowCampaigns(true)`**.

```swift
// iOS — make a custom-attribute audience match on the very first launch
Purchasely.apiKey("YOUR_API_KEY").allowCampaigns(false).start { _ in }
// once the AppsFlyer (or other) value is known:
Purchasely.setUserAttribute(withStringValue: campaignId, forKey: "appsflyer_last_campaign_id")
Purchasely.allowCampaigns(true) // queued campaign now resolves its audience with the attribute set
```

> Add a safety timeout: if the attribute source (e.g. an AppsFlyer conversion callback) never returns, call `allowCampaigns(true)` anyway after a few seconds so users without that attribute still get the normal campaign flow.

## Placement-based campaigns — no extra SDK code

You already fetch the placement (native iOS/Android v6: `PLYPresentationBuilder.forPlacementId("PLACEMENT_ID")` / `PLYPresentation { placementId("PLACEMENT_ID") }`; Flutter v6: `PresentationBuilder.placement("PLACEMENT_ID").build()`; React Native / Cordova v5: `fetchPresentation("PLACEMENT_ID")`). When a campaign targets that placement and the user matches the audience, the SDK substitutes the campaign's Screen for the Placement's default rules. Same `PLYPresentationType` handling, same display path. Nothing to change in your code.
Expand Down Expand Up @@ -113,6 +134,7 @@ Property bag includes `campaign_id`, `campaign_name`, `screen_id`, `audience_id`
- ❌ **Leaving campaigns gated.** If you set `allowCampaigns = false` (native iOS/Android v6 and Flutter v6) / `readyToOpenDeeplink(false)` (React Native / Cordova v5) and never flip it back, trigger-based campaigns silently never appear.
- ❌ **Re-enabling campaigns too early.** If your splash screen runs after `start()`, flipping `allowCampaigns = true` (native iOS/Android v6 and Flutter v6) / `readyToOpenDeeplink(true)` (React Native / Cordova v5) while it is still up lands the campaign paywall on top of the splash. Wait until your launch routine is complete.
- ❌ **Coupling capping logic to placement-based campaigns.** Capping only applies on triggers — if you need capping on a placement, build the cap into your audience attribute or use a trigger.
- ❌ **Assuming `setUserAttribute` re-launches campaign targeting.** It does not. The attribute is saved but no campaign/placement is re-evaluated — only the next trigger resolution or placement fetch uses it. For a custom-attribute audience, set the attribute *before* campaigns are evaluated (see [above](#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated)).
- ❌ **Refetching the presentation returned by the deeplink handler.** You lose the campaign context (audience match, screen variant, exposure tracking).
- ❌ **Targeting subscribers with promotional offers without eligibility audience.** See [promotional-offers.md](promotional-offers.md#eligibility-is-your-responsibility-promotional-offers--developer-determined-offers).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ In v6 the `.purchase` interceptor is an async callback that **returns** an `Inte
Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async {
if (payload is! PurchasePayload) return InterceptResult.notHandled;

final purchased = await myBilling.purchase(payload.plan['productId']);
final purchased = await myBilling.purchase(payload.plan.productId);
if (!purchased) return InterceptResult.failed;

await Purchasely.synchronize(); // resolves once the native bridge confirms
Expand Down
2 changes: 1 addition & 1 deletion purchasely/references/concepts/promotional-offers.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Purchasely.setPaywallActionInterceptorCallback((result) => {

#### Flutter (Dart) — `PurchasePayload` from the per-action interceptor

In v6 Flutter mirrors the native per-action model: register `Purchasely.interceptAction` for the purchase kind and return an `InterceptResult`.
In v6 Flutter mirrors the native per-action model: register `Purchasely.interceptAction` for the purchase kind and return an `InterceptResult`. `PurchasePayload` carries real Dart model objects (`PLYPlan`, nullable `PLYSubscriptionOffer`, nullable `PLYPromoOffer`), so read properties instead of indexing maps.

```dart
Purchasely.interceptAction(PresentationActionKind.purchase, (info, payload) async {
Expand Down
20 changes: 20 additions & 0 deletions purchasely/references/concepts/user-attributes-targeting.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ User attributes are key-value pairs the SDK forwards to Purchasely servers. They

> **Attribute changes can change which audience matches** → invalidate any [presentation cache](presentation-cache.md) after setting attributes.

## When an attribute change takes effect (timing)

Setting an attribute is **not** an event the SDK reacts to. `setUserAttribute(...)` saves the value immediately (and persists it across sessions in the SDK's own disk cache, so you only have to set it once), but it does **not** re-run, re-fetch, or re-evaluate any placement or campaign on its own. The new value is applied to audience matching only on the **next call** that resolves a screen:

- **Placements:** the next presentation fetch/build picks up the value (native v6 `PLYPresentationBuilder.forPlacementId(...).build()` / `.preload()`, Flutter v6 `PresentationBuilder.placement(...).build()` → `preload()` / `display(...)`, React Native / Cordova v5 `fetchPresentation(...)`). So set the attribute **before** you fetch the placement.
- **Campaigns:** the audience is evaluated when the campaign trigger resolves — for the default `APP_STARTED` trigger this happens **shortly after SDK start** (or when you flip [`allowCampaigns(true)`](campaigns.md#sdk-setup--gating-campaign-display) if you gated it). Setting the attribute afterwards has no effect on that already-resolved trigger.

**Consequence for a campaign whose audience is built on a custom attribute:** if the attribute is set *after* the SDK has already evaluated campaigns at start, the audience will **not** match on the **first launch**. Because the value is persisted, it is present at the next cold start, so the campaign matches **from the next session** onward — which is why this kind of bug looks intermittent ("it worked once").

To make it reliable on the **first** launch, set the attribute before campaigns are evaluated. The recommended ordering is **start SDK → set user attributes → allow campaigns**, gating campaigns until your attributes are set:

```swift
// iOS — defer campaigns, set attributes, then release
Purchasely.apiKey("YOUR_API_KEY").allowCampaigns(false).start { _ in }
Purchasely.setUserAttribute(withStringValue: campaignId, forKey: "appsflyer_last_campaign_id")
Purchasely.allowCampaigns(true) // the campaign trigger now resolves with the attribute present
```

See [campaigns.md](campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated) for the per-platform `allowCampaigns` API and the full recipe.

## Supported attribute types

Same on every platform; only the method signatures differ:
Expand Down
16 changes: 7 additions & 9 deletions purchasely/references/flutter/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ await Purchasely.interceptAction(
);
```

Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `ClosePayload`, `CloseAllPayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`); payload-less kinds (`login`, `restore`, `promoCode`) carry no extra fields.
Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate`, `purchase`, `restore`, `openPresentation`, `openPlacement`, `promoCode`, `webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`, `ClosePayload`, `CloseAllPayload`, `OpenPresentationPayload`, `OpenPlacementPayload`, `WebCheckoutPayload`); payload-less kinds (`login`, `restore`, `promoCode`) carry no extra fields. `PurchasePayload` exposes real Dart objects: `plan` is a `PLYPlan`, `subscriptionOffer` is a nullable `PLYSubscriptionOffer`, and `offer` is a nullable `PLYPromoOffer`.

### Removing interceptors

Expand Down Expand Up @@ -423,17 +423,15 @@ if (handled) {

> The v5 names `readyToOpenDeeplink(bool)` and `isDeeplinkHandled(uri)` remain only as **deprecated aliases** for `allowDeeplink` / `handleDeeplink`.

### Default Presentation Result Handler
### Default Presentation Dismiss Handler

Retrieve the result of user actions on presentations opened via deeplinks by attaching `onDismissed` to a default-source request (replaces `setDefaultPresentationResultHandler`):
Retrieve the result of user actions on presentations opened by the SDK itself (deeplinks, campaigns, promoted in-app purchases) with `setDefaultPresentationDismissHandler` (replaces `setDefaultPresentationResultHandler`):

```dart
PresentationBuilder.defaultSource()
.onDismissed((outcome) {
print('Deeplink presentation dismissed: ${outcome.purchaseResult} / ${outcome.closeReason}');
})
.build()
.display();
await Purchasely.setDefaultPresentationDismissHandler((outcome) {
print('SDK presentation dismissed: ${outcome.presentation?.screenId} / '
'${outcome.purchaseResult} / ${outcome.closeReason}');
});
```

## Synchronize Purchases
Expand Down
25 changes: 13 additions & 12 deletions purchasely/references/flutter/migration-v6.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ been removed in favour of the builder API.
| `Purchasely.closePresentation()` / `hidePresentation()` / `close()` | `presentation.close()` (on the loaded `Presentation`) |
| `Purchasely.showPresentation()` | `presentation.display()` (on the loaded `Presentation`) |
| `Purchasely.clientPresentationDisplayed(...)` / `clientPresentationClosed(...)` | handled via the `PresentationRequest` lifecycle (`preload` → inspect `PresentationType.client` → render your own UI) |
| `Purchasely.setDefaultPresentationResultHandler(cb)` / `setDefaultPresentationResultCallback(cb)` | `PresentationBuilder.defaultSource().onDismissed((outcome) => …).build().display()` |
| `Purchasely.setDefaultPresentationResultHandler(cb)` / `setDefaultPresentationResultCallback(cb)` | `Purchasely.setDefaultPresentationDismissHandler(cb)` |
| `Purchasely.setPaywallActionInterceptorCallback(cb)` + `Purchasely.onProcessAction(bool)` | `Purchasely.interceptAction(kind, handler)` — handler returns `InterceptResult.success` / `.failed` / `.notHandled` (no more `onProcessAction`) |

> **Reminder.** Everything *not* in this table — purchases, restore, login,
Expand Down Expand Up @@ -263,7 +263,7 @@ await Purchasely.interceptAction(
PresentationActionKind.purchase,
(info, payload) async {
if (payload is PurchasePayload) {
final ok = await MyPurchaseSystem.purchase(payload.plan['productId']);
final ok = await MyPurchaseSystem.purchase(payload.plan.productId);
return ok ? InterceptResult.success : InterceptResult.failed;
}
return InterceptResult.notHandled;
Expand Down Expand Up @@ -291,24 +291,25 @@ Action kinds (`PresentationActionKind`): `close`, `closeAll`, `login`, `navigate
`webCheckout`. Each kind has a typed payload (`NavigatePayload`, `PurchasePayload`,
`ClosePayload`, `CloseAllPayload`, `OpenPresentationPayload`,
`OpenPlacementPayload`, `WebCheckoutPayload`); payload-less kinds (`login`,
`restore`, `promoCode`) carry no extra fields.
`restore`, `promoCode`) carry no extra fields. `PurchasePayload` exposes real
Dart objects: `plan` is a `PLYPlan`, `subscriptionOffer` is a nullable
`PLYSubscriptionOffer`, and `offer` is a nullable `PLYPromoOffer`.

---

## Deeplinks & default result handler
## Deeplinks, campaigns & default dismiss handler

```dart
// Allow deeplinks at start:
await PurchaselyBuilder.apiKey('<YOUR_API_KEY>').allowDeeplink(true).start();

// Default result handler (replaces setDefaultPresentationResultHandler) — attach
// onDismissed to a default-source request:
PresentationBuilder.defaultSource()
.onDismissed((outcome) {
print('Deeplink presentation dismissed: ${outcome.purchaseResult} / ${outcome.closeReason}');
})
.build()
.display();
// Default dismiss handler (renamed from setDefaultPresentationResultHandler).
// Used for presentations opened by the SDK itself: campaigns, deeplinks,
// promoted in-app purchases.
await Purchasely.setDefaultPresentationDismissHandler((outcome) {
print('SDK presentation dismissed: ${outcome.presentation?.screenId} / '
'${outcome.purchaseResult} / ${outcome.closeReason}');
});

// isDeeplinkHandled is UNCHANGED:
final handled = await Purchasely.isDeeplinkHandled('app://ply/presentations/');
Expand Down
2 changes: 2 additions & 0 deletions purchasely/references/troubleshooting/common-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ Purchasely.Builder(applicationContext)
}
```

**Related — campaign on a custom-attribute audience is hit-or-miss on the first launch:** `setUserAttribute(...)` saves the value but does **not** re-evaluate any campaign. A trigger-based campaign evaluates its audience when the trigger resolves (default `APP_STARTED` → shortly after start), using the attributes held at that moment. If the attribute is set after that, the audience won't match on the **first** launch; because the value is persisted in the SDK's disk cache, it matches **from the next session** (hence the "it worked once" symptom). To make it reliable on first launch, gate campaigns until attributes are set — `allowCampaigns(false)` → `setUserAttribute(...)` → `allowCampaigns(true)` (ordering: start → set attributes → allow campaigns). See [campaigns.md](../concepts/campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated).

## 8. Paywall Disappears Immediately

**Symptoms:** Paywall flashes on screen and then vanishes.
Expand Down
1 change: 1 addition & 0 deletions purchasely/skills/purchasely-debug/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ When you identify one of these patterns, apply the known fix immediately:
| 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 |
| Campaign on a custom-attribute audience is hit-or-miss / only shows on the 2nd launch | `setUserAttribute(...)` was called after the SDK evaluated campaigns at start; it saves the value but does not re-trigger targeting, so the audience misses on first launch and matches from the next session (value persisted) | Gate campaigns until attributes are set: `allowCampaigns(false)` → `setUserAttribute(...)` → `allowCampaigns(true)` (ordering: start → set attributes → allow campaigns). See `../../references/concepts/campaigns.md#custom-attribute-audiences-set-the-attribute-before-campaigns-are-evaluated` |
| Purchase succeeds but status not updated | Observer mode without `synchronize()` call | Add `Purchasely.synchronize()` after every successful purchase in Observer mode. If using a wrapper pattern, ensure the wrapper calls `synchronize()` when observing `TransactionResult.Success` |
| 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. Cross-platform (RN / Cordova, v5): call `onProcessAction(false)` for all outcomes. 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 |
Expand Down
Loading
Loading