From c1870415d1a88417c535b176550c5fd4bec35c66 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 19:17:37 +0200 Subject: [PATCH 1/2] docs: three Web2App facts the release notes surfaced and our docs missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modelling the 6.1.0 release notes on React Native's turned up three integrator-facing facts we never wrote down. 1. **Compare an anonymous user id case-insensitively.** An id passed to `anonymousUserId` is stored uppercase on both platforms, but an id the SDK *generates* is not consistent across them. Measured in this release's own E2E runs on 6.1.0: iOS generated `1933C4CC-…` (uppercase), Android generated `ff394b92-…` and `209c9287-…` (lowercase). So `==` against `Purchasely.anonymousUserId` is a real footgun. React Native's notes say the SDK generates ids "in lowercase". That is true on Android and false on iOS — I had the counter-example in my own logs and documented the measured behaviour instead of copying the claim. Same class of mistake as the "the masked email hint is iOS only" line that had to be corrected earlier in this release. 2. **A successful redemption restores the web purchase's user attributes**, built-in and custom, applied *before* the entitlements refresh, so every later event and audience already sees them. Verified in the Android 6.1.0 source (`WebRedemptionContextMapper.kt`, `attributesToRestore` / `builtInAttributes`). Readable through the existing getters or `setUserAttributeListener`. 3. **The redemption token never reaches the app** — not the listener, not a log line, not an analytics event. And `REDEMPTION_CONSUMED` also fires when the user taps an already redeemed link: a replay is a success, and `purchase_context.replay` tells the two apart. Docs only; no code change. Co-Authored-By: Claude Opus 5 (1M context) --- purchasely/lib/src/purchasely_builder.dart | 5 ++++ sdk_public_doc.md | 27 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/purchasely/lib/src/purchasely_builder.dart b/purchasely/lib/src/purchasely_builder.dart index 8e7697b8..cec3975d 100644 --- a/purchasely/lib/src/purchasely_builder.dart +++ b/purchasely/lib/src/purchasely_builder.dart @@ -132,6 +132,11 @@ class PurchaselyBuilder { /// **`override: true` splits the user history.** The backend keeps every /// event and every purchase under the previous id. Use it only when the app /// owns the anonymous identity, e.g. after a cross-device restore. + /// + /// **Compare an anonymous user id case-insensitively.** An id passed here is + /// stored uppercase on both platforms, but an id the SDK *generates* is not + /// consistent across them — measured on 6.1.0: uppercase on iOS, lowercase on + /// Android. Never `==` a stored id against [Purchasely.anonymousUserId]. PurchaselyBuilder anonymousUserId(String id, {bool override = false}) { _anonymousUserId = id; _anonymousUserIdOverride = override; diff --git a/sdk_public_doc.md b/sdk_public_doc.md index 1175d0a4..7e0b1e71 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -190,6 +190,13 @@ holds no anonymous id yet. Pass `override: true` to replace an existing id: every purchase under the previous id. Use `true` only when your app owns the anonymous identity, for example after a cross-device restore. +> **Compare an anonymous user id case-insensitively.** An id you pass here is +> stored uppercase on both platforms. An id the SDK generates itself is *not +> consistent across platforms* — measured on 6.1.0: uppercase on iOS +> (`1933C4CC-…`), lowercase on Android (`ff394b92-…`). So never compare a stored +> id to `Purchasely.anonymousUserId` with `==`; use +> `a.toLowerCase() == b.toLowerCase()`. + ### API proxy (6.1.0) Route Purchasely API traffic through a proxy instead of `api.purchasely.io`, for @@ -315,11 +322,31 @@ Three behaviours to know: event drops the hint on both platforms, so the listener is the only place it appears. +#### A successful redemption also restores the web purchase's user attributes + +The built-in and custom user attributes attached to the web purchase are applied +**before** the entitlements refresh, so every later event and every audience +already sees them. Read them with the getters you already use +(`Purchasely.userAttribute`, `Purchasely.userAttributes`) or observe them through +`Purchasely.setUserAttributeListener`. + +`PLYEventPropertyRedemptionPurchaseContext.built_in_attributes` and +`.custom_attributes` report what the SDK actually applied, not the raw response. + +#### The redemption token never reaches your app + +Not through the listener, not in a log line, not in an analytics event. It is a +bearer credential; `PLYWebRedemptionResult.errorMessage` never contains it. + The SDK also emits two analytics events for a redemption, `PLYEventName.REDEMPTION_CONSUMED` and `PLYEventName.REDEMPTION_FAILED`, with their payload on `PLYEventProperties.redemption`. Read them with `Purchasely.addEventListener`. +`REDEMPTION_CONSUMED` also fires when the user taps an **already redeemed** link: +a replay is a success. `properties.redemption?.purchase_context?.replay` tells a +first redemption from a repeat. + --- ## Displaying Presentations From e591b231c85b6f6cefac2ce1d4e735ed1013a1a6 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 7 Sep 2026 20:07:40 +0200 Subject: [PATCH 2/2] docs: correct a false token-exposure claim, caught in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged it and it is right. I wrote "the redemption token never reaches your app, a log line or an analytics event", copied from the React Native release notes. It is false, and it is the security-relevant direction to be wrong in. Verified against the Android 6.1.0 source: `RedemptionOutcome` builds the events with `PLYEvent.RedemptionConsumed(redemption.copy(token = token))` and `PLYEvent.RedemptionFailed(code, message, token)` — the **raw** token lands on `PLYEventProperties.redemption.token`. An integrator trusting my sentence could forward redemption events unredacted and ship a bearer credential to their analytics stack. Replaced with what the sources actually say, per channel: | Channel | Token | | --- | --- | | `PLYEventProperties.redemption.token` | raw token — exclude from telemetry | | `DEEPLINK_OPENED` `deeplink_identifier` | redacted to a 6-character prefix | | `PLYWebRedemptionResult.errorMessage` | never contains it | | SDK logs | never contains it | Also corrected the dartdoc on the field itself, which described it as "the redemption link token this event reports on" without saying it is the credential. Note for the React Native release notes: they carry the same false claim. Second time this release that copying a cross-wrapper claim instead of checking the native source produced a wrong statement — after "the masked email hint is iOS only". Both were caught by review rather than by me. Co-Authored-By: Claude Opus 5 (1M context) --- purchasely/lib/purchasely_flutter.dart | 8 +++++++- sdk_public_doc.md | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/purchasely/lib/purchasely_flutter.dart b/purchasely/lib/purchasely_flutter.dart index 8c7e0445..aa864f26 100644 --- a/purchasely/lib/purchasely_flutter.dart +++ b/purchasely/lib/purchasely_flutter.dart @@ -1327,7 +1327,13 @@ class PLYEventPropertyRedemptionPurchaseContext { /// /// Every field is optional: the SDK omits a key it has no value for. class PLYEventPropertyRedemption { - /// The redemption link token this event reports on. + /// The redemption link token this event reports on — the **raw bearer + /// credential**, not a redacted form. + /// + /// If you forward Purchasely events to a third-party analytics stack, exclude + /// this field. `DEEPLINK_OPENED` redacts the token to a 6-character prefix and + /// [PLYWebRedemptionResult.errorMessage] never contains it, but this field + /// does. String? token; PLYEventPropertyRedemptionReceipt? receipt; List? subscriptions; diff --git a/sdk_public_doc.md b/sdk_public_doc.md index 7e0b1e71..3d1eae77 100644 --- a/sdk_public_doc.md +++ b/sdk_public_doc.md @@ -333,10 +333,21 @@ already sees them. Read them with the getters you already use `PLYEventPropertyRedemptionPurchaseContext.built_in_attributes` and `.custom_attributes` report what the SDK actually applied, not the raw response. -#### The redemption token never reaches your app +#### ⚠️ The redemption token IS exposed on the analytics events — do not forward it -Not through the listener, not in a log line, not in an analytics event. It is a -bearer credential; `PLYWebRedemptionResult.errorMessage` never contains it. +`PLYEventProperties.redemption.token` carries the **raw bearer token** on both +`REDEMPTION_CONSUMED` and `REDEMPTION_FAILED`. If you forward Purchasely events +to a third-party analytics stack, **exclude that field**, or the credential leaves +the device with your telemetry. + +The other channels do not carry it, verified against the 6.1.0 native sources: + +| Channel | Token | +|---|---| +| `PLYEventProperties.redemption.token` | **raw token** — exclude it from telemetry | +| `DEEPLINK_OPENED`'s `deeplink_identifier` | redacted to a 6-character prefix | +| `PLYWebRedemptionResult.errorMessage` | never contains it | +| SDK logs | never contains it | The SDK also emits two analytics events for a redemption, `PLYEventName.REDEMPTION_CONSUMED` and `PLYEventName.REDEMPTION_FAILED`, with