react-native-revnix 1.4.2 → 1.5.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.
package/README.md CHANGED
@@ -1,766 +1,191 @@
1
- # react-native-revnix
1
+ <p align="center">
2
+ <a href="https://www.revnix.io"><img src="https://www.revnix.io/sdk/logo.png" width="360" alt="Revnix"></a>
3
+ </p>
4
+
5
+ <h1 align="center">Subscriptions, Paywalls and Attribution<br>for Your React Native App</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/react-native-revnix"><img src="https://img.shields.io/npm/v/react-native-revnix?color=2f6fe0&logo=npm" alt="npm version"></a>
9
+ <a href="https://github.com/Oth-tech/revnix-sdk/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/react-native-revnix?color=2f6fe0" alt="license"></a>
10
+ <img src="https://img.shields.io/badge/TypeScript-ready-2f6fe0?logo=typescript&logoColor=white" alt="TypeScript">
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://www.revnix.io"><b>Website</b></a> •
15
+ <a href="https://www.revnix.io/docs/react-native"><b>Docs</b></a> •
16
+ <a href="https://www.revnix.io/docs/react-native/reference"><b>API Reference</b></a>
17
+ </p>
18
+
19
+ ![Revnix: subscriptions, paywalls and attribution for mobile apps](https://www.revnix.io/sdk/hero.png)
20
+
21
+ Revnix SDK makes in-app subscriptions, paywalls and attribution for React Native fast and easy. One package buys through StoreKit 2 and Google Play Billing, validates the receipt on the server and unlocks the entitlement. Zero npm dependencies.
22
+
23
+ ## Table of Contents
24
+
25
+ - [Why Revnix?](#why-revnix)
26
+ - [Getting Started](#getting-started)
27
+ - [Quick start](#quick-start)
28
+ - [Purchases and entitlements without server code](#purchases-and-entitlements-without-server-code)
29
+ - [Paywalls that update without app releases](#paywalls-that-update-without-app-releases)
30
+ - [A/B tests with a built-in holdout](#ab-tests-with-a-built-in-holdout)
31
+ - [Attribution and deep links](#attribution-and-deep-links)
32
+ - [Real-time analytics for your React Native app](#real-time-analytics-for-your-react-native-app)
33
+ - [Compatibility](#compatibility)
34
+ - [Documentation](#documentation)
35
+ - [Migrating from another SDK](#migrating-from-another-sdk)
36
+ - [Support](#support)
37
+ - [License](#license)
38
+
39
+ ## Why Revnix?
40
+
41
+ - [Purchases in one call](https://www.revnix.io/docs/react-native/make-purchases). `purchase()` opens the App Store or Google Play sheet, validates the receipt on the server and grants the entitlement. No react-native-iap, no server code.
42
+ - [Entitlements that work offline](https://www.revnix.io/docs/react-native/check-entitlements). Entitlement checks are cached on device, so a user who paid stays unlocked without a network.
43
+ - [Remote paywalls](https://www.revnix.io/docs/paywall-builder). Design paywalls in the dashboard, pick from 250 templates and ship copy, prices and layout changes without an app release.
44
+ - [A/B tests and holdouts](https://www.revnix.io/docs/ab-tests). Split a placement between variants, measure revenue per user with credible intervals and ship the winner from the dashboard.
45
+ - [Attribution and deep links](https://www.revnix.io/docs/attribution). Install attribution, deferred deep links, Apple Search Ads, Google Ads and MMP forwarding, all from the same SDK.
46
+ - [Integrations](https://www.revnix.io/docs/integrations). Send subscription events to analytics, attribution and messaging tools, or to your own server through signed webhooks.
47
+ - [Real-time analytics](https://www.revnix.io/docs/analytics). Revenue, MRR, LTV, ROAS, cohorts and funnels for iOS and Android, filtered by store, product, channel and campaign.
48
+
49
+ ## Getting Started
50
+
51
+ ### For React Native projects
2
52
 
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: react-native-revnix/<version>` header (`SDK_VERSION` is exported).
53
+ ```sh
54
+ # using npm
55
+ npm install react-native-revnix
56
+
57
+ # or using yarn
58
+ yarn add react-native-revnix
59
+ ```
60
+
61
+ Then `cd ios && pod install`. Android links through Gradle automatically.
8
62
 
9
- ## Install
63
+ ### For Expo projects
10
64
 
11
65
  ```sh
12
- npm install react-native-revnix
66
+ npx expo install react-native-revnix
67
+ npx expo prebuild
13
68
  ```
14
69
 
15
- ## Setup
70
+ The store module is native code, so Expo needs a development build rather than Expo Go.
71
+
72
+ Read the [installation guide](https://www.revnix.io/docs/react-native/installation) and [configuration reference](https://www.revnix.io/docs/react-native/configuration) to set up the SDK.
73
+
74
+ ## Quick start
16
75
 
17
76
  ```ts
77
+ import "react-native-revnix/store";
18
78
  import { Revnix } from "react-native-revnix";
19
79
  import { MMKV } from "react-native-mmkv";
20
80
 
21
81
  const mmkv = new MMKV();
22
82
 
83
+ // 1. Configure once at app start
23
84
  Revnix.configure({
24
- apiKey: "rvx_pk_test_…", // dashboard → Settings → API keys
25
- baseUrl: "https://<deployment>.convex.site", // Convex site URL
85
+ apiKey: "rvx_pk_live_…",
86
+ baseUrl: "https://<your-deployment>.convex.site",
26
87
  storage: {
27
88
  getItem: (k) => mmkv.getString(k) ?? null,
28
89
  setItem: (k, v) => mmkv.set(k, v),
29
90
  },
30
91
  });
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 "react-native-revnix";
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
92
 
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 "react-native-revnix";
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
- ```
93
+ // 2. Buy: opens the store sheet, validates the receipt, grants the entitlement
94
+ await Revnix.client.purchase("pro.monthly");
219
95
 
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();
96
+ // 3. Check access anywhere
243
97
  const pro = await Revnix.client.isEntitled("pro");
244
- const { offering, paywall } = await Revnix.client.resolvePlacement("paywall_main");
245
98
  ```
246
99
 
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.
100
+ Your API key and deployment URL are in the dashboard under Settings. See [API keys](https://www.revnix.io/docs/api-keys).
311
101
 
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
- });
102
+ ## Purchases and entitlements without server code
326
103
 
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
- ```
104
+ **Revnix handles the hard parts of subscriptions in a small, developer-friendly SDK.**
332
105
 
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:
106
+ - `purchase(productId)` runs the whole flow: store sheet, server-side receipt validation, entitlement, transaction finish. `restore()` brings purchases back on a new device.
107
+ - Renewals, purchases on other devices and Ask to Buy approvals arrive on their own and are registered automatically.
108
+ - Entitlements and placements are cached on device, so paid users stay unlocked offline.
109
+ - Failed registrations are retried automatically when the app comes back online.
110
+ - Identify users across devices with `identify()`; purchases follow the customer, not the install. See [Identify users](https://www.revnix.io/docs/react-native/identify-users).
111
+ - Typed errors for blocked purchases and account transfers. See [Errors and transfers](https://www.revnix.io/docs/react-native/errors-and-transfers).
112
+ - Already using `react-native-iap` or another store library? Keep it and call `registerPurchase()` instead. See [Make purchases](https://www.revnix.io/docs/react-native/make-purchases).
384
113
 
385
- ```ts
386
- Revnix.configure({
387
- apiKey, baseUrl, storage,
388
- onDeferredDeepLink: (url, match) => router.push(url),
389
- });
390
- ```
114
+ ## Paywalls that update without app releases
391
115
 
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.
116
+ ![Revnix paywall builder: element tree, background library and a live iPhone preview](https://www.revnix.io/sdk/react-native/paywalls.png)
396
117
 
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`:
118
+ With the [Revnix paywall builder](https://www.revnix.io/docs/paywall-builder) you design the paywall in the dashboard and render it natively in your app.
400
119
 
401
- ```ts
402
- const url = await Revnix.client.resolveDeepLink(clickedUrl);
403
- router.push(url);
404
- Revnix.client.handleDeepLink(url);
405
- ```
120
+ - **Native rendering**: `RevnixPaywall` from `react-native-revnix/ui` draws the paywall with React Native views, no web views, and buys the selected package itself.
121
+ - **250 templates**: pick a layout, theme and accent, then edit copy, packages and badges.
122
+ - **Update without redeploying**: change prices, copy or layout any time; the next placement resolve picks it up.
123
+ - **Localized**: 43 languages for the built-in strings, plus per-locale copy of your own.
406
124
 
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.
125
+ Learn more in [Show paywalls](https://www.revnix.io/docs/react-native/show-paywalls) and [Placements](https://www.revnix.io/docs/placements).
410
126
 
411
- ## Apple Search Ads attribution
127
+ ## A/B tests with a built-in holdout
412
128
 
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:
129
+ ![Revnix A/B test results with a winner and credible intervals](https://www.revnix.io/sdk/react-native/ab-test.png)
418
130
 
419
- ```ts
420
- const verdict = await Revnix.client.handleAttributionToken(token);
421
- ```
131
+ - Split a placement's traffic between offering and paywall variants. Conversions, trials, revenue per user and MRR are calculated per variant.
132
+ - Add a **holdout** variant that shows no paywall at all, to measure what the paywall is really worth.
133
+ - Bayesian results with 95% credible intervals and a sample-size planner, so you know when to stop.
134
+ - Ship the winner from the dashboard; the SDK needs no change.
422
135
 
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.
136
+ Learn more in [A/B tests](https://www.revnix.io/docs/ab-tests).
428
137
 
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.
138
+ ## Attribution and deep links
432
139
 
433
- ## Install attribution
140
+ ![Revnix MRR by country, last 90 days](https://www.revnix.io/sdk/react-native/attribution.png)
434
141
 
435
- Needs 1.2.4 or later. On 1.2.3, call `GET /v1/customers/{id}/attribution`
436
- yourself.
142
+ - **Install attribution** from Revnix links, with per-link click lookback windows. See [Attribution](https://www.revnix.io/docs/attribution).
143
+ - **Deferred deep links** resolve on first launch, so a user who installs from a campaign lands on the right screen. See [Deferred deep links](https://www.revnix.io/docs/deferred-deep-links).
144
+ - **Apple Search Ads** and **Google Ads** attribution, with App Tracking Transparency support. See [Apple Search Ads](https://www.revnix.io/docs/apple-search-ads) and [Google Ads](https://www.revnix.io/docs/google-ads-attribution).
145
+ - **MMP forwarding** to AppsFlyer and Adjust, **ad revenue** logging and **uninstall measurement**. See [MMP attribution](https://www.revnix.io/docs/mmp-attribution), [Ad revenue](https://www.revnix.io/docs/ad-revenue) and [Uninstall measurement](https://www.revnix.io/docs/uninstall-measurement).
146
+ - **Fraud prevention**: anonymous-IP and click-injection checks keep paid channels honest. See [Fraud prevention](https://www.revnix.io/docs/fraud-prevention).
437
147
 
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.
148
+ ## Real-time analytics for your React Native app
444
149
 
445
- ```ts
446
- const attribution = await Revnix.client.getAttribution();
447
- ```
150
+ ![Revnix overview: active subscriptions, revenue and MRR over 90 days](https://www.revnix.io/sdk/react-native/analytics.png)
448
151
 
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.
152
+ - Revenue, MRR, ARR, ARPU, LTV and ROAS, updated from the ledger as purchases arrive.
153
+ - Cohorts, retention, funnels and predicted LTV. See [Analytics](https://www.revnix.io/docs/analytics).
154
+ - Filter and group by store, product, country, channel, campaign and A/B test variant.
155
+ - Saved reports, alerts and a REST API for your own dashboards. See [Reports](https://www.revnix.io/docs/reports) and [REST API](https://www.revnix.io/docs/rest-api).
457
156
 
458
- ```ts
459
- Revnix.configure({
460
- apiKey, baseUrl, storage,
461
- onAttribution: (attribution) => analytics.setUserProperties(attribution),
462
- });
463
- ```
157
+ ## Compatibility
464
158
 
465
- The SDK never fetches attribution on its own unless `onAttribution` is set.
159
+ - **React Native** 0.72 or later, New Architecture and legacy architecture alike. iOS 15.1 or later (StoreKit 2), Android with Google Play Billing 8.
160
+ - **Expo**: supported with a development build. Expo Go cannot load the store module.
161
+ - **React and Next.js**: the same package powers web apps; the store module is mobile only, web registers purchases from your server. See [React](https://www.revnix.io/docs/react).
162
+ - **Other platforms**: Revnix also ships SDKs for [iOS](https://www.revnix.io/docs/ios), [Android](https://www.revnix.io/docs/android), [Flutter](https://www.revnix.io/docs/flutter), [Capacitor](https://www.revnix.io/docs/capacitor) and [Unity](https://www.revnix.io/docs/unity).
163
+ - Ships CommonJS and ESM builds, tree-shakeable, with full TypeScript types.
466
164
 
467
- ## Ad revenue
165
+ ## Documentation
468
166
 
469
- Needs 1.2.4 or later. On 1.2.3, call `POST /v1/ad-revenue` yourself.
167
+ - [Overview](https://www.revnix.io/docs/react-native)
168
+ - [Installation](https://www.revnix.io/docs/react-native/installation)
169
+ - [Configuration](https://www.revnix.io/docs/react-native/configuration)
170
+ - [Identify users](https://www.revnix.io/docs/react-native/identify-users)
171
+ - [Make purchases](https://www.revnix.io/docs/react-native/make-purchases)
172
+ - [Check entitlements](https://www.revnix.io/docs/react-native/check-entitlements)
173
+ - [Show paywalls](https://www.revnix.io/docs/react-native/show-paywalls)
174
+ - [Errors and transfers](https://www.revnix.io/docs/react-native/errors-and-transfers)
175
+ - [API reference](https://www.revnix.io/docs/react-native/reference)
470
176
 
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).
177
+ ## Migrating from another SDK
549
178
 
550
- ## Last deep link
179
+ Moving from Adapty or RevenueCat? Revnix imports your customers and purchase history, and the SDK calls map one to one.
551
180
 
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
- ```
181
+ - [Migrate from Adapty](https://www.revnix.io/docs/migrate-from-adapty)
182
+ - [Migrate from RevenueCat](https://www.revnix.io/docs/migrate-from-revenuecat)
560
183
 
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
- ```
184
+ ## Support
583
185
 
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.
186
+ - Email [support@revnix.io](mailto:support@revnix.io) with questions, bugs or feature requests.
187
+ - Check the [status page](https://www.revnix.io/status) for incidents.
588
188
 
589
- ### Targeting: `setAttributes`
189
+ ## License
590
190
 
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 "react-native-revnix/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 `react-native-revnix/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
- ```
191
+ Revnix SDK is available under the MIT license. See [LICENSE](https://github.com/Oth-tech/revnix-sdk/blob/main/LICENSE) for details.