react-native-revnix 1.4.0 → 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 +139 -714
- package/android/build.gradle +34 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +247 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStorePackage.kt +14 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +69 -1
- package/dist/esm/client.js +69 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/store/index.js +22 -0
- package/dist/esm/store.js +9 -0
- package/dist/esm/ui/RevnixPaywall.js +36 -9
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +25 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +13 -0
- package/dist/types.d.ts +1 -1
- package/dist/ui/RevnixPaywall.d.ts +13 -3
- package/dist/ui/RevnixPaywall.js +36 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +18 -0
- package/ios/RevnixStore.swift +106 -0
- package/package.json +20 -2
- package/react-native-revnix.podspec +22 -0
- package/src/client.ts +88 -1
- package/src/index.ts +5 -0
- package/src/store/index.ts +44 -0
- package/src/store.ts +41 -0
- package/src/types.ts +1 -1
- package/src/ui/RevnixPaywall.tsx +55 -18
- package/src/version.ts +1 -1
- package/store.d.ts +1 -0
- package/store.js +1 -0
- package/ui.js +1 -1
package/README.md
CHANGED
|
@@ -1,766 +1,191 @@
|
|
|
1
|
-
|
|
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
|
+

|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
53
|
+
```sh
|
|
54
|
+
# using npm
|
|
55
|
+
npm install react-native-revnix
|
|
56
|
+
|
|
57
|
+
# or using yarn
|
|
58
|
+
yarn add react-native-revnix
|
|
59
|
+
```
|
|
8
60
|
|
|
9
|
-
|
|
61
|
+
Then `cd ios && pod install`. Android links through Gradle automatically.
|
|
62
|
+
|
|
63
|
+
### For Expo projects
|
|
10
64
|
|
|
11
65
|
```sh
|
|
12
|
-
|
|
66
|
+
npx expo install react-native-revnix
|
|
67
|
+
npx expo prebuild
|
|
13
68
|
```
|
|
14
69
|
|
|
15
|
-
|
|
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
|
|
18
|
-
import
|
|
77
|
+
import "react-native-revnix/store";
|
|
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: "
|
|
25
|
-
baseUrl: "https://<deployment>.convex.site",
|
|
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 "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
92
|
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
93
|
+
// 2. Buy: opens the store sheet, validates the receipt, grants the entitlement
|
|
94
|
+
await Revnix.client.purchase("pro.monthly");
|
|
240
95
|
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+

|
|
396
117
|
|
|
397
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
127
|
+
## A/B tests with a built-in holdout
|
|
412
128
|
|
|
413
|
-
|
|
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
|
+

|
|
418
130
|
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
+

|
|
434
141
|
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
const attribution = await Revnix.client.getAttribution();
|
|
447
|
-
```
|
|
150
|
+

|
|
448
151
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
459
|
-
Revnix.configure({
|
|
460
|
-
apiKey, baseUrl, storage,
|
|
461
|
-
onAttribution: (attribution) => analytics.setUserProperties(attribution),
|
|
462
|
-
});
|
|
463
|
-
```
|
|
157
|
+
## Compatibility
|
|
464
158
|
|
|
465
|
-
|
|
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
|
-
##
|
|
165
|
+
## Documentation
|
|
468
166
|
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
177
|
+
## Migrating from another SDK
|
|
481
178
|
|
|
482
|
-
|
|
483
|
-
reports through `registerPurchase`.
|
|
484
|
-
|
|
485
|
-
```ts
|
|
486
|
-
Revnix.client.track("level_up", { level: 5, difficulty: "hard" });
|
|
487
|
-
```
|
|
179
|
+
Moving from Adapty or RevenueCat? Revnix imports your customers and purchase history, and the SDK calls map one to one.
|
|
488
180
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
invalid `event` name sends nothing. Fire-and-forget, like `logAdRevenue`.
|
|
181
|
+
- [Migrate from Adapty](https://www.revnix.io/docs/migrate-from-adapty)
|
|
182
|
+
- [Migrate from RevenueCat](https://www.revnix.io/docs/migrate-from-revenuecat)
|
|
492
183
|
|
|
493
|
-
##
|
|
184
|
+
## Support
|
|
494
185
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
Revnix credits revenue to the right network/campaign.
|
|
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.
|
|
498
188
|
|
|
499
|
-
|
|
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
|
-
```
|
|
189
|
+
## License
|
|
523
190
|
|
|
524
|
-
|
|
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
|
-
```
|
|
191
|
+
Revnix SDK is available under the MIT license. See [LICENSE](https://github.com/Oth-tech/revnix-sdk/blob/main/LICENSE) for details.
|