react-native-revnix 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +766 -0
  3. package/dist/blocks/background.d.ts +101 -0
  4. package/dist/blocks/background.js +165 -0
  5. package/dist/blocks/color.d.ts +88 -0
  6. package/dist/blocks/color.js +329 -0
  7. package/dist/blocks/locale.d.ts +42 -0
  8. package/dist/blocks/locale.js +263 -0
  9. package/dist/blocks/selection.d.ts +49 -0
  10. package/dist/blocks/selection.js +109 -0
  11. package/dist/blocks/style.d.ts +93 -0
  12. package/dist/blocks/style.js +365 -0
  13. package/dist/blocks/tags.d.ts +12 -0
  14. package/dist/blocks/tags.js +121 -0
  15. package/dist/blocks/types.d.ts +301 -0
  16. package/dist/blocks/types.js +44 -0
  17. package/dist/client.d.ts +402 -0
  18. package/dist/client.js +1653 -0
  19. package/dist/device.d.ts +29 -0
  20. package/dist/device.js +137 -0
  21. package/dist/errors.d.ts +39 -0
  22. package/dist/errors.js +68 -0
  23. package/dist/esm/blocks/background.js +160 -0
  24. package/dist/esm/blocks/color.js +318 -0
  25. package/dist/esm/blocks/locale.js +255 -0
  26. package/dist/esm/blocks/selection.js +100 -0
  27. package/dist/esm/blocks/style.js +356 -0
  28. package/dist/esm/blocks/tags.js +117 -0
  29. package/dist/esm/blocks/types.js +40 -0
  30. package/dist/esm/client.js +1649 -0
  31. package/dist/esm/device.js +130 -0
  32. package/dist/esm/errors.js +59 -0
  33. package/dist/esm/iap.js +101 -0
  34. package/dist/esm/implicit.js +63 -0
  35. package/dist/esm/index.js +29 -0
  36. package/dist/esm/package.json +1 -0
  37. package/dist/esm/storage.js +78 -0
  38. package/dist/esm/types.js +4 -0
  39. package/dist/esm/ui/RevnixBlockPaywall.js +706 -0
  40. package/dist/esm/ui/RevnixPaywall.js +890 -0
  41. package/dist/esm/ui/index.js +6 -0
  42. package/dist/esm/version.js +4 -0
  43. package/dist/iap.d.ts +20 -0
  44. package/dist/iap.js +104 -0
  45. package/dist/implicit.d.ts +69 -0
  46. package/dist/implicit.js +67 -0
  47. package/dist/index.d.ts +18 -0
  48. package/dist/index.js +50 -0
  49. package/dist/storage.d.ts +14 -0
  50. package/dist/storage.js +85 -0
  51. package/dist/types.d.ts +463 -0
  52. package/dist/types.js +5 -0
  53. package/dist/ui/RevnixBlockPaywall.d.ts +96 -0
  54. package/dist/ui/RevnixBlockPaywall.js +742 -0
  55. package/dist/ui/RevnixPaywall.d.ts +148 -0
  56. package/dist/ui/RevnixPaywall.js +927 -0
  57. package/dist/ui/index.d.ts +7 -0
  58. package/dist/ui/index.js +25 -0
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +7 -0
  61. package/package.json +79 -0
  62. package/src/blocks/background.ts +232 -0
  63. package/src/blocks/color.ts +352 -0
  64. package/src/blocks/locale.ts +271 -0
  65. package/src/blocks/paywall-background-wire.json +12 -0
  66. package/src/blocks/paywall-selection-wire.json +149 -0
  67. package/src/blocks/selection.ts +161 -0
  68. package/src/blocks/style.ts +411 -0
  69. package/src/blocks/tags.ts +125 -0
  70. package/src/blocks/types.ts +352 -0
  71. package/src/client.ts +1993 -0
  72. package/src/device.ts +146 -0
  73. package/src/errors.ts +69 -0
  74. package/src/iap.ts +137 -0
  75. package/src/implicit.ts +129 -0
  76. package/src/index.ts +82 -0
  77. package/src/storage.ts +92 -0
  78. package/src/types.ts +528 -0
  79. package/src/ui/RevnixBlockPaywall.tsx +1423 -0
  80. package/src/ui/RevnixPaywall.tsx +1542 -0
  81. package/src/ui/index.ts +43 -0
  82. package/src/version.ts +4 -0
  83. package/ui.d.ts +1 -0
  84. package/ui.js +5 -0
package/README.md ADDED
@@ -0,0 +1,766 @@
1
+ # revnix-react
2
+
3
+ Revnix SDK for React Native / JavaScript. Pure TypeScript over the Revnix
4
+ `/v1` REST API, no native modules, no dependencies. Pairs with
5
+ `react-native-iap` (or any StoreKit 2 / Play Billing wrapper) for purchases.
6
+ Ships CJS + ESM (tree-shakeable); every request carries an
7
+ `X-Revnix-SDK: revnix-react/<version>` header (`SDK_VERSION` is exported).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install revnix-react
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ ```ts
18
+ import { Revnix } from "revnix-react";
19
+ import { MMKV } from "react-native-mmkv";
20
+
21
+ const mmkv = new MMKV();
22
+
23
+ Revnix.configure({
24
+ apiKey: "rvx_pk_test_…", // dashboard → Settings → API keys
25
+ baseUrl: "https://<deployment>.convex.site", // Convex site URL
26
+ storage: {
27
+ getItem: (k) => mmkv.getString(k) ?? null,
28
+ setItem: (k, v) => mmkv.set(k, v),
29
+ },
30
+ });
31
+ ```
32
+
33
+ `Revnix.configure()` returns the `RevnixClient`; afterwards `Revnix.client`
34
+ is available anywhere (it throws if used before `configure`). Re-running
35
+ `configure()` disposes the previous client first (stopping its retry drain,
36
+ read-your-writes polls, implicit triggers and foreground listener, safe under
37
+ dev fast-refresh). You
38
+ can also construct `new RevnixClient(config)` directly; call `dispose()` when
39
+ you discard one.
40
+
41
+ ### API keys
42
+
43
+ The dashboard issues two kinds of key, and the key fixes the app **and**
44
+ environment server-side; a test key can never write into production:
45
+
46
+ - **Publishable** `rvx_pk_live_…` / `rvx_pk_test_…`: safe to embed in the
47
+ app. Can read entitlements, register its own purchases, report installs and
48
+ paywall views/closes, and set non-reserved attributes.
49
+ - **Secret** `rvx_sk_live_…` / `rvx_sk_test_…`: server-side only, never in
50
+ an app binary. Additionally allowed to call `identify()`, `alias()` and
51
+ `getIdentity()` (the identity graph is server-only), plus the server-only
52
+ grants/attributes endpoints.
53
+
54
+ Calling a secret-gated method with a publishable key fails with
55
+ `RevnixAuthError` (403). Legacy `rvx_live_…`/`rvx_test_…` keys still work and
56
+ have full access.
57
+
58
+ ### Config options
59
+
60
+ | Option | Default | What it does |
61
+ |---|---|---|
62
+ | `apiKey` (required) | n/a | Sent as `Authorization: Bearer <key>` |
63
+ | `baseUrl` (required) | n/a | Deployment site URL; trailing slashes stripped. Must be `https://`, `http://` is allowed only for localhost/private-network dev hosts (`localhost`, `127.*`, `10.*`, `192.168.*`, `172.16-31.*`, `[::1]`) |
64
+ | `storage` | auto | Persistent adapter; see below |
65
+ | `customerId` | n/a | Start as this customer instead of loading/generating an anonymous id |
66
+ | `requestTimeoutMs` | `10000` | Per-request timeout; slow/dead backend fails fast with `RevnixTimeoutError` |
67
+ | `readYourWritesDelaysMs` | `[250, 500, 1000, 2000]` | Post-purchase entitlement re-fetch schedule; `[]` disables |
68
+ | `entitlementsTtlMs` | `30000` | In-memory soft TTL on `getEntitlements()`/`isEntitled()`: a server read younger than this answers repeat calls from memory. `0` restores always-fetch |
69
+ | `offlineMaxCacheAgeMs` | 14 days | Max age of a cache-served entitlements snapshot; older snapshots serve every entitlement as `isActive: false` |
70
+ | `reportInstalls` | `true` | `POST /v1/installs` on first launch (forced off on non-persistent storage) |
71
+ | `installMetadata` | n/a | `{ platform?, appVersion? }` attached to the install report (superseded by `device`) |
72
+ | `device` | detected | `{ platform?, osVersion?, appVersion?, locale?, currency?, storefront?, model?, sandbox? }` sent with every placement resolve as `X-Revnix-Device`, evaluated by targeting rules on that request and stored as `device.*` attributes. Browser detects platform/OS/locale; React Native apps pass what `react-native-device-info` gives them. `false` disables |
73
+ | `prefetchPlacements` | n/a | Placement keys resolved-and-cached at construction (fire-and-forget), so the session's first paywall renders even offline |
74
+ | `onDiagnostic` | n/a | Callback invoked on every swallowed background failure (retry drains, prefetches, install/paywall reports, storage errors); see Diagnostics |
75
+ | `fetch` | global fetch | Custom fetch (tests, polyfills) |
76
+ | `onImplicitPaywall` | n/a | Handler for implicit placement moments (`app_launch`, `session_start`, etc.), configured in the dashboard |
77
+ | `implicitPlacements` | whether `onImplicitPaywall` is set | Explicit off switch for implicit placements, even when `onImplicitPaywall` is set |
78
+ | `onDeferredDeepLink` | n/a | Delivers the link the customer clicked before install, at most once. Since 1.2.2 |
79
+ | `onAttribution` | n/a | Fires after the first-launch install report and whenever the install-attribution verdict changes. Turns on the background fetch for it (since 1.2.4) |
80
+ | `lifecycle` | `visibilitychange` listener in a browser, nothing under React Native | How the SDK learns the app came to the foreground; `false` disables foreground detection |
81
+ | `sessionTimeoutMs` | `1800000` (30 minutes) | How long the app must be backgrounded before the return counts as a new session |
82
+
83
+ `getDeviceHeader()` returns the encoded `X-Revnix-Device` value this client
84
+ sends, or `null` when `device: false`.
85
+
86
+ **Storage:** pass a persistent `storage` adapter (MMKV/AsyncStorage on React
87
+ Native). On the web, omitting it automatically falls back to `localStorage`.
88
+ If neither is available the SDK runs memory-only: it still works, but it
89
+ warns loudly and **disables install reporting**; otherwise every launch
90
+ would look like a new anonymous customer and inflate your install metrics
91
+ (prior purchases would also not be attached across launches). A storage
92
+ adapter that *throws* never breaks the client either: reads/writes are
93
+ guarded, the failure is reported via `onDiagnostic`, and the customer id
94
+ survives in memory for the session. `memoryStorage()` is exported for tests.
95
+
96
+ Anonymous ids (`rvx_anon_…`) are minted with `crypto.getRandomValues`; there
97
+ is deliberately no `Math.random` fallback. On runtimes without WebCrypto,
98
+ polyfill `globalThis.crypto` or pass an explicit `customerId`.
99
+
100
+ ## Identity
101
+
102
+ ```ts
103
+ // on-device (publishable key):
104
+ await Revnix.client.getCustomerId(); // rvx_anon_… until identified
105
+ await Revnix.client.logout(); // fresh anonymous customer
106
+
107
+ // server-side only (secret rvx_sk_ key, throws locally on a publishable key):
108
+ import { RevnixClient } from "revnix-react";
109
+
110
+ const revnix = new RevnixClient({
111
+ apiKey: process.env.REVNIX_SECRET_KEY!, // rvx_sk_…
112
+ baseUrl: "https://<deployment>.convex.site",
113
+ customerId: deviceCustomerId, // what the device sent from getCustomerId()
114
+ reportInstalls: false,
115
+ });
116
+ await revnix.identify(user.uid); // on login, merges anonymous history
117
+ ```
118
+
119
+ Pass the device's id in as `customerId`: without it the server-side client
120
+ generates its own anonymous id and merges that instead, so the device's
121
+ history is never merged. Build one client per request.
122
+
123
+ - `identify(userId, { strategy })`: `"merge"` (default) folds the anonymous
124
+ history into the identified customer; `"block"` refuses when both ids
125
+ already have history. No-op (no network call) when already identified as
126
+ that id. **Requires a secret or legacy key**, on a publishable key the SDK
127
+ throws `RevnixAuthError` locally, before any network call; run it from your
128
+ backend instead and let the device pick up the canonical id on its next
129
+ `registerPurchase()` (its response always carries the caller's canonical id).
130
+ - `alias(aliasId)`: attach an alternate id to the current customer (the
131
+ alias must be a fresh id; use `identify()` to merge existing customers).
132
+ Also secret-gated.
133
+ - `getIdentity()`: resolves the current id's identity cluster:
134
+ `{ canonicalId, isAlias, anonymous, linkedIds }`. **Also secret-gated**:
135
+ the response is an identity graph, so the server refuses publishable keys
136
+ and the SDK throws `RevnixAuthError` locally on one, before any network call.
137
+ - `logout()`: generates a fresh anonymous customer locally (never merges,
138
+ never re-reports an install).
139
+
140
+ ## Purchases
141
+
142
+ Set `appAccountToken` (iOS) / `obfuscatedAccountIdAndroid` to the Revnix
143
+ customer id when requesting the purchase, then register it:
144
+
145
+ ```ts
146
+ import { purchaseInputFromIap, PurchaseBlockedError } from "revnix-react";
147
+
148
+ // in your purchaseUpdatedListener, before finishTransaction:
149
+ try {
150
+ await Revnix.client.registerPurchase(
151
+ purchaseInputFromIap(purchase, Platform.OS === "ios" ? "apple" : "google")
152
+ );
153
+ } catch (err) {
154
+ if (err instanceof PurchaseBlockedError) {
155
+ // token belongs to another account and the app forbids transfer
156
+ }
157
+ throw err;
158
+ }
159
+ ```
160
+
161
+ Registration and the store's own webhook share an idempotency key; whichever
162
+ arrives first wins, the other becomes a no-op. On Apple, `purchaseInputFromIap`
163
+ extracts the StoreKit 2 JWS from `jwsRepresentation` (or `purchaseToken`,
164
+ shape-checked), prefers the JWS payload's own `originalTransactionId` as the
165
+ token, and sends the JWS as `signedTransactionInfo` automatically, so
166
+ purchases registered through the helper arrive *proven*, not provisional,
167
+ with no extra work. Google purchases map to
168
+ `token = transactionId = purchaseToken` (deliberately: it matches the
169
+ webhook's purchase key, not the Play `orderId`).
170
+
171
+ **Offline resilience:** if registration fails for a *transient* reason
172
+ (network, timeout, 429, 5xx) the purchase is persisted to a retry queue and
173
+ the error is rethrown; the queue drains automatically on the next app launch
174
+ (or call `retryPendingPurchases()` yourself). Permanent rejections, 400 bad
175
+ input, 401/403 auth, and `PurchaseBlockedError` (409), are never queued, and
176
+ one that slipped into the queue earlier is drained on the next retry pass.
177
+ Repeated failures of the same purchase collapse to one queue entry.
178
+
179
+ **Read-your-writes:** after a successful `registerPurchase` the SDK re-fetches
180
+ entitlements until the response's ledger cursor covers the purchase, so
181
+ `getEntitlements()` / `isEntitled()` right after buying normally include the
182
+ purchase, and the offline cache is warm at exactly the moment it
183
+ matters. Tune or disable via `readYourWritesDelaysMs` (pass `[]` to disable).
184
+ Each delay gets ±20% jitter, a 429's `Retry-After` is honored as extra sleep,
185
+ and every poll bypasses the entitlements soft TTL so it always reads the
186
+ server. The poll is skipped when the token turned out to belong to another
187
+ customer, and it resolves with the last read when the schedule runs out, it
188
+ never throws from the schedule itself. For out-of-band grants (e.g. your
189
+ server granted an entitlement and returned its `seq`),
190
+ `waitForEntitlements(seq)` runs the same poll on demand.
191
+
192
+ `customerId` in the result is **the id this caller should use going forward**, its own canonical id. It differs from the one you sent only when identity
193
+ resolution merged that id away (a restore after login), so the SDK follows it
194
+ and persists it.
195
+
196
+ When the token belongs to someone else, that is reported separately and the
197
+ SDK stays where it is: `ownedByOtherCustomer: true`, plus `tokenOwner` naming
198
+ the holder for secret-key callers only, a publishable key learns the outcome
199
+ but never another customer's id. `transferred: true` means the token was
200
+ reassigned *to you*.
201
+
202
+ **Store proof, send `signedTransactionInfo`.** A publishable key ships inside
203
+ your app binary, so "this device bought pro.monthly" is an assertion until a
204
+ store backs it. Pass StoreKit 2's
205
+ `VerificationResult.jwsRepresentation` as `signedTransactionInfo` and the
206
+ purchase is proven: Revnix verifies it against Apple's certificate chain and
207
+ checks that it names this exact transaction.
208
+
209
+ ```ts
210
+ await Revnix.client.registerPurchase({
211
+ source: "apple",
212
+ token: transaction.originalID,
213
+ transactionId: transaction.id,
214
+ productId: transaction.productID,
215
+ expiresAt: transaction.expirationDate,
216
+ signedTransactionInfo: verificationResult.jwsRepresentation, // ← proof
217
+ });
218
+ ```
219
+
220
+ Without proof the purchase is still accepted, but the result carries
221
+ `provisional: true`: the entitlement is granted for a short window rather than
222
+ the full period, and no revenue is recorded, until Apple's or Google's own
223
+ notification for the charge confirms it, which normally happens within
224
+ seconds and promotes the purchase automatically. On Google there is no
225
+ client-side receipt; if you have connected Play credentials, Revnix verifies
226
+ the purchase token directly with Play instead, and a token Play rejects is
227
+ refused with `403`. Once your installed base sends proof, an app can require
228
+ it outright (`projects.setRequirePurchaseProof`), after which unproven
229
+ registrations get `403` rather than a provisional grant.
230
+
231
+ **Transfer trust (server-side):** the backend only honors a transfer (or an
232
+ imported-history merge) for a claim it can verify: a secret `rvx_sk_…` key,
233
+ or that same `signedTransactionInfo`. A publishable-key registration without
234
+ proof still binds a *free* token, but a token bound to another customer stays
235
+ put and the response carries `refused: "unverified-claim"`, with your own
236
+ customer id untouched, so a refused claim never moves the device onto the
237
+ rightful owner's identity.
238
+
239
+ ## Entitlements & placements
240
+
241
+ ```ts
242
+ const { entitlements, cursor } = await Revnix.client.getEntitlements();
243
+ const pro = await Revnix.client.isEntitled("pro");
244
+ const { offering, paywall } = await Revnix.client.resolvePlacement("paywall_main");
245
+ ```
246
+
247
+ `getEntitlements()` is network-first with an **offline cache**: on success the
248
+ response is cached (per customer id, snapshots for up to 4 customers are
249
+ kept, oldest evicted, so an offline launch right after `identify()`/`logout()`
250
+ still has a gate) and returned with `stale: false`. On a *transient* failure
251
+ (offline, timeout, 429, 5xx) the last cached snapshot is returned with
252
+ `stale: true` and its original `fetchedAt`; a deliberate server rejection
253
+ (401/403/404/409, e.g. a revoked key) is **always rethrown, cache or not**,
254
+ so the cache never defeats a kill switch. It only throws on transient
255
+ failures when there is nothing cached. `isEntitled()` goes through the same
256
+ path, so entitlement gates keep working offline. `getCachedEntitlements()`
257
+ reads the cache without touching the network (returns `null` when empty), useful for instant startup gating.
258
+
259
+ **Soft TTL & coalescing:** a server read younger than `entitlementsTtlMs`
260
+ (default 30 s) answers repeat `getEntitlements()`/`isEntitled()` calls from
261
+ memory with no request, and concurrent calls share one in-flight request; so a screen full of entitlement gates costs one fetch. Set
262
+ `entitlementsTtlMs: 0` to always hit the network; `waitForEntitlements()`
263
+ and the post-purchase poll always bypass the TTL. Practical consequence:
264
+ out-of-band entitlement changes (a webhook-driven grant or revoke) become
265
+ visible within ~30 s, not instantly.
266
+
267
+ **Local expiry grace:** cache-served entitlements are re-evaluated against the
268
+ device clock, an entitlement whose `expiresAt` passed more than **3 days**
269
+ ago is served as `isActive: false`, so a long-lapsed subscription never stays
270
+ active offline forever. Within those 3 days it stays active (the auto-renewal
271
+ an offline device cannot see yet). Independently of per-entitlement expiry, a
272
+ cached snapshot older than `offlineMaxCacheAgeMs` (default **14 days**), or
273
+ one that appears to be from the *future* because the device clock rolled back
274
+ more than 5 minutes, is served with every entitlement inactive, so even a
275
+ lifetime grant cannot be farmed by keeping a device offline forever. Fresh
276
+ server reads are authoritative and never re-evaluated.
277
+
278
+ `resolvePlacement(placementKey)` resolves the latest *published* catalog
279
+ revision. Both `offering` and `paywall` are nullable; guard before rendering
280
+ (a placement can exist with nothing attached, and an unpublished environment
281
+ is an error).
282
+
283
+ **Placements are offline-safe too:** each successful `resolvePlacement` caches
284
+ its resolution (per placement key); when the live read fails *transiently*
285
+ the cached copy is returned with `stale: true`, so the paywall renders the
286
+ last published version rather than not at all; it only throws when nothing
287
+ is cached. Deliberate answers pass through the cache: a deleted placement
288
+ (404) or an unpublished environment (409) still throws, so a pulled paywall
289
+ stays pulled.
290
+ `getCachedPlacement(placementKey)` reads that cache with no network call, and
291
+ `prefetchPlacements` (config) warms it at app start.
292
+
293
+ ## Implicit placements
294
+
295
+ Six placements resolve without a `resolvePlacement` call: `app_install`,
296
+ `app_launch`, `session_start`, `deeplink_open`, `paywall_decline` and
297
+ `transaction_abandon` are moments the SDK recognises itself, so an operator
298
+ can attach a paywall to any of them from the dashboard with no release. Five
299
+ need no app code; `deeplink_open` needs the `handleDeepLink` call shown
300
+ below on native (the web SDK reports the landing URL itself since 1.2.4, see
301
+ below). **Off by default**: passing
302
+ `onImplicitPaywall` is what turns
303
+ them on; without it the SDK makes no extra requests for the other five
304
+ moments, though `handleDeepLink` always sends its one report regardless.
305
+ With `onImplicitPaywall` set, the SDK asks `GET /v1/config` once at
306
+ construction and fires for the moments the dashboard has configured plus
307
+ `session_start`, which the server always lists so every app records
308
+ `session.started`; an app that configured nothing costs one cached
309
+ `/v1/config` response per launch and one `session_start` trigger per
310
+ session.
311
+
312
+ ```ts
313
+ Revnix.configure({
314
+ apiKey, baseUrl, storage,
315
+ onImplicitPaywall: ({ placement, resolution }) => present(resolution),
316
+ // session_start needs foreground detection. A browser is handled for you
317
+ // (visibilitychange); React Native passes an adapter over AppState.
318
+ lifecycle: {
319
+ onStateChange: (handler) => {
320
+ const sub = AppState.addEventListener("change", (s) =>
321
+ handler(s === "active" ? "foreground" : "background"));
322
+ return () => sub.remove();
323
+ },
324
+ },
325
+ });
326
+
327
+ // deeplink_open is the one moment the SDK cannot see itself: the URL
328
+ // reaches your router.
329
+ Linking.getInitialURL().then((url) => url && Revnix.client.handleDeepLink(url));
330
+ Linking.addEventListener("url", ({ url }) => Revnix.client.handleDeepLink(url));
331
+ ```
332
+
333
+ `getInitialURL()` is the cold start (the link that launched a closed app);
334
+ the `url` event is a link arriving while the app is already running. Run
335
+ both once, at module scope, not inside a component effect that can remount
336
+ (StrictMode runs effects twice in development). A full JS reload (dev
337
+ reload, an OTA update) makes `getInitialURL()` return the same URL again,
338
+ and `handleDeepLink` would report that open twice.
339
+
340
+ On the web (`document` present) the SDK does this step itself (since 1.2.4;
341
+ on 1.2.3 call `handleDeepLink(window.location.href)` yourself): a landing
342
+ URL whose query
343
+ carries `utm_*` or `revnix_link` is reported once at construction, no host
344
+ code needed. Call `handleDeepLink` yourself only for client-side navigations
345
+ or for URLs without those params. A call with the
346
+ same landing URL is skipped once so it doesn't double-report; anything
347
+ after that reports normally.
348
+
349
+ `handleDeepLink` also recognises the dashboard's QR/link preview
350
+ (`…?revnix_preview=<token>`) and routes it through the same
351
+ `onImplicitPaywall` handler with `placement: "revnix_preview"`, no extra
352
+ wiring needed. Purchases are disabled on that display: `RevnixPaywall`
353
+ (classic or designed) shows an alert instead of calling `onPurchase`, and no
354
+ paywall view/close/interaction is sent to analytics for it. A host rendering
355
+ `RevnixBlockPaywall` directly, without `RevnixPaywall`, is not covered: it
356
+ takes no `placementKey` and has no way to know the display is a preview.
357
+
358
+ The SDK never presents for you; it does not own your navigation stack. You
359
+ get the same `PlacementResolution` as `resolvePlacement` returns. When you
360
+ render it, **pass `resolution.placementKey` to `RevnixPaywall` /
361
+ `logPaywallShown`**: that marks the display as implicit, which is the only
362
+ thing that stops a `paywall_decline` paywall from firing `paywall_decline`
363
+ again when dismissed. A cold start hands you at most one paywall (install →
364
+ launch → session, first that resolves wins), and a deep link on the first
365
+ frame waits for that decision. A close is a decline: never report one for a
366
+ display that ended in a purchase. `sessionTimeoutMs` (default 30 min) is how
367
+ long the app must be backgrounded for the return to count as a session.
368
+
369
+ ## Deferred deep links
370
+
371
+ A click on a Revnix link (`https://<deployment>.convex.site/l/<token>`,
372
+ created on the Links page) sends Android to Google Play with the link's query
373
+ string as the install referrer, iOS to the App Store, and desktop to the
374
+ link's fallback URL, and remembers every non-Android click for the link's
375
+ click window (1 hour by default, set per link). `POST /v1/installs` (fired
376
+ automatically on first launch) can come back with the link the install
377
+ matched: exact on Android, from that install referrer; probabilistic on iOS,
378
+ from a same-network click or impression within the link's window, so it can
379
+ be wrong on a shared network. Only a link whose scheme
380
+ matches your app's configured URL scheme is ever returned. Pass
381
+ `onDeferredDeepLink` to get it, delivered at most once per install; route the
382
+ URL yourself, optionally also through `handleDeepLink` for `deeplink_open`
383
+ paywall rules:
384
+
385
+ ```ts
386
+ Revnix.configure({
387
+ apiKey, baseUrl, storage,
388
+ onDeferredDeepLink: (url, match) => router.push(url),
389
+ });
390
+ ```
391
+
392
+ On Android, the same link can also arrive later through the Play Install
393
+ Referrer service; read it yourself and hand the raw string to
394
+ `Revnix.client.handleInstallReferrer(referrer)`, which also feeds the
395
+ `deferredDeepLink` response into the same `onDeferredDeepLink` handler.
396
+
397
+ An email service provider (Mailchimp, SendGrid) rewrites links through its
398
+ own click-tracking domain before your customer ever sees them; pass those
399
+ through `resolveDeepLink` first, then to your router and `handleDeepLink`:
400
+
401
+ ```ts
402
+ const url = await Revnix.client.resolveDeepLink(clickedUrl);
403
+ router.push(url);
404
+ Revnix.client.handleDeepLink(url);
405
+ ```
406
+
407
+ A failure (bad input, network error) hands the URL back unchanged. The
408
+ result can still be an http(s) URL if the chain could not be unwrapped, so
409
+ check the scheme before routing on it.
410
+
411
+ ## Apple Search Ads attribution
412
+
413
+ iOS-only, and only for a host that mints its own token: `AAAttribution
414
+ .attributionToken()` is an iOS framework call with no JavaScript binding, so
415
+ this package cannot read it for you. Mint it in a native module and hand the
416
+ raw string to `Revnix.client.handleAttributionToken(token)`, which reports it
417
+ alongside the install and resolves to Apple's verdict:
418
+
419
+ ```ts
420
+ const verdict = await Revnix.client.handleAttributionToken(token);
421
+ ```
422
+
423
+ - `"resolved"`: Apple named a Search Ads campaign. Final.
424
+ - `"organic"`: Apple answered, but this was not an ASA install. Final.
425
+ - `"pending"`: a freshly minted token is unregistered on Apple's side for a
426
+ few seconds; worth minting a fresh token and retrying on the next cold
427
+ start.
428
+
429
+ Safe to leave unawaited: it never throws, and resolves to `undefined` if
430
+ the client is disposed, the token is blank, storage is memory-only, or the
431
+ request failed.
432
+
433
+ ## Install attribution
434
+
435
+ Needs 1.2.4 or later. On 1.2.3, call `GET /v1/customers/{id}/attribution`
436
+ yourself.
437
+
438
+ `GET /v1/customers/{id}/attribution` returns the install-attribution verdict
439
+ for the current customer: `installMatch` (`"referrer"` / `"click"` /
440
+ `"impression"` / `"organic"`), `attributedAt`, the campaign facts frozen at
441
+ that judgment (`source`, `medium`, `campaign`, `term`, `content`), the
442
+ evidence behind it (`referrerSource`, `matchSignals`), `linkToken`, and
443
+ `reattributedAt` when a later signal re-attributed the customer.
444
+
445
+ ```ts
446
+ const attribution = await Revnix.client.getAttribution();
447
+ ```
448
+
449
+ `null` means no install has been recorded yet (a normal cold-start race) or
450
+ the request failed. Pass `onAttribution` to be told about it instead of
451
+ polling: it fires after the first-launch install report resolves and after
452
+ `handleInstallReferrer` / `handleAttributionToken`, whenever the verdict
453
+ differs from the last one delivered (persisted, so an unchanged verdict never
454
+ fires twice). The SDK does not poll: call `getAttribution()` (e.g. at launch)
455
+ to pick up a later re-attribution, which also fires `onAttribution` when it
456
+ changed.
457
+
458
+ ```ts
459
+ Revnix.configure({
460
+ apiKey, baseUrl, storage,
461
+ onAttribution: (attribution) => analytics.setUserProperties(attribution),
462
+ });
463
+ ```
464
+
465
+ The SDK never fetches attribution on its own unless `onAttribution` is set.
466
+
467
+ ## Ad revenue
468
+
469
+ Needs 1.2.4 or later. On 1.2.3, call `POST /v1/ad-revenue` yourself.
470
+
471
+ ```ts
472
+ Revnix.client.logAdRevenue({ revenue: 0.0032, currency: "USD", network: "admob" });
473
+ ```
474
+
475
+ Input: `revenue` (major units, must be > 0), `currency` (ISO 4217), and
476
+ optional `network`, `mediation`, `adUnit`, `placement`, `format`, `eventId`.
477
+ Fire-and-forget: it never blocks or throws, and a non-positive or non-finite
478
+ `revenue` sends nothing.
479
+
480
+ ## Custom events
481
+
482
+ Report an in-app event Revnix doesn't already track — not a purchase, which
483
+ reports through `registerPurchase`.
484
+
485
+ ```ts
486
+ Revnix.client.track("level_up", { level: 5, difficulty: "hard" });
487
+ ```
488
+
489
+ `event` must match `^[a-z0-9_]{1,64}$` and is recorded as `custom.<event>`;
490
+ `properties` is an optional flat object of strings/numbers/booleans. An
491
+ invalid `event` name sends nothing. Fire-and-forget, like `logAdRevenue`.
492
+
493
+ ## MMP attribution
494
+
495
+ If you already run an MMP (Adjust, AppsFlyer, Singular, Branch, Kochava,
496
+ Tenjin, Airbridge), call `setAttribution` from its attribution callback so
497
+ Revnix credits revenue to the right network/campaign.
498
+
499
+ ```ts
500
+ // Adjust's attribution callback
501
+ adjustAttributionListener((attribution) => {
502
+ Revnix.client.setAttribution({
503
+ provider: "adjust",
504
+ network: attribution.network,
505
+ campaign: attribution.campaign,
506
+ adGroup: attribution.adgroup,
507
+ creative: attribution.creative,
508
+ });
509
+ });
510
+
511
+ // AppsFlyer's onConversionDataSuccess
512
+ onConversionDataSuccess((data) => {
513
+ if (data.af_status === "Organic") return;
514
+ Revnix.client.setAttribution({
515
+ provider: "appsflyer",
516
+ network: data.media_source,
517
+ campaign: data.campaign,
518
+ adGroup: data.af_adset,
519
+ creative: data.af_ad,
520
+ });
521
+ });
522
+ ```
523
+
524
+ Fire-and-forget, like `logAdRevenue`: it never blocks or throws.
525
+
526
+ ## Uninstall measurement
527
+
528
+ Revnix measures uninstalls the way Adjust/AppsFlyer do: hand it the device's
529
+ push token, and once a day a silent push probes every registered device;
530
+ when APNs/FCM reports a token dead, the customer gets an `app.uninstalled`
531
+ event. Register the token whenever your push library delivers or refreshes
532
+ one:
533
+
534
+ ```ts
535
+ // iOS: your push library's registration callback (APNs device token)
536
+ // Android: your push library's onNewToken / getToken()
537
+ messaging().onTokenRefresh((token) => Revnix.client.setPushToken(token));
538
+ ```
539
+
540
+ Fire-and-forget, like `setAttribution`: it never blocks or throws, dedupes
541
+ per customer+token, and the same token passed to `setPushToken` again after
542
+ `logout()`/`identify()` changed the customer is sent again. **iOS/Android
543
+ only** — on web it's a no-op, there is no device
544
+ push token to collect. Requires the app to already have push set up (iOS:
545
+ Push Notifications + Background Modes → Remote notifications capability;
546
+ Android: Firebase Cloud Messaging); no notification permission needed, the
547
+ probe is a silent/data-only message. See
548
+ [Uninstall measurement](https://revnix.io/docs/uninstall-measurement).
549
+
550
+ ## Last deep link
551
+
552
+ The SDK remembers the most recent link it saw, from either `handleDeepLink`
553
+ or a delivered deferred deep link, so you can ask for it again later, for
554
+ example after login or onboarding swallowed the original delivery:
555
+
556
+ ```ts
557
+ const last = await Revnix.client.getLastDeepLink();
558
+ if (last) router.push(last.url);
559
+ ```
560
+
561
+ `last` is `{ url, receivedAt }` or `null` when nothing has been recorded yet.
562
+ A dashboard preview link (`…?revnix_preview=<token>`) is never recorded. The
563
+ value is not cleared on logout.
564
+
565
+ ## A/B tests
566
+
567
+ `resolvePlacement` sends the current customer id, so a running A/B test on
568
+ that placement serves this customer their assigned variant. The returned
569
+ `offering` and `paywall` are **already the variant's**; render what you get.
570
+ The assignment is exposed only so you can attribute your own analytics:
571
+
572
+ ```ts
573
+ const { offering, paywall, experiment } = await Revnix.client.resolvePlacement(
574
+ "paywall_main"
575
+ );
576
+ if (experiment) {
577
+ analytics.track("paywall_shown", {
578
+ experiment: experiment.key,
579
+ variant: experiment.variantId,
580
+ });
581
+ }
582
+ ```
583
+
584
+ `experiment` is `null` when no running test covers the placement. Assignment
585
+ is sticky per customer, survives `identify()` merges, and enrolls the
586
+ customer exactly once; the server records an `experiment.enrolled` ledger
587
+ event in the same transaction as the assignment.
588
+
589
+ ### Targeting: `setAttributes`
590
+
591
+ A test can be narrowed to an *audience*: conditions over customer
592
+ attributes. `setAttributes` is how a mobile app supplies the facts those
593
+ conditions read:
594
+
595
+ ```ts
596
+ await Revnix.client.setAttributes({
597
+ country: "US",
598
+ app_version: "4.2.0",
599
+ lifetime_orders: 3,
600
+ stale_key: null, // null deletes
601
+ });
602
+ ```
603
+
604
+ Unlike the fire-and-forget beacons this **awaits** the write, because the very
605
+ next `resolvePlacement` may depend on it and silently racing the audience
606
+ check would make targeting look broken. Set the attributes an audience targets
607
+ *before* the first resolve on a covered placement: eligibility is evaluated at
608
+ that resolve, and a customer described only afterwards enrolls on their next
609
+ one.
610
+
611
+ This is the one write under `/v1/customers/*` a publishable key may make. Two
612
+ server rules bound it, and each rejects the whole batch rather than applying
613
+ part of it: `email` and `username` are reserved (secret key, from your own
614
+ backend), and an attribute your backend already set cannot be changed from a
615
+ device.
616
+
617
+ ## Diagnostics
618
+
619
+ The SDK swallows failures it can recover from (retry-queue drains, placement
620
+ prefetches, install and paywall-view reports, storage read/write errors). `onDiagnostic` surfaces them: it is called with `{ op, error, at }` for every
621
+ such failure (ops include `retryPendingPurchases`, `prefetchPlacement`,
622
+ `waitForEntitlements`, `reportInstall`, `logPaywallShown`, `logPaywallClosed`,
623
+ `logPaywallEvent`, `paywall.render`, `storageRead`, `storageWrite`,
624
+ `handleInstallReferrer`, `handleAttributionToken`, `resolveDeepLink`,
625
+ `getLastDeepLink`, `rememberDeepLink`, `onDeferredDeepLink`,
626
+ `onImplicitPaywall`, `implicitConfig`, `implicitLaunch`,
627
+ `implicit:<placement>`, `preview`, `lifecycleSubscribe`, `session`,
628
+ `getAttribution`, `onAttribution`, `logAdRevenue`, `track`, `setAttribution`,
629
+ `setPushToken`);
630
+ exceptions thrown by your callback are swallowed. The count of background
631
+ failures since the last report also rides the next request as an
632
+ `X-Revnix-Bg-Failures: <n>` header, so the backend can see silent client pain
633
+ without any app-side wiring. One related guard: a 200 response with a
634
+ non-JSON body (captive portal, misbehaving proxy) throws a *retryable*
635
+ `RevnixError` instead of returning garbage.
636
+
637
+ `paywall.render` is the one op nothing on the network produces: a block
638
+ paywall reporting a paint string it could not read (a gradient, a clip, a
639
+ tile size this build does not draw). `RevnixPaywall` routes those through
640
+ `client.reportRenderDiagnostic(message)`, so a host that already wired
641
+ `onDiagnostic` sees render fallbacks with no new wiring. Call it yourself only
642
+ if you are drawing paywall blocks with your own renderer.
643
+
644
+ ## Errors
645
+
646
+ All API errors extend `RevnixError` (`status` + `isRetryable`, which is true
647
+ for status 0, 429, and 5xx):
648
+
649
+ | Class | Status | When |
650
+ |---|---|---|
651
+ | `RevnixTimeoutError` | 0 | Request exceeded `requestTimeoutMs` |
652
+ | `RevnixNetworkError` | 0 | fetch failed (offline, DNS, TLS) |
653
+ | `RevnixAuthError` | 401/403 | Bad/revoked key, or publishable key on a secret-gated endpoint |
654
+ | `PurchaseBlockedError` | 409 | Purchase token owned by another customer, transfers blocked |
655
+ | `RevnixRateLimitError` | 429 | Rate limited; `retryAfterMs` when the server sent `Retry-After` |
656
+ | `RevnixError` | other | Anything else; message from the server's `error` field |
657
+
658
+ ## Rendering the paywall (React Native)
659
+
660
+ `RevnixPaywall` renders a published paywall design exactly as the dashboard
661
+ paywall builder previews it; layout, copy, accent color, hero image, badge,
662
+ highlighted package, feature list, social proof, and offer framing all come
663
+ from the placement's `paywall.config`. The app supplies localized store prices
664
+ (StoreKit / Play Billing) and the purchase handlers, so the display never
665
+ disagrees with the charge.
666
+
667
+ `config.template` selects one of **nine layouts**, `focus`, `feature-list`,
668
+ `minimal` (the three original templates, byte-identical to how they always
669
+ rendered) plus `hero`, `timeline`, `plans`, `feature-grid`, `offer`, and
670
+ `reveal`. The dashboard's 200-template gallery is presets over these nine, so
671
+ a preset never introduces a shape this component cannot draw. Two optional
672
+ blocks compose onto any layout: `config.review` (star rating, quote, author,
673
+ and a `count` line under the CTA) and `config.offer` (struck-through anchor
674
+ price on the highlighted package, urgency line above the CTA).
675
+
676
+ ```tsx
677
+ import { RevnixPaywall } from "revnix-react/ui";
678
+
679
+ const { paywall, offering } = await Revnix.client.resolvePlacement("paywall_main");
680
+ if (!paywall || !offering) return null; // both are nullable; guard first
681
+
682
+ <RevnixPaywall
683
+ config={paywall.config}
684
+ packages={offering.packages.map((pkg) => ({
685
+ packageId: pkg.packageId,
686
+ title: pkg.product?.displayName ?? pkg.productId,
687
+ priceLabel: storePriceFor(pkg.productId), // localized, from the store
688
+ }))}
689
+ onPurchase={(packageId) => startPurchase(packageId)}
690
+ loading={purchasing}
691
+ onRestore={restorePurchases}
692
+ onTerms={openTerms}
693
+ onPrivacy={openPrivacy}
694
+ client={Revnix.client} // auto-reports paywall.viewed on mount
695
+ placementKey="paywall_main"
696
+ />;
697
+ ```
698
+
699
+ - Pass `client` to report a `paywall.viewed` event once per mount (opt out
700
+ with `disableViewTracking`); or call `client.logPaywallShown()` yourself, it's fire-and-forget and never throws.
701
+ - Report the **end** of the display too: `logPaywallShown()` hands back the
702
+ `viewId` it minted, and `client.logPaywallClosed(viewId, …)` closes that one
703
+ display. The shared id is what makes it idempotent, so a retry or a
704
+ double-dismiss can never count two. Without the close, a funnel knows how
705
+ many saw the paywall but not how many left without buying.
706
+ - `client.logPaywallEvent(event, viewId, options?)` reports what happened **in
707
+ between**, one of six: `selected`, `purchase_started`,
708
+ `purchase_abandoned`, `purchase_failed`, `restore`, `error`. `RevnixPaywall`
709
+ sends `selected`, `purchase_started`, `restore` and a no-products `error`
710
+ for you; the purchase *outcome* is yours, because your app performs the
711
+ store call. All six are pure ledger history: over-reporting can skew a
712
+ report, never grant or revoke access.
713
+ - Theming: dark by default, `config.mode: "light"` switches to the light
714
+ theme; override any token via the `theme` prop
715
+ (`DEFAULT_PAYWALL_THEME` / `LIGHT_PAYWALL_THEME` are exported).
716
+ - Selection can be controlled (`selectedPackageId` + `onSelectPackage`) or
717
+ left internal (defaults to the highlighted package).
718
+ - Pass `onClose` to let the customer dismiss: without it no close is drawn;
719
+ with `client` the close is reported as `paywall.closed`.
720
+ - Designed (block) paywalls: `locale` picks the copy language (defaults to
721
+ the device's), `gradient` takes your LinearGradient (expo-linear-gradient
722
+ or react-native-linear-gradient), `blur` takes a BlurView,
723
+ `fontFamilyMap` maps design fonts to registered faces, `onDiagnostic`
724
+ receives render fallbacks when no `client` is passed.
725
+ - Unreleased (on `main`, not in 1.3.0): `Revnix.setLocale(tag)` (also
726
+ exported from `revnix-react/ui`) forces every designed paywall mounted
727
+ after the call into `tag`'s language; `Revnix.setLocale()` clears it. A
728
+ `locale` prop still wins for that one view. The links block's Restore,
729
+ Terms and Privacy labels follow the paywall's language.
730
+
731
+ The `/ui` entrypoint is separate from the core so pure-JS consumers never
732
+ load `react` / `react-native` (both are peer dependencies used only by `/ui`).
733
+
734
+ ## Endpoints used
735
+
736
+ | Method | Endpoint | Key |
737
+ |---|---|---|
738
+ | `registerPurchase` | `POST /v1/purchases` | any |
739
+ | `getEntitlements` / `waitForEntitlements` | `GET /v1/customers/{id}/entitlements` | any |
740
+ | `getIdentity` | `GET /v1/customers/{id}/identity` | secret |
741
+ | `resolvePlacement` | `GET /v1/placements/{key}/offering` | any |
742
+ | install report | `POST /v1/installs` | any |
743
+ | `logPaywallShown` | `POST /v1/paywalls/viewed` | any |
744
+ | `logPaywallClosed` | `POST /v1/paywalls/closed` | any |
745
+ | `logPaywallEvent` | `POST /v1/paywalls/events` | any |
746
+ | `setAttributes` | `POST /v1/customers/{id}/attributes` | any |
747
+ | `identify` | `POST /v1/identify` | secret |
748
+ | `alias` | `POST /v1/customers/{id}/alias` | secret |
749
+ | implicit placement config | `GET /v1/config` | any |
750
+ | implicit moments / `handleDeepLink` | `POST /v1/placements/triggered` | any |
751
+ | dashboard preview link | `GET /v1/paywalls/preview/{token}` | any |
752
+ | `resolveDeepLink` | `GET /v1/links/resolve` | any (since 1.2.2) |
753
+ | `handleInstallReferrer` / `handleAttributionToken` | `POST /v1/installs` | any |
754
+ | `getAttribution` | `GET /v1/customers/{id}/attribution` | any (since 1.2.4) |
755
+ | `logAdRevenue` | `POST /v1/ad-revenue` | any (since 1.2.4) |
756
+ | `setPushToken` | `POST /v1/push-token` | any (since 1.3.0) |
757
+ | `track` | `POST /v1/events` | any (since 1.3.0) |
758
+ | `setAttribution` | `POST /v1/attribution` | any (since 1.3.0) |
759
+
760
+ ## Development
761
+
762
+ ```sh
763
+ npm install
764
+ npm test
765
+ npm run build # CJS + ESM (dist/ and dist/esm/)
766
+ ```