&path=` to find
which funnel a request on a custom domain names: `{ projectId, slug }` for
a verified, cert-issued host, matching only the first path segment (the
rest belongs to the funnel, so `/quiz/step-2` and `/quiz?x=1` both still
name `quiz`).
## The "Powered by Rovenue" badge [#the-powered-by-rovenue-badge]
On Rovenue Cloud, a hosted web funnel of a project with no card on file
carries a small "Powered by Rovenue" link at the bottom of every screen. It
sits in its own row under the page, so it never covers the CTA, a sticky
footer or the checkout, and it is plain text in the funnel's own colours —
no image, no request to anywhere (`packages/funnel-renderer/src/powered-by.tsx`).
The API decides it, never the browser: the published-funnel payload carries
`branding.poweredBy`, which is `true` only on Rovenue Cloud, for a web
funnel, when the project's billing subscription is not `active` or
`past_due` (`apps/api/src/services/funnel/branding.ts`). Your own domain and
the `/f//` address show the same thing, and the builder's
**Play through** preview shows it exactly as visitors will.
To remove it, add a card under **Settings → Billing**: the next time the
funnel loads, the badge is gone. The value is read on every request, not
from the published-funnel cache, so there is no delay to wait out.
Native funnels never carry it — an app-store paywall is your app's own UI —
and a self-hosted install never shows it.
## Embedding [#embedding]
A web funnel can run in an `iframe` inside your own page. Two things can
reach the host page, and nothing else — custom actions and the close event
travel out through `window.postMessage`
(`apps/dashboard/src/runner/host-bridge.ts:5-10`):
| Message | When |
| --------------- | ----------------------------------------------------- |
| `custom_action` | A `custom` action ran, with its `actionId` and params |
| `close_funnel` | The funnel ended |
(`apps/dashboard/src/runner/host-bridge.ts:20-28`).
```ts compile=body
function onCustomAction(actionId: string | undefined, params: Record | undefined): void {
void actionId;
void params;
}
function onFunnelClosed(): void {}
window.addEventListener("message", (event) => {
const msg = event.data as {
source?: string;
type?: string;
actionId?: string;
params?: Record;
} | null;
if (msg?.source !== "rovenue-funnel") return;
if (msg.type === "custom_action") onCustomAction(msg.actionId, msg.params);
if (msg.type === "close_funnel") onFunnelClosed();
});
```
Custom actions are also pushed to `window.dataLayer` as
`rovenue_funnel_custom_action`, so a tag manager can pick them up without
any code (`apps/dashboard/src/runner/host-bridge.ts:14`,
`apps/dashboard/src/runner/host-bridge.ts:40-45`).
The envelope deliberately carries no session id and no visitor id. Messages
are posted to any origin, so anything in them would be readable by whoever
embeds the page — identifiers are capabilities, and none are sent
(`apps/dashboard/src/runner/host-bridge.ts:6-10`).
### Talking *to* a running funnel [#talking-to-a-running-funnel]
You cannot. A funnel reads its inputs once, from the URL that opened it:
`utm_*`, the referrer, and the declared app values seeded from the query
string (`apps/dashboard/src/runner/session-inputs.ts:30-39`,
`apps/dashboard/src/runner/session-inputs.ts:40-53`). Set them on the
`iframe`'s `src` and they are there before the first screen renders — a
marketing link, an embed, and a native WebView all write the same query
string (`apps/dashboard/src/runner/session-inputs.ts:7-12`).
This is also how the funnel runs inside a native WebView: the app builds
the same URL, with the same query string. There is no separate mobile
embed (`apps/dashboard/src/runner/session-inputs.ts:7-12`).
## Payments and Apple Pay [#payments-and-apple-pay]
A web funnel's checkout takes payment on the page through your connected
Stripe account — see [Checkout](/docs/funnels/web/checkout). Apple Pay
appears in that sheet only when the host the page was served from is
registered as a payment-method domain on the account taking the charge, so
registration runs per connected account
(`apps/api/src/services/stripe/apple-pay-domain.ts:12-17`).
The domain check is proved by an association file: one platform-wide copy
served from the funnel host verifies that host for every connected
account (`apps/api/src/services/stripe/apple-pay-domain.ts:42-48`). The
host registered is the one the paywall is served from — registering any
other host succeeds at the API level and Apple Pay silently never appears
(`apps/api/src/services/stripe/apple-pay-domain.ts:99-110`).
# Core Concepts (/docs/getting-started/core-concepts)
The Rovenue SDK is built around a small set of concepts that apply identically across React Native, Swift, and Kotlin. Understanding them makes every other part of the API predictable.
***
## The two user IDs: `rovenueId` and `appUserId` [#the-two-user-ids-rovenueid-and-appuserid]
Every install gets a **permanent, anonymous** `rovenueId` (prefix `rov_`) the moment `configure()` is called. It is stable for the lifetime of the app install — it does not rotate on app restart, network change, or OS update.
At login, you call `identify(appUserId)` to attach your own backend user ID to the `rovenueId`. From that point, Rovenue uses the `appUserId` as the canonical subscriber identity.
```
Install Login Sign-out
│ │ │
▼ ▼ ▼
rovenueId = rov_abc... appUserId = "usr_123" logOut()
(anonymous subscriber) (identified subscriber) (new rovenueId minted)
```
The current state is always available via `currentUser()`:
### Types on this page [#types-on-this-page]
```ts
type User = {
rovenueId: string; // permanent per-install id, prefix rov_; never changes
appUserId: string | null; // your backend user id; null until identify()
};
type ChangeEvent =
| 'EntitlementsChanged' // active entitlements updated
| 'IdentityChanged' // rovenueId or appUserId changed (identify / logOut)
| 'VirtualCurrenciesChanged'; // virtual-currency balances changed
```
| Field | Type | Description |
| ----------- | ---------------- | --------------------------------------------------------------------------------------------- |
| `rovenueId` | `string` | Permanent per-install identifier. Never reassigned for the life of the install. |
| `appUserId` | `string \| null` | Your backend's user ID. `null` in anonymous mode. Set by `identify()`; cleared by `logOut()`. |
> Swift uses `User.rovenueId` / `User.appUserId`. Kotlin mirrors the same field names. `ChangeEvent` is a Swift enum (`.entitlementsChanged`) and a Kotlin enum (`ENTITLEMENTS_CHANGED`). See [Types](/docs/reference/types#user).
The `rovenueId` is the unit of billing: it is what gets linked to App Store / Play Store receipts and what holds virtual-currency balances. Even in anonymous mode, purchases work correctly — they attach to the `rovenueId` and are transferred to the `appUserId` automatically when `identify()` is called.
```ts
import { Rovenue, useCurrentUser } from '@rovenue/react-native-sdk';
// Imperative read
const user = Rovenue.currentUser();
// user.rovenueId → "rov_abc123..."
// user.appUserId → null (anonymous) or "usr_123" (identified)
// Reactive hook (re-renders on IdentityChanged)
function Header() {
const user = useCurrentUser(); // User | null
return {user?.appUserId ?? user?.rovenueId};
}
```
```swift
import Rovenue
let user = await Rovenue.shared.currentUser()
// user.rovenueId → "rov_abc123..."
// user.appUserId → nil (anonymous) or "usr_123" (identified)
```
```kotlin
import dev.rovenue.sdk.Rovenue
val user = Rovenue.shared.currentUser()
// user.rovenueId → "rov_abc123..."
// user.appUserId → null (anonymous) or "usr_123" (identified)
```
### `identify()` — binding a logged-in user [#identify--binding-a-logged-in-user]
Call `identify(appUserId)` once your auth flow completes and you have a stable, permanent user ID. The call is **optimistic**: local state (including `currentUser()`) updates synchronously; in the background the SDK calls `POST /v1/identify` to perform the server-side bind and purchase transfer.
Pass a **stable, permanent** user ID — a UUID or database primary key from your backend. Never pass a session token, email address, or anything that can change. Passing an unstable ID creates a new subscriber record per login and the merge cannot be undone. See [Identifying users](/docs/guides/identifying-users) and [Identity & consent](/docs/guides/identity-consent).
### `logOut()` — clearing identity on sign-out [#logout--clearing-identity-on-sign-out]
`logOut()` mints a fresh `rovenueId` and clears the `appUserId`. All scope-bound caches (entitlements, credits, offerings) are cleared. The next user on a shared device starts completely clean. No network call is needed — the operation is local-only. Session events and attribute changes the outgoing user made but the SDK has not sent yet are kept, not discarded: each was recorded under that user's `rovenueId` and is still delivered under it, never under the next user.
Always call `logOut()` on sign-out, especially on shared devices (family iPad, demo devices). Failing to do so leaves the previous user's entitlements accessible to the next session.
Method references: [`identify`](/docs/reference/methods#identity), [`logOut`](/docs/reference/methods#logout), [`currentUser`](/docs/reference/methods#currentuser).
***
## Entitlements — what your app actually checks [#entitlements--what-your-app-actually-checks]
An **entitlement** is a named access grant your app checks at runtime (for example, `"pro"`, `"premium"`, or `"credits_pack"`). Entitlements are independent of store SKUs: you configure the mapping in the Rovenue dashboard, then check the entitlement ID in code.
**The key mental model:** your feature-gating code never references a product ID or SKU. It only ever asks "does the current user have entitlement `"pro"`?" The Rovenue dashboard is where you wire store products to entitlements. This means you can add new products, change prices, or run a promo without shipping a new binary.
Entitlements have an `isActive` boolean — the only field your app needs at the gate:
```ts
type Entitlement = {
id: string; // entitlement id as configured in the dashboard
isActive: boolean; // true = access currently granted
productIdentifier: string; // store SKU that created the grant
store: string; // "app_store" | "play_store" | "stripe"
expiresIso?: string; // ISO-8601 expiry; undefined for lifetime products
};
```
| Field | Type | Description |
| ------------------- | --------- | ------------------------------------------------------------------- |
| `id` | `string` | Dashboard entitlement ID. This is what you pass to `entitlement()`. |
| `isActive` | `boolean` | The only field to check at the gate. |
| `productIdentifier` | `string` | The store SKU that created the grant. |
| `store` | `string` | Which store issued the grant. |
| `expiresIso` | `string?` | Expiry in ISO-8601. Absent for lifetime products. |
> See [Types](/docs/reference/types#entitlement) and [Entitlements guide](/docs/guides/entitlements).
Check `isActive`, not `expiresIso`. An entitlement with a future expiry date and `isActive: false` is in a grace period or was revoked. The server sets `isActive` based on subscription state — your app should treat it as a boolean gate.
```tsx
import { useEntitlement, Rovenue } from '@rovenue/react-native-sdk';
// Reactive hook — re-renders on EntitlementsChanged
function ProFeature() {
const pro = useEntitlement('pro');
if (!pro?.isActive) return ;
return ;
}
// Imperative — for non-component code
const pro = Rovenue.entitlement('pro');
const all = Rovenue.entitlementsAll();
```
```swift
import Rovenue
// Cache-first read; never suspends on network
let pro = await Rovenue.shared.entitlement("pro")
let all = await Rovenue.shared.entitlementsAll()
if pro?.isActive == true {
showProFeature()
}
```
```kotlin
import dev.rovenue.sdk.Rovenue
val pro = Rovenue.shared.entitlement("pro")
val all = Rovenue.shared.entitlementsAll()
if (pro?.isActive == true) {
showProFeature()
}
```
***
## Virtual Currencies — multi-currency balances [#virtual-currencies--multi-currency-balances]
Virtual currencies let you grant any number of named currency balances per subscriber (e.g. `COINS`, `GEMS`, `ENERGY`). The SDK exposes them as cache-first reads. **Spending is server-side only** — your backend calls the secret-key `POST /v1/virtual-currencies/:appUserId/:code/transactions` endpoint; there is no client-side spend method.
```ts
// Read all balances (cache-first) — returns { [currencyCode]: amount }
Rovenue.virtualCurrencies()
// Read a single currency by code (0 if absent)
Rovenue.virtualCurrency('COINS')
// Force a network refresh
Rovenue.refreshVirtualCurrencies()
```
React Native also ships `useVirtualCurrencies()` → `Record` and `useVirtualCurrency(code)` → `number` hooks that subscribe to `VirtualCurrenciesChanged` internally and re-render automatically.
Virtual-currency balances are part of the `PurchaseResult` — after a currency-pack purchase, `result.virtualCurrencies` already reflects the updated balances. No separate refresh is needed. See the [Virtual Currencies guide](/docs/guides/credits).
### When a product grants currency [#when-a-product-grants-currency]
A product's currency grant is set to fire on `PURCHASE`, on `RENEWAL`, or on `BOTH`. Rovenue applies one rule on every store:
1. **Only a paid term grants.** A paid term is one store charge with an amount above zero: the first charge of a subscription, a renewal, the first paid charge after a free trial, a resubscribe after the old subscription lapsed, or a one-time purchase.
2. **Each term grants once per currency, project-wide.** A redelivered notification, a receipt the app sends again, a restore, or a merge of two subscribers between two deliveries does not grant a second time.
3. **Nothing else grants.** Free trials, auto-renew toggles, cancellations, expiries, billing issues, grace periods, plan-change announcements, family-shared members and operator comps (amount zero) grant nothing.
4. **A refund takes back that term's grant.** The refunded term's grants come back out of the balance, in proportion for a partial refund. Rovenue never takes more than the live balance holds. A revoke takes back every term of the subscription. If the store reverses the refund, the grant is restored.
The first paid term of a subscription fires `PURCHASE`; every later auto-renewed term fires `RENEWAL`. `BOTH` fires on either. A resubscribe fires `PURCHASE`; a currency with no `PURCHASE` grant gets its `RENEWAL` grant instead — never both.
| Event | Grants | Trigger |
| ------------------------------------------------------ | -------------------------------------------- | -------------------------- |
| Buy (first paid charge, or a one-time purchase) | yes | `PURCHASE` |
| Renewal | yes | `RENEWAL` |
| Free trial starts | no | — |
| First paid charge after a free trial | yes | `PURCHASE` |
| Resubscribe after the subscription expired (any store) | yes | `PURCHASE`, else `RENEWAL` |
| Cancel (now or at period end), or undo a cancel | no; the balance is kept | — |
| Refund of one term (full or partial) | takes back that term's grant (in proportion) | — |
| Revoke (family sharing removed, chargeback) | takes back every term's grant | — |
***
## Offerings and packages — the product catalogue [#offerings-and-packages--the-product-catalogue]
An **offering** is a named, versioned slice of your product catalogue as configured in the Rovenue dashboard. The `current` offering is what your funnels sell this user (you can A/B test offerings with experiments). Each offering contains **packages** — individual purchasable items.
```ts
type Offerings = {
current: Offering | null;
all: Record;
};
type Offering = { identifier: string; isDefault: boolean; packages: Package[] };
type Package = {
identifier: string;
packageType: 'monthly' | 'annual' | 'weekly' | /* ... */ 'lifetime';
product: StoreProduct;
};
type StoreProduct = {
id: string;
type: 'subscription' | 'consumable' | 'non_consumable';
productCategory: 'subscription' | 'nonSubscription';
displayName: string;
description: string | null;
priceString: string | null; // localised, e.g. "$4.99"
price: number | null;
currencyCode: string | null;
subscriptionPeriod: Period | null; // { value: number; unit: 'day'|'week'|'month'|'year'; iso8601: string }
introPrice: IntroPrice | null; // iOS only
discounts: Discount[]; // iOS promotional offers
isEligibleForIntroOffer: boolean | null;
subscriptionOptions: SubscriptionOption[] | null; // Android only
defaultOption: SubscriptionOption | null;
pricePerMonth: number | null; // approximated per-unit prices
pricePerMonthString: string | null;
// ... see Types reference for full schema
};
```
`getOfferings()` returns a cached result and refreshes in the background. When you need a guaranteed-fresh list (unusual; typically not needed on the critical path), call `refreshEntitlements()` and then `getOfferings()`.
## Funnels — onboarding and payment screen in one document [#funnels--onboarding-and-payment-screen-in-one-document]
A **funnel** is the one authoring product for everything before the purchase: a multi-screen onboarding with branching, values, and the payment screen in a single document, created for exactly one target — `web` (hosted URL, Stripe checkout) or `native` (served to a placement inside your app, store purchase). The SDK presents native funnels and reports their lifecycle events; see [Funnels](/docs/funnels) for the full guide.
***
## Cache-first reads and background sync [#cache-first-reads-and-background-sync]
All read operations — `entitlement()`, `entitlementsAll()`, `virtualCurrencies()`, `virtualCurrency()`, `getOfferings()` — return instantly from an on-device SQLite cache powered by the Rust core. They **never block on a network call**.
The SDK keeps the cache warm automatically:
1. On `configure()`: loads the SQLite snapshot, serves it immediately.
2. While foregrounded: polls the server on a configurable interval (default: 30 s) and applies deltas silently.
3. On purchase: immediately calls `refreshEntitlements()` before resolving `purchase()`, so `PurchaseResult` is always authoritative.
4. On `setForeground(true)`: forces an immediate refresh (call this from your app's foreground lifecycle event).
The cache is backed by rusqlite — it persists across app restarts. On first launch (empty cache) entitlements are `null` / empty until the first network poll completes. Design your paywall to handle this gracefully (show a loading state, not a blank screen).
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
import { AppState } from 'react-native';
// Tell the SDK when the app foregrounds so it refreshes immediately
AppState.addEventListener('change', state => {
Rovenue.setForeground(state === 'active');
});
```
```swift
import Rovenue
import UIKit
// In SceneDelegate or AppDelegate:
func sceneDidBecomeActive(_ scene: UIScene) {
Rovenue.shared.setForeground(true)
}
func sceneWillResignActive(_ scene: UIScene) {
Rovenue.shared.setForeground(false)
}
```
```kotlin
import dev.rovenue.sdk.Rovenue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.ProcessLifecycleOwner
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = Rovenue.shared.setForeground(true)
override fun onStop(owner: LifecycleOwner) = Rovenue.shared.setForeground(false)
})
```
See [App lifecycle](/docs/guides/app-lifecycle) for the full setup.
***
## The change stream — reacting to updates [#the-change-stream--reacting-to-updates]
When the SDK receives updated data from the server, it emits a `ChangeEvent`. Subscribe once and update your UI reactively — no polling, no manual refresh.
```ts
type ChangeEvent =
| 'EntitlementsChanged' // active entitlements updated
| 'IdentityChanged' // rovenueId or appUserId changed
| 'VirtualCurrenciesChanged'; // virtual-currency balances changed
```
| Event | When it fires |
| -------------------------- | --------------------------------------------------------------------------- |
| `EntitlementsChanged` | After purchase, restore, server-side revocation, or background poll. |
| `IdentityChanged` | After `identify()` or `logOut()`. |
| `VirtualCurrenciesChanged` | After a purchase that grants virtual currency or a server-side spend/grant. |
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
import { useEffect } from 'react';
function useRovenueChanges() {
useEffect(() => {
const unsubscribe = Rovenue.addChangeListener(event => {
if (event === 'EntitlementsChanged') {
console.log('Entitlements updated');
}
if (event === 'VirtualCurrenciesChanged') {
console.log('New balances:', Rovenue.virtualCurrencies());
}
});
return unsubscribe; // cleans up on unmount
}, []);
}
```
In practice, using `useEntitlement()` and `useVirtualCurrencies()` hooks is simpler — they subscribe to the change stream internally and re-render automatically.
Swift exposes `ChangeEvent` as a typed enum and the stream as `AsyncStream`:
```swift
import Rovenue
Task {
for await event in Rovenue.shared.changes {
switch event {
case .entitlementsChanged:
// read new entitlements
case .virtualCurrenciesChanged:
let balances = await Rovenue.shared.virtualCurrencyBalances()
print("New balances:", balances)
case .identityChanged:
let user = await Rovenue.shared.currentUser()
print("Now identified as:", user.appUserId ?? "anonymous")
}
}
}
```
Kotlin exposes `ChangeEvent` as a typed enum and the stream as `SharedFlow`:
```kotlin
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.ChangeEvent
viewModelScope.launch {
Rovenue.shared.changes.collect { event ->
when (event) {
ChangeEvent.ENTITLEMENTS_CHANGED -> {
// read new entitlements
}
ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> {
val balances = Rovenue.shared.virtualCurrencyBalances()
println("New balances: $balances")
}
ChangeEvent.IDENTITY_CHANGED -> {
val user = Rovenue.shared.currentUser()
println("Now identified as: ${user.appUserId ?: "anonymous"}")
}
}
}
}
```
See [Reacting to changes](/docs/guides/reacting-to-changes) for patterns including Combine publishers, SwiftUI `.task` modifiers, and Compose `collectAsState`.
***
## `logOut()` — the shared-device contract [#logout--the-shared-device-contract]
When a user signs out, call `logOut()`. This:
1. Mints a fresh `rovenueId` (the old one is preserved on the server, attached to the `appUserId`).
2. Clears `appUserId` from local state.
3. Clears all scope-bound caches (entitlements, virtual currencies, offerings).
4. Emits `IdentityChanged`.
The next user who opens the app starts in a clean anonymous session. Their subsequent `identify()` call attaches them to their own subscriber record.
Skipping `logOut()` on a shared device means the next user sees the previous user's entitlements until the background poll overwrites the cache — up to 30 seconds. Always call it on sign-out.
Method reference: [`logOut`](/docs/reference/methods#logout).
***
**Further reading:** [Quick Start](/docs/getting-started/quickstart) for runnable end-to-end code. [Identifying users](/docs/guides/identifying-users) for the full `identify()` / `logOut()` flow. [Entitlements](/docs/guides/entitlements) for the subscription state machine. [Reacting to changes](/docs/guides/reacting-to-changes) for reactive UI patterns. [Methods](/docs/reference/methods) and [Types](/docs/reference/types) for the full API reference.
# Installation (/docs/getting-started/installation)
The Rovenue SDK shares a single Rust core (`librovenue`) across all three platforms. Native billing — StoreKit 2 on iOS, Play Billing 6 on Android — is handled by the platform-specific façade. Pick your platform path and follow the steps below.
### Types on this page [#types-on-this-page]
```ts
type RovenueConfig = {
apiKey: string; // public SDK key — prefix rov_pub_
baseUrl?: string; // API endpoint — defaults to https://api.rovenue.io
debug?: boolean; // verbose log output; default false
appVersion?: string; // override version reported to the server
};
```
| Field | Type | Description |
| ------------ | ---------- | ---------------------------------------------------------------------------------------------- |
| `apiKey` | `string` | Public SDK key from the Rovenue dashboard. Prefix `rov_pub_`. |
| `baseUrl` | `string?` | Optional. API endpoint; defaults to `https://api.rovenue.io`. Override only when self-hosting. |
| `debug` | `boolean?` | Enables verbose SDK logging. Keep `false` in production. |
| `appVersion` | `string?` | App version string reported to the server. Defaults to the OS-reported value. |
> Swift and Kotlin mirror this with platform-idiomatic parameters. See [Types](/docs/reference/types#rovenueconfig).
The `rov_pub_` key is safe to ship in client code — it can only read public project data. Never embed your **secret** API key (`rov_sec_...`) in a mobile binary.
***
### Add the package [#add-the-package]
Install with your package manager (works with bare React Native and Expo). Your choice is remembered across the whole site:
npm
pnpm
yarn
bun
```bash
npm install @rovenue/react-native-sdk
```
```bash
pnpm add @rovenue/react-native-sdk
```
```bash
yarn add @rovenue/react-native-sdk
```
```bash
bun add @rovenue/react-native-sdk
```
**Peer dependencies:** React Native ≥ 0.76, React ≥ 18.3, Expo ≥ 52 — in an Expo app, the React Native version your Expo SDK pins (`npx expo install --check`). The Nitro module bundles the prebuilt `librovenue` dylib — no separate native install is needed.
In Xcode: **File → Add Package Dependencies** and enter:
```
https://github.com/broverse/rovenue-ios
```
Or add to your `Package.swift`:
```swift
dependencies: [
.package(
url: "https://github.com/broverse/rovenue-ios",
from: "0.16.0"
)
],
targets: [
.target(
name: "YourTarget",
dependencies: ["Rovenue"]
)
]
```
Supported platforms: iOS 16.0+ (device and simulator) and macOS 12.0+.
Add to your `Podfile`:
```ruby
pod 'Rovenue'
```
Then run:
```sh
pod install
```
In `build.gradle.kts` (module level):
```kotlin
dependencies {
implementation("io.rovenue:sdk:0.16.0")
}
```
Make sure `mavenCentral()` is in your `settings.gradle.kts` repository list.
### Wire up native entry points [#wire-up-native-entry-points]
**Expo (recommended):** Add the config plugin so `expo prebuild` auto-wires the native layer:
```json title="app.json"
{
"expo": {
"plugins": ["@rovenue/react-native-sdk/app.plugin"]
}
}
```
Then run prebuild:
```sh
npx expo prebuild
```
**Bare React Native:** Link native modules (React Native ≥ 0.76 auto-links via the Nitro module spec — no manual `react-native link` needed). For Android, ensure `minSdkVersion` is 24 or higher in `android/build.gradle`; on iOS the deployment target must be 16.0 or higher.
No additional wiring is needed beyond adding the package. The SDK hooks into StoreKit 2 automatically at runtime.
If you are using **UIKit** (non-SwiftUI), ensure your `AppDelegate` does not override `application(_:open:options:)` in a way that swallows deep links — the SDK uses SKPaymentQueue for fallback receipt handling on older OS versions.
The SDK requires the `BILLING` permission. Confirm your `AndroidManifest.xml` includes it (the AAR ships with a merged manifest, so it is usually added automatically):
```xml title="AndroidManifest.xml"
```
Ensure your app targets **minSdk 24** or higher. Initialize the SDK from your `Application` subclass, not from an `Activity`, to avoid lifecycle issues (see Step 3).
### Call `configure()` once at startup [#call-configure-once-at-startup]
`configure()` must be called before any other SDK method. It initialises the Rust core, opens the SQLite cache, and starts the background poller.
Call it at the top of your app entry file, before the component tree renders:
```ts title="index.ts"
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.configure({
apiKey: 'rov_pub_your_key_here',
});
```
With Expo Router, put this in `app/_layout.tsx` before the `` render, or in a dedicated `app/init.ts` that is imported first.
Call from `application(_:didFinishLaunchingWithOptions:)` (UIKit) or the `@main` struct `init()` (SwiftUI):
```swift title="AppDelegate.swift"
import Rovenue
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Rovenue.configure(
apiKey: "rov_pub_your_key_here"
)
return true
}
}
```
SwiftUI `@main`:
```swift title="YourApp.swift"
import SwiftUI
import Rovenue
@main
struct YourApp: App {
init() {
Rovenue.configure(
apiKey: "rov_pub_your_key_here"
)
}
var body: some Scene {
WindowGroup { ContentView() }
}
}
```
Call from your `Application.onCreate()`:
```kotlin title="App.kt"
import android.app.Application
import dev.rovenue.sdk.Rovenue
class App : Application() {
override fun onCreate() {
super.onCreate()
Rovenue.configure(
context = this,
apiKey = "rov_pub_your_key_here"
)
}
}
```
Register `App` in `AndroidManifest.xml`:
```xml title="AndroidManifest.xml"
```
**Self-hosting?** Pass `baseUrl` to point the SDK at your own deployment, e.g. RN `Rovenue.configure({ apiKey: 'rov_pub_...', baseUrl: 'https://api.acme.com' })`, Swift `Rovenue.configure(apiKey: "rov_pub_...", baseUrl: "https://api.acme.com")`, Kotlin `Rovenue.configure(apiKey = "rov_pub_...", baseUrl = "https://api.acme.com")`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
Call `configure()` once at app startup, before any other Rovenue call. Calling it again later is supported on every platform — it replaces the shared instance rather than throwing — so if you need to change the key (e.g. in a multi-tenant app), you can call `configure()` again directly; `shutdown()` first is not required. What is a real hazard is the *other* ordering mistake: calling any method other than `configure()` before the first `configure()` call. That's a programming error to fix at the call site — see [Calling the SDK before configure()](/docs/reference/errors#calling-the-sdk-before-configure) for what actually happens per platform (a hard crash on iOS, not a catchable error).
### Verify the installation [#verify-the-installation]
After `configure()`, call `getVersion()` to confirm the SDK is wired correctly. The version string is also logged at `info` level on startup.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
console.log('Rovenue SDK', Rovenue.getVersion());
// → "Rovenue SDK 0.2.0"
```
```swift
import Rovenue
print("Rovenue SDK", Rovenue.shared.getVersion())
// → "Rovenue SDK 0.6.0"
```
```kotlin
import dev.rovenue.sdk.Rovenue
println("Rovenue SDK ${Rovenue.shared.getVersion()}")
// → "Rovenue SDK 0.6.0"
```
If `configure()` was skipped or ran after this code, this step will not fail gracefully: on iOS it crashes outright (the console/crash log shows `"Rovenue: must call Rovenue.configure(apiKey:baseUrl:) before accessing shared"` just before the process terminates), and on Android it throws an uncaught `IllegalStateException` carrying the same intent, worded for Kotlin's call syntax (`"Rovenue: must call Rovenue.configure(apiKey, baseUrl) before accessing shared"`). Neither is a catchable "NotConfigured" error — see [Calling the SDK before configure()](/docs/reference/errors#calling-the-sdk-before-configure). If you hit either, double-check `configure()` actually ran first — see [App lifecycle](/docs/guides/app-lifecycle) for the correct startup order.
**Next step:** Continue to the [Quick Start](/docs/getting-started/quickstart) to check your first entitlement and make a test purchase. For platform-specific details — Expo plugin options, AppDelegate wiring, Gradle flavours — see the per-platform guides: [React Native](/docs/platforms/react-native), [iOS Swift](/docs/platforms/ios-swift), [Android Kotlin](/docs/platforms/android-kotlin).
# Quick Start (/docs/getting-started/quickstart)
This page walks through the four actions every Rovenue integration needs. Each step is self-contained — you can copy it straight into your project.
If you have not installed the SDK yet, start with [Installation](/docs/getting-started/installation).
***
### Types on this page [#types-on-this-page]
```ts
type RovenueConfig = {
apiKey: string; // public SDK key — prefix rov_pub_
baseUrl?: string; // API endpoint — defaults to https://api.rovenue.io
logLevel?: 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
appVersion?: string;
};
type User = {
rovenueId: string; // permanent per-install id, prefix rov_
appUserId: string | null; // your backend user id; null until identify()
};
type Entitlement = {
id: string; // entitlement id as configured in the dashboard
isActive: boolean; // true = access is currently granted
productIdentifier: string; // the store SKU that unlocked it
store: string; // "app_store" | "play_store" | "stripe"
expiresIso?: string; // ISO-8601 expiry; undefined for lifetime products
};
type Offerings = {
current: Offering | null; // the default offering for this user
all: Record; // all offerings keyed by identifier
};
type Offering = {
identifier: string;
isDefault: boolean;
packages: Package[];
};
type Package = {
identifier: string; // e.g. "$rc_monthly", "$rc_annual"
product: StoreProduct;
};
type StoreProduct = {
id: string;
type: 'subscription' | 'consumable' | 'non_consumable';
displayName: string;
priceString: string | null; // localised price string, e.g. "$4.99"
price: number | null;
currencyCode: string | null;
};
type PurchaseResult = {
entitlements: Entitlement[]; // all active entitlements after the purchase
virtualCurrencies: Record; // virtual-currency balances after the purchase
productId: string;
storeTransactionId: string;
};
```
| Type | Description |
| -------------------------------------------------------- | ----------------------------------------------------------------------------- |
| [`RovenueConfig`](/docs/reference/types#rovenueconfig) | Passed to `configure()` once at startup. |
| [`User`](/docs/reference/types#user) | The two IDs the SDK manages — permanent `rovenueId` and optional `appUserId`. |
| [`Entitlement`](/docs/reference/types#entitlement) | A single access grant. Check `isActive` at the gate. |
| [`Offerings`](/docs/reference/types#offerings) | The product catalogue fetched from the store, structured by offering. |
| [`PurchaseResult`](/docs/reference/types#purchaseresult) | Returned by `purchase()` after server-side validation succeeds. |
> Swift and Kotlin expose the same fields with platform-idiomatic names. See [Types](/docs/reference/types).
***
### Configure once at startup [#configure-once-at-startup]
Call `configure()` before any other SDK method — in your app entry point, before the UI renders. Pass your public API key. `baseUrl` is optional and defaults to the hosted endpoint `https://api.rovenue.io`.
```ts title="index.ts"
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.configure({
apiKey: 'rov_pub_your_key_here',
});
```
For Expo Router, put this in `app/_layout.tsx` before `` renders.
```swift title="AppDelegate.swift"
import Rovenue
Rovenue.configure(
apiKey: "rov_pub_your_key_here"
)
```
Call from `application(_:didFinishLaunchingWithOptions:)` (UIKit) or your `@main` `init()` (SwiftUI). See [App lifecycle](/docs/guides/app-lifecycle).
```kotlin title="App.kt"
import dev.rovenue.sdk.Rovenue
Rovenue.configure(
context = this,
apiKey = "rov_pub_your_key_here"
)
```
Call from `Application.onCreate()`. See [App lifecycle](/docs/guides/app-lifecycle).
**Self-hosting?** Pass `baseUrl` to point the SDK at your own deployment, e.g. RN `Rovenue.configure({ apiKey: 'rov_pub_...', baseUrl: 'https://api.acme.com' })`, Swift `Rovenue.configure(apiKey: "rov_pub_...", baseUrl: "https://api.acme.com")`, Kotlin `Rovenue.configure(apiKey = "rov_pub_...", baseUrl = "https://api.acme.com")`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
Call `configure()` once at app startup, before any other Rovenue call. Calling it again later is supported — it replaces the shared instance — but calling any *other* method before the first `configure()` has run is a programming error to fix at the call site, not something to catch: on iOS it's a hard crash, and on Android it throws a plain (non-`RovenueException`) `IllegalStateException`. See [Calling the SDK before configure()](/docs/reference/errors#calling-the-sdk-before-configure) for the full per-platform breakdown.
See [Configuring](/docs/guides/configuring) for all `RovenueConfig` options, including `logLevel`, `appVersion`, and self-hosted `baseUrl` values.
### Check an entitlement [#check-an-entitlement]
All entitlement reads are **cache-first** — they return instantly from the on-device SQLite store and never block on the network. The SDK keeps the cache warm in the background while the app is foregrounded.
Use the reactive hook in any component. It re-renders automatically whenever the entitlement changes:
```tsx title="ProBadge.tsx"
import { useEntitlement } from '@rovenue/react-native-sdk';
export function ProBadge() {
const pro = useEntitlement('pro'); // Entitlement | null
if (!pro?.isActive) return null;
return Pro;
}
```
Or read imperatively (outside a component):
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const pro = Rovenue.entitlement('pro'); // Entitlement | null
if (pro?.isActive) {
// grant access
}
```
```swift
import Rovenue
// Async read — resolves from cache, never suspends on network
let pro = await Rovenue.shared.entitlement("pro")
if pro?.isActive == true {
// grant access
}
```
In SwiftUI you can observe entitlements via the change stream. See [Reacting to changes](/docs/guides/reacting-to-changes).
```kotlin
import dev.rovenue.sdk.Rovenue
val pro = Rovenue.shared.entitlement("pro")
if (pro?.isActive == true) {
// grant access
}
```
Collect the `SharedFlow` for reactive UI updates. See [Reacting to changes](/docs/guides/reacting-to-changes).
Always check the `isActive` boolean — not `expiresIso`. An entitlement with a future expiry where `isActive` is `false` is in grace-period or revoked state. See [Entitlements](/docs/guides/entitlements) for the full state model.
The `Entitlement` type and its fields are documented at [`/docs/reference/types#entitlement`](/docs/reference/types#entitlement). The method reference is at [`/docs/reference/methods#entitlement`](/docs/reference/methods#entitlement).
### Fetch offerings and make a purchase [#fetch-offerings-and-make-a-purchase]
`getOfferings()` returns the product catalogue configured in your dashboard, with localised pricing from the store. `purchase(pkg)` drives the native purchase sheet (StoreKit 2 on iOS, Play Billing 6 on Android) and validates the receipt server-side — all in one call.
`purchase()` resolves only after server-side validation succeeds. The returned `PurchaseResult` contains up-to-date `entitlements` and `virtualCurrencies` — no polling needed.
```ts title="PaywallScreen.tsx"
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
async function handlePurchase() {
// 1. Fetch the current offering (cached; network-refreshed in background)
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
if (!pkg) return; // no products configured
try {
// 2. Launch native purchase sheet; resolves after server validation
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) {
// Ask-to-Buy / deferred payment — not an error; inform the user to wait
return;
}
console.log('Entitlements:', result.entitlements);
console.log('Virtual currencies:', result.virtualCurrencies);
} catch (e) {
if (e instanceof RovenueError && e.kind === 'PurchaseCanceled') {
// User dismissed the sheet — not an error; do nothing
} else {
throw e;
}
}
}
// Restore purchases (call on a "Restore" button)
await Rovenue.restorePurchases();
```
```swift title="PurchaseView.swift"
import Rovenue
func handlePurchase() async {
// 1. Fetch the current offering
guard let offerings = try? await Rovenue.shared.getOfferings(),
let pkg = offerings.current?.packages.first else { return }
do {
// 2. Launch native purchase sheet; resolves after server validation
let result = try await Rovenue.shared.purchase(pkg)
if result.isDeferred {
// Ask-to-Buy / deferred — not an error; inform user
return
}
print("Entitlements:", result.entitlements)
print("Virtual currencies:", result.virtualCurrencies)
} catch let e as RovenueError {
switch e.kind {
case .purchaseCanceled:
break // User dismissed — not an error
default:
throw e
}
}
}
// Restore purchases
try? await Rovenue.shared.restorePurchases()
```
```kotlin title="PurchaseViewModel.kt"
import android.app.Activity
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.ErrorKind
// `activity` is your current Activity — Play Billing requires it.
suspend fun handlePurchase(activity: Activity) {
// 1. Fetch the current offering
val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull() ?: return
try {
// 2. Launch native purchase sheet; resolves after server validation
val result = Rovenue.shared.purchase(activity, pkg)
if (result.isDeferred) {
// Deferred payment — not an error; inform user
return
}
println("Entitlements: ${result.entitlements}")
println("Virtual currencies: ${result.virtualCurrencies}")
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.PURCHASE_CANCELED -> { /* User dismissed — not an error */ }
else -> throw e
}
}
}
// Restore purchases (also needs the current Activity)
Rovenue.shared.restorePurchases(activity)
```
On purchase success, the SDK automatically refreshes entitlements and virtual-currency balances and emits a `ChangeEvent`. Any `useEntitlement()` hook or change listener updates without extra code. See [Reacting to changes](/docs/guides/reacting-to-changes) and [Processing purchases](/docs/guides/processing-purchases).
Method references: [`getOfferings`](/docs/reference/methods#getofferings), [`purchase`](/docs/reference/methods#purchase), [`restorePurchases`](/docs/reference/methods#restorepurchases). Type references: [`Offerings`](/docs/reference/types#offerings), [`Package`](/docs/reference/types#package), [`PurchaseResult`](/docs/reference/types#purchaseresult).
### Identify the user after login [#identify-the-user-after-login]
The SDK starts every install in anonymous mode with a stable `rovenueId`. Once your user logs in, call `identify(appUserId)` to bind your backend user ID to the current install. Rovenue automatically transfers any anonymous purchases and credits to the identified account.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// After your auth flow resolves and you have a stable user ID:
async function onLoginSuccess(userId: string) {
Rovenue.identify(userId); // optimistic: local state updates immediately
const user = Rovenue.currentUser();
// user.rovenueId → permanent install id (rov_...)
// user.appUserId → userId you just passed
}
// On sign-out: clear appUserId and mint a fresh rovenueId
async function onLogOut() {
Rovenue.logOut();
}
```
```swift
import Rovenue
// After login:
func onLoginSuccess(userId: String) {
Rovenue.shared.identify(appUserId: userId) // optimistic
let user = await Rovenue.shared.currentUser()
// user.rovenueId → permanent install id (rov_...)
// user.appUserId → userId
}
// On sign-out:
func onLogOut() {
Rovenue.shared.logOut()
}
```
```kotlin
import dev.rovenue.sdk.Rovenue
// After login:
fun onLoginSuccess(userId: String) {
Rovenue.shared.identify(appUserId = userId) // optimistic
val user = Rovenue.shared.currentUser()
// user.rovenueId → permanent install id (rov_...)
// user.appUserId → userId
}
// On sign-out:
fun onLogOut() {
Rovenue.shared.logOut()
}
```
`identify()` is **not** how the user logs into your app — it is how you bind your backend's user ID to the Rovenue install. Call it only after your own auth flow completes and you have a stable, permanent user ID. Passing a temporary or session-scoped ID creates a new subscriber record per login and cannot be undone. See [Identifying users](/docs/guides/identifying-users) and [Identity & consent](/docs/guides/identity-consent) for guidance.
Method references: [`identify`](/docs/reference/methods#identity), [`logOut`](/docs/reference/methods#logout), [`currentUser`](/docs/reference/methods#currentuser). Type reference: [`User`](/docs/reference/types#user).
**You're set up.** For a deeper understanding of how the two user IDs relate, how the cache-first model works, and what the `ChangeEvent` stream does, read [Core Concepts](/docs/getting-started/core-concepts). For a full API reference, see [Methods](/docs/reference/methods) and [Types](/docs/reference/types).
# App Lifecycle (/docs/guides/app-lifecycle)
The Rovenue SDK drives its polling and background work from two lifecycle signals you provide: `setForeground(bool)` and `shutdown()`. Without them, the SDK cannot know when to refresh entitlements, flush queued events, or stop background work gracefully.
Session events (app open, background, close) are recorded automatically once you wire up `setForeground` — you do not need to emit them manually.
### Types on this page [#types-on-this-page]
These are void methods with no return value. Full definitions: [Methods reference → Lifecycle](/docs/reference/methods#lifecycle).
| Method | Signature (RN) | Effect |
| --------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `setForeground` | `setForeground(active: boolean)` | `true` starts periodic entitlement and credit refreshes; `false` pauses polling to save battery and network |
| `shutdown` | `shutdown()` | Stops **all** background work — polling, outbox flushing, and the change stream. Drains queued events synchronously before returning. |
***
### Wire foreground/background signals [#wire-foregroundbackground-signals]
Call `setForeground(true)` when the app becomes active and `setForeground(false)` when it goes to the background. The SDK uses this to start and pause its internal polling loop.
```ts
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { Rovenue } from '@rovenue/react-native-sdk';
useEffect(() => {
// Signal foreground immediately on mount
Rovenue.setForeground(true);
// Keep the SDK in sync as the app moves between states
const sub = AppState.addEventListener('change', (state) => {
Rovenue.setForeground(state === 'active');
});
return () => {
sub.remove();
Rovenue.shutdown();
};
}, []);
```
Place this effect at the root of your app (e.g. in `App.tsx`) so it runs for the full app lifetime.
```swift
// AppDelegate.swift
import Rovenue
func applicationDidBecomeActive(_ application: UIApplication) {
Rovenue.shared.setForeground(true)
}
func applicationDidEnterBackground(_ application: UIApplication) {
Rovenue.shared.setForeground(false)
}
func applicationWillTerminate(_ application: UIApplication) {
Rovenue.shared.shutdown()
}
```
**SceneDelegate / SwiftUI:** wire the same calls to `sceneDidBecomeActive`, `sceneDidEnterBackground`, and `sceneWillDeactivate`, or use the `.scenePhase` environment value:
```swift
// SwiftUI — in App.swift
import SwiftUI
import Rovenue
@main
struct MyApp: App {
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: phase) { newPhase in
Rovenue.shared.setForeground(newPhase == .active)
if newPhase == .background {
// setForeground(false) already called above; shutdown on terminate only
}
}
}
}
```
```kotlin
// Application.kt — use ProcessLifecycleOwner for app-wide signals
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import dev.rovenue.sdk.Rovenue
class App : Application() {
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
// App moved to foreground
Rovenue.shared.setForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
// App moved to background
Rovenue.shared.setForeground(false)
}
}
)
}
override fun onTerminate() {
Rovenue.shared.shutdown()
super.onTerminate()
}
}
```
Register `App` in `AndroidManifest.xml`:
```xml
```
### Call shutdown on app termination [#call-shutdown-on-app-termination]
`shutdown()` stops all background work and drains any queued outbox events synchronously. Call it only when the process is ending — not on every background transition.
```ts
// Already handled in the useEffect cleanup above.
// If you need an explicit teardown (e.g. on logout):
await Rovenue.shutdown();
```
```swift
// AppDelegate.swift — called by the OS before the process exits
func applicationWillTerminate(_ application: UIApplication) {
// Give the SDK time to drain queued events
let task = UIApplication.shared.beginBackgroundTask(withName: "RovenueDrain")
Rovenue.shared.shutdown()
UIApplication.shared.endBackgroundTask(task)
}
```
```kotlin
// Application.onTerminate is only called in emulators.
// For production, tie shutdown to ProcessLifecycleOwner.onDestroy or
// handle it in your Activity's onDestroy if you have a single-activity app:
override fun onDestroy() {
Rovenue.shared.shutdown()
super.onDestroy()
}
```
`shutdown()` drains any queued outbox events synchronously before returning. On iOS, requesting a short background-task window (via `beginBackgroundTask`) gives the SDK extra time to flush events that were queued just before termination, improving delivery reliability.
Calling `shutdown()` also cancels the SDK's internal coroutine scope (Kotlin) and Task (Swift). Any change-stream collectors running inside those scopes will stop. See [Reacting to changes](/docs/guides/reacting-to-changes) for correct collector wiring.
# Audit Proof Bundle (/docs/guides/audit-proof)
Every dashboard mutation writes an append-only audit log row. Each row's `rowHash` is a SHA-256 hash over that row's fields plus the previous row's hash, forming a per-project chain — the same construction as a blockchain's block-hash link, without the distributed-consensus part. This guide describes the exported proof bundle, the exact hashing rule a verifier must reproduce, and how to run the standalone verifier script.
The chain detects **alteration of recorded rows**. It does not prove that everything which happened was recorded. If a row was never written, there is no gap for the chain to reveal.
## What the chain does and does not guarantee [#what-the-chain-does-and-does-not-guarantee]
* It guarantees that once a row is written, its fields and its position in the sequence cannot be silently changed without breaking the hash link to every row after it.
* It does **not** guarantee completeness. A missing write (a bug, a rollback, a code path that never called the audit writer) leaves no trace — there is nothing for the recomputed hashes to disagree with.
* It does **not** cover rows written before the chain existed. See [Unhashed rows](#unhashed-rows) below.
## Adversary model — what a single bundle actually proves [#adversary-model--what-a-single-bundle-actually-proves]
Verifying one bundle in isolation proves the bundle is **internally self-consistent** — its hashes and links agree with each other and with its own declared `tip`. It does not, by itself, prove that history wasn't rewritten, because the same database that stores the rows also computes every hash and the `tip` that the bundle asks you to trust.
The audit chain lives in the same Postgres database as the rows it protects. An actor with write access to `audit_logs` — a compromised operator, a malicious self-host admin, anyone who can run SQL against that table — can alter a row and then recompute every later `rowHash`, relink every later `prevHash`, and recompute the exported `tip` to match. The resulting bundle verifies `ok: true`: nothing in `verify-audit-bundle.ts` can distinguish a rewritten-and-recomputed chain from a genuine one, because both are internally consistent by construction.
This matters most for tail deletion. `TIP_MISMATCH` — the one check in the table below that can catch newest-rows-deleted — works only because it compares `entries` against the bundle's own `tip` field. That `tip` is computed and served by the same route (`audit-logs.ts`) that assembled `entries`. Against a server that is itself the adversary, `tip` is not an independent witness; it's the adversary's own claim about where its own chain ends.
The real defense is comparing against something the server operator cannot alter after the fact: retain a bundle's `tip.rowHash` (or the whole bundle) outside the system — a separate machine, cold storage, a signed log shipped to a third party, whatever your threat model calls for — every time you export. On your next export, walk the new bundle's chain and confirm the previously-retained `rowHash` still appears, at the same position, with the same neighbours. A retained tip that has silently vanished, or now sits with different links, is evidence of a rewrite that no single bundle's internal check can show. That is a real and useful property of this format — an auditor who periodically retains tips gets exactly the tamper-evidence they're after — but it depends on doing the retaining; the format does not do it for you.
## Obtaining a bundle [#obtaining-a-bundle]
```
GET /dashboard/audit-logs/proof?projectId=&from=&to=
```
`from` and `to` are optional and filter by `createdAt`; both accept an offset (e.g. `2026-09-01T00:00:00+03:00`). The route requires an authenticated dashboard session with access to the project, same as the rest of the audit log viewer.
The export is capped at 5000 entries per request. A bundle that hit the cap sets `"truncated": true` — see [Truncation](#truncation) below for what that does and does not mean.
Like every Rovenue API response, the HTTP body is `{ "data": }` — the bundle (`formatVersion`, `entries`, and everything else on this page) is the *value* of `data`, not the top-level response. Strip the envelope before handing the file to the verifier:
```bash
curl -s --cookie "" \
"https:///dashboard/audit-logs/proof?projectId=" \
| jq '.data' > bundle.json
pnpm --filter @rovenue/scripts verify:audit-bundle bundle.json
```
Running the verifier against the raw response with the envelope still attached fails with `UNSUPPORTED_FORMAT_VERSION`: the verifier reads the bundle's top-level `formatVersion`, which is `undefined` on `{ "data": { "formatVersion": ... } }` — not a sign the export is broken, just an un-stripped wrapper.
## Bundle shape [#bundle-shape]
The shape below — and the fixture excerpt after it — is the value of `data`, already stripped of the response envelope described above.
```json
{
"formatVersion": "rovenue.audit-chain.v1",
"projectId": "prj_demo1",
"exportedAt": "2026-09-05T12:00:00.000Z",
"range": { "from": null, "to": null },
"origin": null,
"tip": {
"rowHash": "5b8baf33b9d5508ea35f85d6bfa9f67f9bbb595e1fc0a11bfda55551c98340a2",
"createdAt": "2026-09-03T00:00:00.000Z"
},
"truncated": false,
"entries": [
{
"id": "aud_demo1",
"projectId": "prj_demo1",
"userId": "usr_demo1",
"action": "project.create",
"resource": "project",
"resourceId": "prj_demo1",
"before": null,
"after": { "name": "Demo Project" },
"ipAddress": "203.0.113.7",
"userAgent": "rovenue-dashboard/1.0",
"createdAt": "2026-09-01T00:00:00.000Z",
"prevHash": null,
"rowHash": "cb6cba48a098b0209efb2a5bcd49d0b2246f3526ec10ce087f9822add8f63239"
}
]
}
```
This is an excerpt of the real fixture committed at `packages/shared/src/audit-proof-bundle-fixture.json` — it is the fixture the verifier's own tests run against, not a hand-written example.
| Field | Meaning |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `formatVersion` | Names the canonical encoding used to produce every `rowHash` in this bundle. Currently always `"rovenue.audit-chain.v1"`. A verifier that does not implement a given version must refuse the bundle rather than guess at its encoding. |
| `projectId` | The project this chain belongs to. Chains are per-project — there is no cross-project ordering. |
| `exportedAt` | When the export ran. Not part of any hash; purely informational. |
| `range` | `{ from, to }` — the `from`/`to` query parameters this export actually used, as ISO-8601 strings, or `null` for whichever side wasn't given. Purely informational, like `exportedAt`: it tells a reader what was **asked for**, so a non-null `origin` (below) with a non-null `range.from` reads as "this is a deliberately ranged export," while a non-null `origin` with `range.from: null` is unexplained. It does not make that distinction *trustworthy* — see [Adversary model](#adversary-model--what-a-single-bundle-actually-proves): the same server supplies `range`, `origin`, and `entries`, so a server willing to lie about one can lie about all three consistently. |
| `origin` | `{ rowHash }` naming the hash the exported bundle's first entry's own `prevHash` field carries, or `null` if that first entry's `prevHash` is itself `null`. **This is not independent evidence that a predecessor row exists.** The server derives `origin` entirely from `entries[0].prevHash` — it never reads the row `origin.rowHash` supposedly names, so `origin` is consistent with `entries` by construction, whether the export is a legitimate `from`-filtered slice or a chain whose head rows were deleted outright. See [Adversary model](#adversary-model--what-a-single-bundle-actually-proves) for what that limitation means for verification, and `range` above for the one piece of context that at least distinguishes "a `from` filter was requested" from "no filter was requested." |
| `tip` | `{ rowHash, createdAt }` of the last entry in `entries`, or `null` if `entries` is empty. `rowHash` here is nullable too — it carries a legacy, never-hashed row's `null` through unchanged if that row happens to be the last one exported. Lets a verifier confirm the bundle wasn't silently trimmed from the end (see [What a broken chain looks like](#what-a-broken-chain-looks-like)) — but only as far as the [adversary model](#adversary-model--what-a-single-bundle-actually-proves) above allows. |
| `truncated` | `true` when the read hit the 5000-entry cap. See [Truncation](#truncation). |
| `entries` | The rows themselves, oldest first, each carrying every field the row hash covers, plus `id`, `prevHash`, and `rowHash`. |
### Entry fields [#entry-fields]
Each entry contains `id` and `rowHash` alongside exactly the fields the hash is computed over: `projectId`, `userId`, `action`, `resource`, `resourceId`, `before`, `after`, `ipAddress`, `userAgent`, `createdAt`, `prevHash`.
`id` is **not** part of the hashed payload. It identifies the row for humans and tooling; changing it would not be detected by re-hashing. Do not treat a matching `id` as evidence of anything — only `rowHash` and the chain of `prevHash` values carry that weight.
## The canonical encoding rule [#the-canonical-encoding-rule]
A `rowHash` is `SHA-256` over a canonical JSON string built from exactly these eleven fields: `projectId`, `userId`, `action`, `resource`, `resourceId`, `before`, `after`, `ipAddress`, `userAgent`, `createdAt`, `prevHash`. The encoding rules, defined in `packages/shared/src/audit-chain.ts`, are:
* Object keys are emitted in **sorted (lexicographic) order**, recursively — not the order the field list above happens to be written in. For this fixed field set, the actual key order in the canonical string is always: `action, after, before, createdAt, ipAddress, prevHash, projectId, resource, resourceId, userAgent, userId`. Plain `JSON.stringify` does not guarantee key order across JS engines, and a compliance-grade chain must be byte-identical on re-hash — a reimplementation that emits keys in any other order will not reproduce Rovenue's hashes.
* Arrays and objects are recursed into explicitly rather than delegated to `JSON.stringify`, so the sorting rule applies at every depth of `before` / `after`.
* `null` and `undefined` both serialize to `null`.
* A non-finite number (`NaN`, `Infinity`, `-Infinity`) serializes to `null`.
* `createdAt` is always an ISO-8601 string — this module never sees a `Date` object; the caller stringifies it first.
A verifier does not need to trust anything the server says about a row: given the entry's own fields, it recomputes this canonical string, hashes it, and compares the result to the stored `rowHash`.
## The origin rule [#the-origin-rule]
The chain is a singly-linked list ordered by `createdAt`: each row's `prevHash` must equal the previous row's `rowHash`, and the very first row a project ever wrote has `prevHash: null`.
A bundle's `origin` field tells a reader whether this exported slice begins at the chain's true start:
* `origin: null` means the first entry in this bundle is the genuine start of the chain (its `prevHash` is `null`).
* `origin: { rowHash }` means the first entry's own `prevHash` is non-null — this segment does **not** begin at the chain's start. In practice that's a `from` filter excluding earlier history. (The 5000-row cap alone never produces this: entries are read oldest-first, so a truncated export still starts at the true beginning of the range — see [Truncation](#truncation).)
`origin` is **not** independent evidence that a predecessor row exists. It is computed as `{ rowHash: entries[0].prevHash }` — read straight off the very entry it is supposed to anchor — and the server never reads the row `origin.rowHash` names to confirm it is actually there. `verifyAuditBundle` checks `entries[0].prevHash === origin.rowHash`, which is tautologically true for any bundle this server assembled; it is not a check against anything outside the bundle. A chain whose earliest rows were deleted outright produces a byte-identical `origin` to a genuine `from`-filtered export of the same remaining rows. This is the same limitation the [Adversary model](#adversary-model--what-a-single-bundle-actually-proves) section describes for `tip`, for the same reason: the entity supplying the evidence and the entity being checked are the same server. `range` (above) tells you whether a `from` filter was *requested*, which at least lets an honest server's bundle explain itself — but that, too, is something only the server attests to.
## Running the verifier [#running-the-verifier]
`scripts/verify-audit-bundle.ts` is a standalone script: it imports only the canonical encoder (`@rovenue/shared/audit-chain`, which itself touches only `node:crypto`) and `node:` builtins. No code from `apps/api` or `packages/db` runs during verification. That is a *code*-trust guarantee — you are not running the server's software to check the server's claims. It is not a *data*-trust guarantee against a server that is itself the adversary; see [Adversary model](#adversary-model--what-a-single-bundle-actually-proves) above for what recomputing hashes offline does and does not rule out.
```bash
pnpm --filter @rovenue/scripts verify:audit-bundle
# or directly:
tsx scripts/verify-audit-bundle.ts
```
It exits `0` and prints `OK -- N entries verified` when the chain holds, or exits `1` and prints the first failure it hit, with a reason and (for entry-level failures) the offending entry's index and `id`.
### Failure reasons [#failure-reasons]
| Reason | What it means |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MALFORMED_BUNDLE` | The input isn't a JSON object, `entries` isn't an array, an element of `entries` isn't itself an object, or `origin`/`tip` has the wrong shape (e.g. an `origin` object with no string `rowHash`). Deleting the `entries` array entirely — the simplest possible tamper — lands here, not as "zero entries, nothing to check." |
| `UNSUPPORTED_FORMAT_VERSION` | `formatVersion` isn't a value this verifier implements. It refuses rather than guessing at an encoding it doesn't know. |
| `UNHASHED_ROW` | An entry's `rowHash` is missing, not a string, or an empty string. See [Unhashed rows](#unhashed-rows) — this is a finding about the data, not a bug in the verifier. |
| `PREV_HASH_MISMATCH` | An entry's `prevHash` doesn't match the previous entry's `rowHash` (or `origin.rowHash` / `null` for the first entry). The link between two specific rows is broken. |
| `ROW_HASH_MISMATCH` | An entry's own `rowHash` doesn't reproduce from recomputing the canonical encoding over its fields — the row's content was altered after it was hashed. |
| `TIP_MISMATCH` | `tip` disagrees with the last entry actually present in either `rowHash` or `createdAt`, including a bundle that has entries but no `tip`, or a `tip` but no entries. This is the only check that can catch a deleted **tail**: the `prevHash` walk only ever looks backward, so removing a chain's newest rows leaves every remaining link internally consistent. |
## Unhashed rows [#unhashed-rows]
`rowHash` can be `null` on an entry. Audit logging predates the hash chain in this codebase; rows written before the chain existed were never hashed and have no `rowHash` or `prevHash` to check. The export deliberately **includes** these rows rather than hiding them, and the verifier fails a bundle containing one with `UNHASHED_ROW`.
This is not a bug report against the tool — it is the tool correctly telling you that a portion of this project's history predates cryptographic verification and cannot be attested to. Rows before the gap and rows after it verify independently; the gap itself is the thing being reported.
## Truncation [#truncation]
`truncated: true` means the export hit the 5000-entry cap, not that anything is wrong. A truncated bundle can still verify `ok: true` — that means the rows actually exported are internally consistent, **not** that they are the project's complete history. Narrow the export with `from` / `to` to confirm a specific window is captured in full.
The cap exists so exporting a busy project's entire history can't be used as a denial-of-service vector. Rows are read oldest-first (`ORDER BY createdAt ASC, id ASC`) and the read simply stops at the limit, so a truncated export always starts at the true beginning of the requested range and drops the **newest** rows in that range — not the earliest. (That is also why the cap alone never produces a non-null `origin`; see [The origin rule](#the-origin-rule) above.) Because a bundle sitting at exactly the cap is otherwise byte-indistinguishable from a complete export, `truncated` is derived server-side from whether the read returned exactly `AUDIT_PROOF_MAX_ENTRIES` (5000) rows — it is never something a caller can influence — and the verifier surfaces it unconditionally in its result (`entriesChecked` and `truncated` accompany every outcome, success or failure) so a programmatic consumer can never read one field without the other.
## What a broken chain looks like [#what-a-broken-chain-looks-like]
* Editing any field of a stored entry (e.g. `after.priceUsd`) changes what the canonical encoding produces, so the entry's own `ROW_HASH_MISMATCH` fires immediately on that row.
* Deleting a row in the middle of the chain breaks the link between its neighbours: the next surviving row's `prevHash` no longer matches its new predecessor's `rowHash`, surfacing as `PREV_HASH_MISMATCH`.
* Deleting the newest row(s) does **not** break any `prevHash` link — the remaining chain is internally consistent end to end. It is caught only by `TIP_MISMATCH`, because `tip` (recorded by the server at export time) no longer names the last entry actually present in `entries`.
# Configuring the SDK (/docs/guides/configuring)
`configure` is the single entry point for the SDK. Call it once at app startup,
before any other Rovenue method. Every other method is invalid until
`configure` has run.
Calling any other SDK method before `configure` has run is a programming error,
not a runtime condition to catch — on iOS it's a hard crash (not a throwable
error), and on Android it throws a plain, non-`RovenueException`
`IllegalStateException`. See [Calling the SDK before
configure()](/docs/reference/errors#calling-the-sdk-before-configure) for the
full per-platform breakdown. Put `configure` at the earliest possible entry
point — `App.tsx` on React Native, `AppDelegate.application(_:didFinishLaunchingWithOptions:)`
on iOS, or `Application.onCreate()` on Android — to make this impossible to get wrong.
### Types on this page [#types-on-this-page]
```ts
type RovenueConfig = {
apiKey: string; // your public SDK key (prefix rov_pub_)
baseUrl?: string; // optional — defaults to https://api.rovenue.io
logLevel?: 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; // default 'warn'
appVersion?: string; // overrides the platform manifest value
};
```
| Field | Type | Required | Description |
| ------------ | ------------------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `string` | Yes | Your **public** SDK key (`rov_pub_…`). Never use the secret key in the app. |
| `baseUrl` | `string` | No | Your Rovenue API base URL. Defaults to the hosted endpoint `https://api.rovenue.io`; override only when self-hosting. |
| `logLevel` | `'off' \| 'error' \| 'warn' \| 'info' \| 'debug' \| 'trace'` | No | Verbosity of SDK logs. Defaults to `'warn'` — turn up to `'info'`/`'debug'` locally, never ship `'debug'`/`'trace'` in production. |
| `appVersion` | `string` | No | App version string. Auto-read from the platform manifest — only override for standalone JVM builds. |
> Swift and Kotlin mirror these with platform-idiomatic parameter labels. See [Types](/docs/reference/types).
***
## Setting up [#setting-up]
### Find your API key [#find-your-api-key]
Open your Rovenue dashboard, navigate to your
project, and copy the **public** SDK key. It starts with `rov_pub_`.
Never embed your **secret** key (`rov_sec_…`) in the app binary. Secret keys
are for server-to-server calls only. See [Methods — Configuration](/docs/reference/methods#configuration).
### Call `configure` at startup [#call-configure-at-startup]
Pass a `RovenueConfig` object (React Native) or named parameters (Swift /
Kotlin) at the very first line of your app's lifecycle hook.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.configure({
apiKey: 'rov_pub_...',
logLevel: 'info', // optional — turn up from the 'warn' default in dev
appVersion: '1.2', // optional — inferred from app.json by default
});
```
```swift
import Rovenue
// AppDelegate.application(_:didFinishLaunchingWithOptions:)
Rovenue.configure(
apiKey: "rov_pub_...",
logLevel: .info, // optional — turn up from the .warn default
appVersion: "1.2" // optional — inferred from CFBundleShortVersionString
)
```
```kotlin
import dev.rovenue.sdk.Rovenue
// Application.onCreate()
Rovenue.configure(
apiKey = "rov_pub_...",
logLevel = LogLevel.INFO, // optional — turn up from the LogLevel.WARN default
appVersion = "1.2", // optional — inferred from versionName
)
```
**Self-hosting?** Pass `baseUrl` to point the SDK at your own deployment, e.g. RN `Rovenue.configure({ apiKey: 'rov_pub_...', baseUrl: 'https://api.acme.com' })`, Swift `Rovenue.configure(apiKey: "rov_pub_...", baseUrl: "https://api.acme.com")`, Kotlin `Rovenue.configure(apiKey = "rov_pub_...", baseUrl = "https://api.acme.com")`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
### Verify the singleton is ready [#verify-the-singleton-is-ready]
After `configure`, all SDK methods become available. On iOS and Android,
subsequent calls go through the shared singleton (`Rovenue.shared`). On React
Native they go directly through the `Rovenue` namespace.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Smoke-check: log the SDK version
console.log('Rovenue SDK', Rovenue.getVersion());
```
```swift
// Smoke-check: log the SDK version
print("Rovenue SDK", Rovenue.shared.getVersion())
```
```kotlin
// Smoke-check: log the SDK version
println("Rovenue SDK ${Rovenue.shared.getVersion()}")
```
`configure` is a **static entry point**. On iOS and Android it initialises
the shared singleton; every method afterwards is called on that instance
(`Rovenue.shared.entitlement(…)` in Swift/Kotlin), or directly on `Rovenue`
in React Native.
### (Optional) Attach a log handler [#optional-attach-a-log-handler]
Set a custom log handler immediately after `configure` to route SDK logs into
your own logging infrastructure.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.setLogHandler((entry) => {
myLogger.log(entry.level, entry.message);
});
```
```swift
Rovenue.shared.setLogHandler { entry in
myLogger.log(level: entry.level, message: entry.message)
}
```
```kotlin
Rovenue.shared.setLogHandler { entry ->
myLogger.log(entry.level, entry.message)
}
```
***
## Platform defaults for `appVersion` [#platform-defaults-for-appversion]
| Platform | Source |
| ------------------ | ------------------------------------------ |
| iOS / Expo | `CFBundleShortVersionString` / `app.json` |
| Android | `versionName` in `build.gradle` |
| Standalone JVM SDK | No manifest — provide the value explicitly |
***
**Next steps:** After configuring the SDK, identify the logged-in user with
[`identify()`](/docs/guides/identifying-users) and check their entitlements
with [`entitlement()`](/docs/guides/entitlements). See also:
[Methods — Configuration](/docs/reference/methods#configuration).
# Virtual Currencies (/docs/guides/credits)
Virtual currencies let you define any number of named balances per subscriber — for example `COINS`, `GEMS`, or `ENERGY` — useful for AI generations, messages, or any metered feature. Balances are **server-authoritative**: the SDK caches the last-known values locally and syncs with the server automatically.
**Granting** virtual currency happens automatically when a user purchases a currency-pack product configured in the Rovenue dashboard. **Spending** (debiting) is a server-side operation — call `POST /v1/virtual-currencies/:appUserId/:code/transactions` from your backend using your secret API key. There is no client-side spend method.
### Types on this page [#types-on-this-page]
Virtual-currency balances are represented as a map of currency code to integer amount. The methods below are defined in the [Methods reference](/docs/reference/methods#virtual-currencies).
| Method / Hook | Returns | Notes |
| ------------------------------- | ------------------------ | ------------------------------------------------------- |
| `virtualCurrencies()` | `Record` | Cache read; returns `{}` if no balances are cached |
| `virtualCurrency(code)` | `number` | Cache read; returns `0` if the code is absent |
| `refreshVirtualCurrencies()` | `void` | Network call; updates the local cache |
| `useVirtualCurrencies()` (RN) | `Record` | Reactive hook; re-renders on `VirtualCurrenciesChanged` |
| `useVirtualCurrency(code)` (RN) | `number` | Reactive hook; re-renders on `VirtualCurrenciesChanged` |
Swift uses `virtualCurrencyBalances() -> [String: Int64]` and `virtualCurrency(_ code: String) -> Int64`.
Kotlin uses `virtualCurrencyBalances(): Map` and `virtualCurrency(code: String): Long`.
***
### Read balances [#read-balances]
Use the reactive hooks in React Native components, or the imperative methods elsewhere.
```tsx
import { Rovenue, useVirtualCurrencies, useVirtualCurrency } from '@rovenue/react-native-sdk';
// Reactive hook — re-renders automatically when any balance changes
function WalletSummary() {
const balances = useVirtualCurrencies(); // Record
return (
<>
{Object.entries(balances).map(([code, amount]) => (
{code}: {amount}
))}
>
);
}
// Single-currency reactive hook
function CoinsLabel() {
const coins = useVirtualCurrency('COINS'); // 0 if absent
return {coins} coins;
}
// Imperative cache reads (outside a component)
const all = Rovenue.virtualCurrencies(); // instant, no network
const coins = Rovenue.virtualCurrency('COINS'); // 0 if absent
```
```swift
// Cache read — instant, no network
let balances = await Rovenue.shared.virtualCurrencyBalances() // [String: Int64]
let coins = await Rovenue.shared.virtualCurrency("COINS") // 0 if absent
```
```kotlin
// Cache read — instant, no network
val balances = Rovenue.shared.virtualCurrencyBalances() // Map
val coins = Rovenue.shared.virtualCurrency("COINS") // 0 if absent
```
`virtualCurrencies()` and `virtualCurrency(code)` never hit the network. They are safe to call on the main thread and return instantly even in offline scenarios. Values reflect the last successful sync with the server.
### Refresh from the server [#refresh-from-the-server]
Call `refreshVirtualCurrencies()` when you need a guaranteed-fresh set of balances — for example, after a server-side spend/grant that your app triggered.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Pull latest balances from server and update the local cache
await Rovenue.refreshVirtualCurrencies();
// Now virtualCurrencies() and useVirtualCurrencies() reflect the server values
const fresh = Rovenue.virtualCurrencies();
```
```swift
// Pull latest balances from server and update the local cache
try await Rovenue.shared.refreshVirtualCurrencies()
// Now virtualCurrencyBalances() reflects the server values
let fresh = await Rovenue.shared.virtualCurrencyBalances()
```
```kotlin
// Pull latest balances from server and update the local cache
Rovenue.shared.refreshVirtualCurrencies()
// Now virtualCurrencyBalances() reflects the server values
val fresh = Rovenue.shared.virtualCurrencyBalances()
```
Balances also refresh automatically while the app is in the foreground. See [App lifecycle](/docs/guides/app-lifecycle) for the polling behaviour. Manual `refreshVirtualCurrencies()` is only needed when you require immediate consistency after a server-side operation.
### Grant virtual currency (server-side) [#grant-virtual-currency-server-side]
Virtual currency is granted when a user purchases a currency-pack product. Product-to-currency mapping is configured in the Rovenue dashboard — no client code is required.
After a successful purchase, `PurchaseResult.virtualCurrencies` already reflects the updated balances:
```ts
const result = await Rovenue.purchase(pkg);
console.log('COINS after purchase:', result.virtualCurrencies['COINS']);
```
### Spend virtual currency (server-side only) [#spend-virtual-currency-server-side-only]
Spending a virtual currency is a **server-side operation**. From your backend, call the secret-key transactions endpoint:
```http
POST /v1/virtual-currencies/:appUserId/:code/transactions
Authorization: Bearer
Content-Type: application/json
{
"amount": -5,
"idempotencyKey": "",
"description": "AI image generation"
}
```
A negative `amount` debits the balance; a positive `amount` grants currency. Always supply an `idempotencyKey` — the server rejects duplicate keys so a retry after a network drop does not double-deduct.
Never put your secret API key (`rov_sec_…`) in the app binary. The transactions endpoint is server-to-server only. Expose a lightweight endpoint in your own backend that verifies the user session and calls Rovenue on their behalf. After the spend completes, call `refreshVirtualCurrencies()` on the client (or wait for the next foreground sync) to update the local cache.
# Self-Service DSAR API (/docs/guides/dsar)
A data-subject access request (export) or right-to-erasure request (erasure) is something **your backend** asks Rovenue on behalf of one of your own end users. Rovenue never authenticates that end user itself — your backend already did that, however your own auth works, before it ever calls this API.
Every route below requires your **secret** key (`Authorization: Bearer `), never the public key. These are server-to-server calls only. A public key reaching `/v1/dsar/erasure` would let anyone holding your client bundle erase someone else's subscriber — the secret-key requirement is the whole reason this is safe to expose.
## The four endpoints [#the-four-endpoints]
| Method & path | What it does |
| --------------------------- | ------------------------------------------------------------------- |
| `POST /v1/dsar/export` | Starts (or returns the already-running) export for one subscriber. |
| `POST /v1/dsar/erasure` | Starts (or returns the already-running) erasure for one subscriber. |
| `GET /v1/dsar/:id` | Polls the status of a request you started. |
| `GET /v1/dsar/:id/download` | Streams a completed export's artifact. |
### Starting a request [#starting-a-request]
```http
POST /v1/dsar/export
Authorization: Bearer
Content-Type: application/json
{
"appUserId": "rov_01h...",
"requestedBy": "support-ticket-4821"
}
```
`POST /v1/dsar/erasure` takes the identical body shape.
`appUserId` accepts either identifier you hold for the subscriber: the **`rovenueId`** your SDK already has on hand (`user.rovenueId` from [identifying users](/docs/guides/identifying-users)), or the **app user id you passed to `identify()`** — the one your own backend knows. The rovenueId is tried first. When that app user was later merged into another subscriber (a transfer, or an `identify()` on a second device), the request lands on the surviving subscriber, which is where their data now lives. A value that was never sent to Rovenue as either identifier resolves to nothing (`404`).
`requestedBy` is a free-text string your backend supplies to record who or what asked (a ticket id, an admin's email, an internal case number). Rovenue doesn't validate it against anything — it exists purely so the audit trail says who initiated the request, not just that one happened.
The subscriber is always resolved **inside your own project** — there is no `projectId` field to fill in, and none would be trusted if you sent one. The secret key itself determines which project's subscribers you can reach.
Both calls respond with the request record:
```json
{
"data": {
"request": {
"id": "dsr_01h...",
"type": "EXPORT",
"status": "PENDING",
"requestedBy": "support-ticket-4821",
"createdAt": "2026-09-06T12:00:00.000Z",
"updatedAt": "2026-09-06T12:00:00.000Z",
"completedAt": null,
"expiresAt": null,
"error": null,
"downloadReady": false
}
}
}
```
`status` moves `PENDING → RUNNING → COMPLETED | FAILED`. The actual work runs on a background queue — this call only enqueues it and returns immediately; poll `GET /v1/dsar/:id` for the outcome.
### Retrying is safe — it never starts a second job [#retrying-is-safe--it-never-starts-a-second-job]
If your backend calls `POST /v1/dsar/export` again for the same subscriber while an export is still `PENDING` or `RUNNING`, you get back **the same request record**, not a new one — same `id`, same `status`. No second export runs, and no second job is enqueued. This holds whether the retry is deliberate (your backend re-asking) or accidental (a timeout that makes you unsure the first call landed): it is always safe to call `POST /v1/dsar/export` or `POST /v1/dsar/erasure` again for a subscriber you're not sure already has an open request.
This retry is also what makes a request recoverable if your very FIRST call returned a `5xx` instead of `200`: the request record may still have been created even though the response failed. Calling the identical endpoint again finds that record and makes sure its background job actually got scheduled — creating it if the very first attempt never reached the queue — before handing back the same record. You never need a separate "did my request actually get picked up" check; the same retry you'd make anyway does that healing for you.
Once a request reaches `COMPLETED` or `FAILED`, the next call for that subscriber and type starts a genuinely new one — export is a point-in-time snapshot, not a subscription. Asking again after a completed export runs a fresh export and produces a new artifact; it does not "refresh" the old one or extend its download window.
### Checking status and downloading [#checking-status-and-downloading]
```http
GET /v1/dsar/:id
Authorization: Bearer
```
Returns the same request shape as above. `downloadReady` is `true` only once `status` is `COMPLETED`, an artifact exists, and it hasn't expired yet — check it before calling the download route rather than parsing `status` yourself.
```http
GET /v1/dsar/:id/download
Authorization: Bearer
```
**There is no shareable download link.** This route streams the artifact directly, authenticated by your secret key on every call — there is no signed URL, no token you can hand to someone else, nothing to copy into an email. If your own product needs to hand the export to the end user, your backend fetches it from this route and relays it onward itself.
The response is a raw stream (`Content-Type: application/octet-stream`) — not a JSON envelope. A request that isn't `COMPLETED`, has no artifact, or whose export has expired returns `404`, checked before any bytes are sent.
Both `GET` routes 404 identically for "no such request" and "that request belongs to another project" — a probing caller can't tell the difference between an id that doesn't exist and one that belongs to somebody else.
## Rate limits [#rate-limits]
`POST /v1/dsar/export` and `POST /v1/dsar/erasure` share **one budget of 5 requests per minute**, keyed to your API key — not 5 each. An export runs a multi-table read across a subscriber's full history, so this budget is deliberately tighter than other secret-key endpoints. `GET /v1/dsar/:id` and `GET /v1/dsar/:id/download` aren't separately throttled; they fall under the ordinary per-key envelope every `/v1` call already shares (500 requests/minute).
## What an export contains, and how long it lasts [#what-an-export-contains-and-how-long-it-lasts]
An export is a point-in-time JSON snapshot of everything Rovenue holds on that subscriber — not a subscription, and not something that stays in sync with later activity. Once you have it, it reflects that moment; a subscriber's next purchase or attribute change isn't reflected until you request a new export.
The completed artifact is downloadable for **30 days** from completion (`expiresAt` on the request record); after that, `GET /v1/dsar/:id/download` refuses it, but the object itself stays in storage — access is gated by the timestamp, not by removal. No scheduled sweep deletes it: the artifact stays until the subscriber it belongs to is erased (which deletes it outright, regardless of how much of the 30 days is left — see below) or the project is deleted (which purges the project's whole `dsar-exports//` prefix).
If a subscriber is erased while their own export is still `PENDING` or `RUNNING` (queued before the erasure, or picked back up after it), that export fails instead of completing, and the request record never gains a downloadable artifact. Requesting a NEW export for a subscriber who has already been erased fails the same way, immediately, at the `POST /v1/dsar/export` call itself. Either way, no export can hand back data for a subject who has been erased.
One nuance, because "fails" hides it: if the erasure lands in the narrow window after the export has already read the subject's data, the artifact has been written to storage by then. Rovenue deletes it before failing the request, and the two operations take a database row lock on the subscriber so they cannot interleave — whichever finishes first, the other sees it. The residual case is a storage error during that delete: the request still fails and the artifact is unreachable through this API, but the object itself can survive in the bucket, and no sweep will remove it.
## What erasure actually does [#what-erasure-actually-does]
Erasure **anonymises** the subscriber row — it does not delete it. The row's own id survives, its `appUserId` is replaced with a deterministic, non-reversible token, its attributes are cleared, and it's stamped `deletedAt`. Keeping the row (rather than deleting it outright) is what lets the append-only `credit_ledger` keep its guarantee: every ledger entry that ever referenced this subscriber still resolves to a real row, just one that no longer identifies anyone.
Erasure also deletes every artifact from any of the subscriber's OWN prior exports — every `dsar-exports/...` object this subscriber's completed `POST /v1/dsar/export` calls ever produced, not just the most recent one. Postgres anonymisation alone would not be enough: it never touches purchases, entitlements, or the credit ledger, and an already-downloaded export snapshot still has all of that under the subscriber's real, pre-erasure identity. After erasure, `GET /v1/dsar/:id/download` for any of that subscriber's own past export requests returns `404` — the request record itself still shows `COMPLETED` (the export genuinely did complete, at the time), but it no longer has anything to serve.
Erasure also purges the subscriber's rows from every ClickHouse table that stores a plain, queryable subscriber id at rest:
* `raw_exposures`
* `raw_revenue_events`
* `raw_credit_ledger`
* `raw_sdk_session_events`
* `raw_funnel_events`
A request is only marked `COMPLETED` once every part is done — the Postgres anonymisation, the export-artifact deletion above, and every one of the five ClickHouse deletes confirmed finished (not merely submitted; ClickHouse deletes run asynchronously in the background, and this worker waits for each one to actually complete before reporting success). If any part fails, the request is marked `FAILED`, never `COMPLETED` — a record that claims erasure happened while rows (or an old export) are still sitting there would be worse than no record at all. Retrying a failed erasure request is safe: re-running the Postgres step on an already-anonymised subscriber is a no-op, re-deleting an artifact that's already gone matches nothing, and re-submitting the ClickHouse deletes against already-purged rows matches nothing either.
Erasure reaches everything Rovenue stores. It **cannot** reach a copy of that subscriber's data your own backend already exported and kept — from a prior `GET /v1/dsar/:id/download`, your own database, your logs, or anywhere else outside Rovenue. Fulfilling erasure for copies you hold yourself is your responsibility.
### Limits worth stating plainly [#limits-worth-stating-plainly]
* **Only an erasure of the subscriber the export belongs to deletes the artifact from storage.** The 30-day `expiresAt` gate is a download-access cutoff, not a deletion trigger: once it passes, `GET /v1/dsar/:id/download` refuses the request, but nothing sweeps the object out of the bucket on that schedule — or on any schedule. An artifact whose 30 days haven't passed yet, for a subscriber who is never erased, keeps physically existing in storage indefinitely; the same is true, indefinitely, once the 30 days HAVE passed. This isn't something a separate retention job happens to cover either — the file-retention sweeper that runs against uploaded import files only ever deletes storage keys it reads off `import_jobs` rows, never a `dsar_requests` one, so the `dsar-exports/` object is outside its scope entirely. Short of erasing the subject or deleting the project, an export artifact simply stays in storage.
* **Erased subscribers staying erased is enforced by each write route individually** (every route that can write to a subscriber checks whether it's soft-deleted before touching it, so an end user's next SDK call can't silently resurrect a forgotten identity). A build-time check now fails if a route reaches a subscriber without consulting that signal, so a newly added write route can't quietly skip it — though the check proves a route consults the signal, not that it acts on it correctly, so per-route behaviour still carries its own tests.
* **This is a read-side guarantee too, not only a write-side one:** a `POST /v1/dsar/export` in flight when the same subscriber is erased fails rather than completing, and never leaves a downloadable artifact behind — see the callout above for the one storage-error case where the object itself can outlive the request.
## Building this into your own flow [#building-this-into-your-own-flow]
A typical integration: your backend receives a subject-access or erasure request through whatever channel you already use (a support form, an account-settings page, a compliance inbox), authenticates that it's really from the account holder, then calls the matching endpoint here with that subscriber's `rovenueId` and something identifying the request internally. Poll `GET /v1/dsar/:id` until `status` leaves `RUNNING`, and for exports, fetch and relay the artifact via `GET /v1/dsar/:id/download` before its 30-day window closes.
# Checking Entitlements (/docs/guides/entitlements)
Entitlements are the access grants that determine what a subscriber can do in
your app — "pro", "annual", "lifetime", and so on. The SDK keeps them in a local
cache so reads are always instant; you fetch from the server only when you need
guaranteed freshness.
Entitlements refresh automatically while the app is in the foreground (see
[App lifecycle](/docs/guides/app-lifecycle)). Manual `refreshEntitlements()`
is only needed when you require immediate consistency — for example, right after
a purchase completes. See [Methods — Entitlements](/docs/reference/methods#entitlements).
### Types on this page [#types-on-this-page]
```ts
type Entitlement = {
id: string; // the entitlement key you check, e.g. "pro"
isActive: boolean; // whether access is currently granted
productIdentifier: string; // the store product mapped to this entitlement
store: string; // "app_store" | "play_store" | ...
expiresIso?: string; // ISO-8601 expiry; absent for non-expiring grants
};
```
| Field | Type | Description |
| ------------------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
| `id` | `string` | The entitlement key configured in the dashboard (e.g. `"pro"`). |
| `isActive` | `boolean` | `true` when the user currently has access. Always check this field — don't rely on `expiresIso` alone. |
| `productIdentifier` | `string` | The store product ID that granted this entitlement. |
| `store` | `string` | The store that owns this grant: `"app_store"`, `"play_store"`, etc. |
| `expiresIso` | `string \| undefined` | ISO-8601 renewal / expiry timestamp. Absent for non-expiring (lifetime) grants. |
> Swift and Kotlin mirror these with platform-idiomatic names. See [Types — Entitlement](/docs/reference/types#entitlement).
***
## Reading and refreshing entitlements [#reading-and-refreshing-entitlements]
### Check a single entitlement (cache read) [#check-a-single-entitlement-cache-read]
`entitlement(id)` returns the cached `Entitlement` for the given key, or `null`
if the user has no such entitlement. The call never hits the network — it is
safe to call in render paths and tight loops.
On React Native, the `useEntitlement(id)` hook re-renders your component
automatically whenever the entitlement changes (purchase, expiry, restore).
```tsx
import { Rovenue, useEntitlement } from '@rovenue/react-native-sdk';
// Reactive hook — updates automatically when entitlement state changes
function ProBadge() {
const pro = useEntitlement('pro');
return pro?.isActive ? : null;
}
// Imperative cache read
const pro = Rovenue.entitlement('pro');
if (pro?.isActive) {
enableProFeatures();
}
```
```swift
// Imperative cache read
let pro = await Rovenue.shared.entitlement("pro")
if pro?.isActive == true {
enableProFeatures()
}
```
```kotlin
// Imperative cache read
val pro = Rovenue.shared.entitlement("pro")
if (pro?.isActive == true) {
enableProFeatures()
}
```
### Read all entitlements [#read-all-entitlements]
`entitlementsAll()` returns every cached entitlement for the current user. Use
it when your paywall or settings screen needs to display the full set of active
grants.
```tsx
import { Rovenue, useEntitlements } from '@rovenue/react-native-sdk';
// Reactive hook — returns all entitlements, re-renders on change
function SubscriptionStatus() {
const all = useEntitlements();
return (
{all.filter(e => e.isActive).map(e => (
{e.id} — expires {e.expiresIso ?? 'never'}
))}
);
}
// Imperative cache read
const all = Rovenue.entitlementsAll();
const active = all.filter(e => e.isActive);
```
```swift
// Imperative cache read
let all = await Rovenue.shared.entitlementsAll()
let active = all.filter { $0.isActive }
for ent in active {
print("\(ent.id) expires \(ent.expiresIso ?? "never")")
}
```
```kotlin
// Imperative cache read
val all = Rovenue.shared.entitlementsAll()
val active = all.filter { it.isActive }
active.forEach { ent ->
println("${ent.id} expires ${ent.expiresIso ?: "never"}")
}
```
### Refresh from the server [#refresh-from-the-server]
Call `refreshEntitlements()` to pull the latest entitlement state from the
server. The SDK updates its cache and emits an `EntitlementsChanged` event if
anything changed.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// After a purchase, force a server refresh to confirm the new entitlement
await Rovenue.refreshEntitlements();
const pro = Rovenue.entitlement('pro');
console.log('pro active:', pro?.isActive);
```
```swift
// After a purchase, force a server refresh to confirm the new entitlement
try await Rovenue.shared.refreshEntitlements()
let pro = await Rovenue.shared.entitlement("pro")
print("pro active:", pro?.isActive ?? false)
```
```kotlin
// After a purchase, force a server refresh to confirm the new entitlement
Rovenue.shared.refreshEntitlements()
val pro = Rovenue.shared.entitlement("pro")
println("pro active: ${pro?.isActive}")
```
`purchase()` already returns an up-to-date `PurchaseResult` that includes the
new entitlements — you do not need a separate `refreshEntitlements()` call
immediately after a purchase. See [Processing Purchases](/docs/guides/processing-purchases).
### React to entitlement changes (optional) [#react-to-entitlement-changes-optional]
Use `addChangeListener` to respond to `EntitlementsChanged` events without
polling — the listener fires whenever a purchase, restore, expiry, or server
push changes the entitlement state.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const removeListener = Rovenue.addChangeListener((event) => {
if (event === 'EntitlementsChanged') {
const pro = Rovenue.entitlement('pro');
updateUI(pro?.isActive ?? false);
}
});
// Later, when your component unmounts:
removeListener();
```
```swift
// Consume the AsyncStream on a background Task
Task {
for await event in Rovenue.shared.changeStream {
if event == .entitlementsChanged {
let pro = await Rovenue.shared.entitlement("pro")
await updateUI(pro?.isActive ?? false)
}
}
}
```
```kotlin
// Collect the SharedFlow in a coroutine scope
scope.launch {
Rovenue.shared.changeStream.collect { event ->
if (event == RovenueChangeEvent.ENTITLEMENTS_CHANGED) {
val pro = Rovenue.shared.entitlement("pro")
updateUI(pro?.isActive ?: false)
}
}
}
```
***
**Related:** [Processing Purchases](/docs/guides/processing-purchases) · [Reacting to
changes](/docs/guides/reacting-to-changes) · [Methods — Entitlements](/docs/reference/methods#entitlements) ·
[Types — Entitlement](/docs/reference/types#entitlement)
# Production & Sandbox (/docs/guides/environments)
Every purchase Rovenue records carries an **environment**: `PRODUCTION` or
`SANDBOX`. Environment is a property of the *transaction*, not the user —
the same subscriber can hold a sandbox subscription from testing and a
production one from paying, and each is reported on its own side. Test
traffic never inflates revenue figures, and it never needs deleting.
## Where the environment comes from [#where-the-environment-comes-from]
Each store reports test mode its own way; Rovenue normalizes all of them
at ingestion time:
* **Apple** — the signed transaction's `environment` (`Sandbox` vs
`Production`), verified as part of the StoreKit JWS chain.
* **Google Play** — `testPurchase` on product purchases and the voided /
test flags on subscription purchases. Promo-code and rewarded-ad
acquisitions stay `PRODUCTION` (real users, not license tests) but
book **$0 revenue** — no money moved, so the list price must not land
as revenue.
* **Stripe** — the event's `livemode`, matched against the connected
account's own mode. A test event arriving on a live connection (or vice
versa) is acknowledged but **not processed** (`mode_mismatch`), so a
misconfigured endpoint can never pollute either side.
Rows written before environment recording shipped read as production,
including historical Apple sandbox rows. This is accepted and stable —
there is no backfill, so old test traffic stays where it always was.
## Sandbox still grants access [#sandbox-still-grants-access]
A sandbox purchase is a fully processed purchase: it creates the
subscription, writes revenue rows, and **grants entitlements** exactly
like production. Your app unlocks for test buyers the same way it does
for paying ones. The only difference is where the numbers show up.
## Reading one environment: `?env=` [#reading-one-environment-env]
Analytics defaults to production. Append `?env=sandbox` to any scoped
read — MRR, revenue summary, LTV, charts, transactions, subscriptions,
credits, cohorts, retention — to see the sandbox side instead:
```http
GET /dashboard/projects/:id/metrics/revenue?env=sandbox # cash collected
GET /dashboard/projects/:id/metrics/mrr?env=sandbox # MRR, from paid terms
GET /dashboard/projects/:id/subscriptions?scope=all&env=sandbox
```
The dashboard's topbar toggle sends `?env=` with every request, so
flipping it switches the whole page's data at once. The toggle is
persisted per project.
## What stays production-only [#what-stays-production-only]
A few surfaces deliberately take no `env` parameter:
* **Experiments and placements** — exposures carry no environment, so a
sandbox revenue slice would divide by a production denominator.
* **LTV prediction, leaderboards, and digest emails** — forecasts and
rankings over real business only.
* **Subscriber lists and engagement** — user counts, not revenue.
* **Billing usage (MTR)** — you are billed on production revenue.
The metrics CSV export follows the same `?env=` you pass it.
# Experiments (/docs/guides/experiments)
Rovenue's experiments are Bayesian: every result is a posterior distribution
over a metric (conversion rate, ARPU, or proceeds per user), not a p-value.
A single decision-engine endpoint backs both the dashboard results page and
the SDK-facing placement resolution, so there is exactly one number for any
given experiment at any given time — never a dashboard figure that disagrees
with what the SDK actually served.
This page documents the decision rule itself, the assumptions it depends on,
and — as importantly — the places it is honest about what it cannot tell
you.
## The stopping rule [#the-stopping-rule]
A variant is only ever recommended for shipping when **all four** of the
following hold. Any one of them failing withholds the recommendation
entirely; the response tells you which gate is blocking (`SAMPLE_SIZE`,
`RUNTIME`, `SRM`, `CROSSOVER`, `REFUND_GUARDRAIL`, `EXPECTED_LOSS`,
`NO_LEADER`). Proceeds metrics always resolve a rate — configured, or
the store's default — so there is no gate for a missing rate.
1. **Expected loss** — the leader's expected loss, expressed as a fraction
of the control's posterior mean, is below a fixed relative threshold
(0.2%). Expressing it as a fraction rather than an absolute amount is
what lets the same threshold mean the same thing for a conversion rate
and for ARPU.
2. **Sample size** — every arm has reached the sample size required to
detect the experiment's own configured minimum detectable effect.
3. **Whole weekly cycles** — at least one full week has elapsed since the
experiment started, regardless of how quickly clause 2 was satisfied. A
high-traffic app can clear any sample threshold inside a single weekday,
and shipping on one weekday's user mix is the classic novelty /
day-of-week trap. This gate exists specifically to prevent that.
4. **No integrity or guardrail failure** — none of three checks has fired:
a sample-ratio mismatch (the observed traffic split disagrees with the
declared weights), excessive crossover contamination (subscribers
exposed to more than one variant), or the refund guardrail (the leader's
refund rate is meaningfully worse than control's). Unlike the other
gates, these three **suppress** the leader outright rather than
annotating it — a recommendation rendered next to a warning is a
recommendation that gets shipped anyway, so `leadingVariantId` is
withheld entirely rather than shown alongside a caveat.
**What "expected loss" does and does not guarantee.** Expected loss bounds
the *expected regret* of shipping the leader, computed under the posterior
model's own prior. **It is not a Type-I error rate**, and clearing this gate
is not the same claim as "p \< 0.05." Rovenue also does not claim that
continuously monitoring this number is "safe" in the frequentist sense —
that guarantee belongs to a different statistical framework and this engine
does not make it. The fixed-horizon Welch's-t cross-check the results page
also shows is the closest thing to a frequentist read available here, and it
is only valid at the experiment's originally planned sample size — never
during continuous monitoring, and never as the decision itself.
## The value model, and what it assumes [#the-value-model-and-what-it-assumes]
For revenue-based metrics (ARPU, proceeds per user), Rovenue fits a
log-normal posterior to each variant's per-converter value: the conversion
rate and the per-converter value are modeled and drawn independently, then
multiplied per Monte Carlo draw — never by multiplying their posterior
means, which is not the same thing for correlated quantities drawn from one
experiment.
The value factor is fitted **per subscriber**, not per order. A subscriber
who purchases twice in one experiment contributes one net-revenue figure,
not two independent data points — treating repeat purchases from the same
person as independent observations overstates the sample and understates
uncertainty.
The log-normal assumption has a real, visible failure mode: if every mature
converter in a variant paid the exact same price (a paywall selling one
product at one price is the common case), the log-value variance is exactly
zero. Rovenue treats that as information, not as missing data — the
posterior mean is exact in that case, and the metric's remaining
uncertainty lives entirely in the conversion rate, where it belongs. The
Welch's-t cross-check makes no distributional assumption at all, which is
why the results page shows both and flags it when they disagree in sign.
## The maturation window [#the-maturation-window]
Every subscriber's revenue is counted for a fixed number of days starting at
their **first exposure** to the experiment — the maturation window. Revenue
inside that window counts; revenue after it does not. It is a window, not a
delay.
Subscribers whose window has not yet fully elapsed are excluded from every
revenue metric and from the conversion-rate denominator entirely; they are
not counted as non-converters, because that would penalize a variant purely
for having gained traffic recently.
This means **exposures from the last several days are not counted yet.**
An experiment that looks flat because recent traffic hasn't matured is not
necessarily flat — check the **Excluded** column on the variants table
(immature / crossover, beside the **Mature** count every posterior is fitted
on) before concluding a variant has no effect.
A subscriber is also excluded — separately, into a "crossover" bucket —
if they were exposed to more than one variant of the same experiment. A
crossover rate above a small threshold suppresses the recommendation
entirely (gate 4 above), because a contaminated split cannot honestly
attribute an outcome to either arm. The rate is shown on the analysis card
beside the SRM verdict.
## A fully refunded purchase is not a conversion [#a-fully-refunded-purchase-is-not-a-conversion]
A subscriber whose gross revenue is fully offset by refunds within the
maturation window is **not counted as a converter**. This is a deliberate
semantic change from what an earlier, now-removed results path reported:
that older code counted any purchase event as a conversion regardless of
what happened to it afterward. Net revenue (gross minus refunds) is what
drives every conversion count, every log-value aggregate, and every
revenue-per-user figure on this page — a purchase-then-full-refund is
economically a non-event, and is now reported as one.
Partial refunds still count the subscriber as a converter, but reduce their
contribution to net revenue accordingly.
A subscriber whose net is *negative* — refunded more than they generated, e.g.
a chargeback against an accumulated balance — is not a converter either, by the
same `net > 0` rule. They still contribute their negative amount to the
revenue-per-user cross-check, which is computed over all mature subscribers
rather than over converters.
## Holdout groups [#holdout-groups]
A project can reserve a percentage of subscribers as a permanent holdout:
membership excludes a subscriber from every experiment on every placement,
serving them the control treatment instead — the experiment's **first
declared variant**, the same arm the results page treats as control — with
their exposure still recorded against a reserved holdout cohort id (an
unmeasured holdout is just a smaller, wasted audience). A held-out
subscriber is never shown an empty placement: they see the control funnel,
just never a treatment. Their exposures are reported separately from the
experiment's arms, so switching a holdout on never changes an experiment's
own split, SRM verdict or recommendation.
Membership is decided **server-side**, before the client ever receives an
experiment's variant menu — the variant draw itself is client-side and
deterministic, so holdout has to be resolved earlier than that, not layered
on top of it.
**Raising and lowering the holdout percentage are not symmetric.**
Membership is "hash below a threshold," so **raising** the percentage only
ever adds subscribers and leaves every existing member in place — the
accumulated comparison stays valid. **Lowering** it removes members whose
exposures are already recorded, retroactively mixing previously-held-out
and now-treated data in the same cohort. Raising is safe to do at any time;
lowering is lossy and should be treated as starting a new comparison, not
adjusting an old one.
## Variants are materialized server-side [#variants-are-materialized-server-side]
A `FUNNEL` experiment's variants each name a whole funnel. The server
**materializes** them into complete per-variant snapshots before they are
ever sent anywhere, so what reaches the device is a menu of fully resolved
funnels to draw from — never a reference the client has to go fetch.
Materialization is all-or-nothing. If any declared variant's funnel is
archived, inactive, or unpublished at resolution time, the whole variant set
is dropped rather than served short: the variant draw is client-side and falls
through to the last variant for any bucket past the cumulative weight total,
so a partial set would silently route 100% of traffic to one arm while every
exposure still logged as a normal split.
**A cached envelope or offline fallback file generated before an experiment
existed on a placement predates it entirely** — it has no idea the experiment
exists, because it was resolved and frozen before the experiment was created.
The device only sees the experiment once it reaches the network again and
re-resolves the placement live.
## The offline fallback file freezes the menu, not the decision [#the-offline-fallback-file-freezes-the-menu-not-the-decision]
The dashboard's bundled fallback-file export (**Dashboard → SDK → Offline
fallback**, served by `GET /dashboard/projects/:projectId/placements/fallback-export`) resolves
every active placement **anonymously** — no subscriber id, no attributes —
and is bundled into the app before it ships. Because it is generated before
any subscriber exists to draw a variant for, what it captures for an
experiment-targeted placement is the undrawn menu as of export time: the
same shape a live anonymous request would return, with no variant singled
out yet. So the bundled file is an undrawn menu, not a decision — it freezes
whatever the placement resolved to at export time, before any per-subscriber
draw has happened.
The actual variant draw only happens on-device, deterministically, once a
real subscriber id is available to hash against the experiment's key — the
same mechanism whether the SDK is reading a live response, a cached one, or
the bundled fallback. That includes an experiment whose variants are
**funnels**: it rides in the envelope on its own key, but it is drawn by the
same function against the same bucketing vectors, so a given subscriber
lands on the same variant offline as online. A stale export (taken before an experiment started,
or before an audience rule changed) means an offline device keeps resolving
whatever the placement pointed to when the file was generated until it can
reach the network and re-resolve live — for an experiment-targeted
placement, that is most often its pre-experiment or default path.
Re-export the fallback file whenever you start, stop, or retarget an
experiment on a placement you also ship offline coverage for.
# Identifying Users (/docs/guides/identifying-users)
Every Rovenue install carries two identifiers: a permanent SDK-generated
`rovenueId` and an optional `appUserId` that you supply when your user signs in.
Purchases, virtual currencies, and entitlements always belong to the `rovenueId`; the
`appUserId` is a label you attach so your backend can address the same subscriber
regardless of which device they are on.
The identity model is **optimistic**: `identify()` updates local state instantly
and fires a background network call to bind the IDs server-side. The user never
waits for a round trip. See [Methods — Identity](/docs/reference/methods#identity).
### Types on this page [#types-on-this-page]
```ts
type User = {
rovenueId: string; // permanent per-install id (prefix rov_); always present
appUserId: string | null; // your backend user id; null until identify()
};
```
| Field | Type | Description |
| ----------- | ---------------- | ------------------------------------------------------------------------------------------------ |
| `rovenueId` | `string` | Permanent per-install identifier (prefix `rov_`); never changes for the lifetime of the install. |
| `appUserId` | `string \| null` | Your backend's user ID; `null` until `identify()` is called. |
> Swift and Kotlin mirror these with platform-idiomatic names (`user.rovenueId` / `user.appUserId`). See [Types — User](/docs/reference/types#user).
***
## Identity lifecycle [#identity-lifecycle]
### Read the current user [#read-the-current-user]
`currentUser()` is a **cache read** — it never hits the network and returns
instantly. Use it to check whether a user is already identified at startup, or
use the React Native `useCurrentUser()` hook for reactive rendering.
```ts
import { Rovenue, useCurrentUser } from '@rovenue/react-native-sdk';
// Imperative cache read
const user = Rovenue.currentUser();
console.log(user.rovenueId); // always present, e.g. "rov_01abc..."
console.log(user.appUserId); // null until identify() has been called
// Reactive hook alternative — re-renders when identity changes
function Header() {
const user = useCurrentUser();
return {user?.appUserId ?? '(anonymous)'};
}
```
```swift
let user = await Rovenue.shared.currentUser()
print(user.rovenueId) // always present, e.g. "rov_01abc..."
print(user.appUserId ?? "(anonymous)") // nil until identify() has been called
```
```kotlin
val user = Rovenue.shared.currentUser()
println(user.rovenueId) // always present, e.g. "rov_01abc..."
println(user.appUserId ?: "(anonymous)") // null until identify() has been called
```
### Call `identify()` after sign-in [#call-identify-after-sign-in]
Call `identify(appUserId)` immediately after your auth flow completes and you
have a stable backend user ID. The call is **optimistic** — local state updates
immediately so entitlement reads are consistent without waiting for the server.
In the background, Rovenue fires `POST /v1/identify` to bind the `appUserId` to
the current `rovenueId`.
If that `appUserId` already exists on another install, Rovenue **automatically
transfers** that install's purchases and virtual-currency balances to this device. No extra call
is required.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Call immediately after your auth flow resolves
await Rovenue.identify('user_abc123');
// The local user now reflects the new appUserId
const user = Rovenue.currentUser();
console.log(user.appUserId); // "user_abc123"
```
```swift
// Call immediately after your auth flow resolves
try await Rovenue.shared.identify("user_abc123")
// The local user now reflects the new appUserId
let user = await Rovenue.shared.currentUser()
print(user.appUserId ?? "nil") // "user_abc123"
```
```kotlin
// Call immediately after your auth flow resolves (suspend function)
Rovenue.shared.identify("user_abc123")
// The local user now reflects the new appUserId
val user = Rovenue.shared.currentUser()
println(user.appUserId) // "user_abc123"
```
Use **opaque, unguessable** IDs for `appUserId` — never an email address,
username, or any value a third party could predict. Because `identify()` can
transfer purchases between installs, a guessable ID is an account-takeover
surface: an attacker who knows (or can guess) a victim's ID can call `identify`
on their own device and claim the victim's entitlements.
### Call `logOut()` on sign-out [#call-logout-on-sign-out]
When your user signs out, call `logOut()`. This mints a **fresh `rovenueId`**
for the install and clears `appUserId` and all scope-bound caches. The next user
on a shared device starts with a clean slate and cannot inherit any prior
entitlements or virtual-currency balances.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Call when your user signs out
await Rovenue.logOut();
const user = Rovenue.currentUser();
console.log(user.appUserId); // null — fresh anonymous install
console.log(user.rovenueId); // new rov_ id, different from the previous one
```
```swift
// Call when your user signs out
try await Rovenue.shared.logOut()
let user = await Rovenue.shared.currentUser()
print(user.appUserId ?? "nil") // nil — fresh anonymous install
print(user.rovenueId) // new rov_ id
```
```kotlin
// Call when your user signs out (suspend function)
Rovenue.shared.logOut()
val user = Rovenue.shared.currentUser()
println(user.appUserId) // null — fresh anonymous install
println(user.rovenueId) // new rov_ id
```
After `logOut()` the SDK emits an `IdentityChanged` change event. If you have
listeners attached via `addChangeListener`, they fire automatically. See
[Reacting to changes](/docs/guides/reacting-to-changes).
***
## Backend-driven consolidation [#backend-driven-consolidation]
For authoritative, bulk, or programmatic merging — for example, importing
subscribers from another platform or resolving a support-driven account merge —
your **backend** can call the transfer endpoint directly using your secret API key:
```http
POST /v1/subscribers/transfer
Authorization: Bearer
Content-Type: application/json
{
"fromRovenueId": "rov_01abc...",
"toRovenueId": "rov_01xyz..."
}
```
This call is server-to-server only. It gives you full control over which
subscriber "wins" and is the recommended path for bulk migrations or cases where
the client-side auto-transfer behaviour is insufficient.
Never put your **secret** API key (`rov_sec_…`) in the app binary. The transfer
endpoint is a server-to-server call only. Expose a lightweight endpoint in your
own backend that verifies the user session and then calls Rovenue on their
behalf.
***
**Related:** [Configuring the SDK](/docs/guides/configuring) · [Checking
Entitlements](/docs/guides/entitlements) · [Methods — Identity](/docs/reference/methods#identity) ·
[Types — User](/docs/reference/types#user) · [Privacy & consent](/docs/guides/identity-consent)
# Identity Context & Consent (/docs/guides/identity-consent)
When you call `identify()` or pass an `identityContext` with an event, the SDK forwards certain fields to the Rovenue API server for fan-out to downstream ad-attribution integrations (Meta CAPI, TikTok Events API, etc.). This guide explains which fields are forwarded, how each is handled, and where consent responsibility lies.
You must obtain end-user consent **before** passing any personally identifiable information in `identityContext`. Rovenue is a data processor — the consent obligation rests with you, the data controller.
### Types on this page [#types-on-this-page]
The `identityContext` object is the only input type in this guide. It is called `identityContext` (camelCase) in the React Native SDK and `IdentityContext` (PascalCase) in the Swift and Kotlin SDKs; the fields and semantics are identical across all three platforms.
```ts
// React Native
type identityContext = {
email?: string; // PII — SHA-256 hashed before forwarding
phone?: string; // PII — SHA-256 hashed before forwarding
externalId?: string; // PII — SHA-256 hashed before forwarding
ip?: string; // PII — forwarded verbatim, not hashed
userAgent?: string; // forwarded verbatim, not hashed
fbp?: string; // Meta click ID — forwarded verbatim
fbc?: string; // Meta browser ID — forwarded verbatim
ttclid?: string; // TikTok click ID — forwarded verbatim
ttp?: string; // TikTok pixel ID — forwarded verbatim
};
```
Fields marked **PII** must only be populated after obtaining the required consent. See [Consent is your responsibility](#consent-is-your-responsibility) below.
***
### Understand how each field is handled [#understand-how-each-field-is-handled]
The table below describes exactly what Rovenue does with each field. "Hashed" means the raw value is never sent over the wire or stored — only its SHA-256 digest is forwarded.
| Field | Forwarded as | Handling |
| ------------ | ---------------------------------------------------- | ----------------------------------------------- |
| `email` | `user_data.em` | SHA-256 — lowercased and trimmed before hashing |
| `phone` | `user_data.ph` | SHA-256 — digits only extracted before hashing |
| `externalId` | `user_data.external_id` | SHA-256 — trimmed before hashing |
| `ip` | `user_data.client_ip_address` / `context.ip` | Forwarded verbatim — **not** hashed |
| `userAgent` | `user_data.client_user_agent` / `context.user_agent` | Forwarded verbatim — **not** hashed |
| `fbp` | `user_data.fbp` | Forwarded verbatim — **not** hashed |
| `fbc` | `user_data.fbc` | Forwarded verbatim — **not** hashed |
| `ttclid` | `context.ttclid` | Forwarded verbatim — **not** hashed |
| `ttp` | `properties.ttp` | Forwarded verbatim — **not** hashed |
`ip`, `userAgent`, `fbp`, `fbc`, `ttclid`, and `ttp` are **not hashed**. They are forwarded verbatim to downstream networks. Only collect and pass these fields after the user has given explicit, informed consent.
### Pass identityContext after consent is collected [#pass-identitycontext-after-consent-is-collected]
Only populate `identityContext` fields once your consent gate has been accepted. Below is the pattern for each platform.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Only call after the user has accepted your privacy/consent prompt
await Rovenue.identify('user_123', {
email: 'user@example.com', // hashed before forwarding
phone: '+15555550100', // hashed before forwarding
ip: requestIp, // forwarded verbatim — consent required
});
```
```swift
import Rovenue
// Only call after the user has accepted your privacy/consent prompt
try await Rovenue.shared.identify(
"user_123",
identityContext: IdentityContext(
email: "user@example.com", // hashed before forwarding
phone: "+15555550100", // hashed before forwarding
ip: requestIp // forwarded verbatim — consent required
)
)
```
```kotlin
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.IdentityContext
// Only call after the user has accepted your privacy/consent prompt
Rovenue.shared.identify(
"user_123",
identityContext = IdentityContext(
email = "user@example.com", // hashed before forwarding
phone = "+15555550100", // hashed before forwarding
ip = requestIp // forwarded verbatim — consent required
)
)
```
### Consent is your responsibility [#consent-is-your-responsibility]
Rovenue acts as a **data processor**. Your application is the **data controller**. You must obtain informed consent from the end-user before passing any PII in `identityContext`. This obligation applies regardless of jurisdiction — GDPR, CCPA, KVKK, PDPA, and similar regulations all place the consent responsibility on the controller, not the processor.
Do not pass PII in `identityContext` without collecting the required consent. The fields are forwarded to third-party attribution networks (Meta, TikTok). Once sent, Rovenue **cannot** recall them on your behalf.
Practical checklist before passing any PII field:
* You have a privacy policy that discloses the use of ad-attribution networks.
* The user has been shown a consent prompt and actively accepted it.
* Your consent record is tied to the user session and survives app restarts.
* You have a mechanism to withdraw consent and stop forwarding PII if the user opts out.
See also: [Identifying users](/docs/guides/identifying-users) for the full `identify()` flow, and [Meta CAPI](/docs/integrations/meta-capi) / [TikTok Events](/docs/integrations/tiktok-events) for integration-specific consent requirements.
## Additional data-handling guarantees [#additional-data-handling-guarantees]
* Rovenue **never persists** `identityContext` fields at rest. PII is hashed in-process and only the hash is forwarded downstream; raw values are not written to any database.
* `response_body` from downstream providers is stored but **truncated to 4096 bytes** to prevent inadvertent PII capture in error messages.
* The wire format uses **camelCase JSON**; each envelope carries a numeric `version` field (currently `1`) and a stable `eventId`, both stamped by the SDK. Do not construct envelope JSON manually — use the SDK methods, which enforce the schema and set the version.
# Placements (/docs/guides/placements)
A **placement** is a named location in your app — `onboarding_end`,
`settings_upsell`, `feature_gate` — where a funnel may appear. Your code asks
for the placement by its identifier; the dashboard decides what to show there:
which funnel, to which audience, or which A/B-test variant. Change the
funnel, retarget the audience, or start a test without shipping an update.
A placement serves a **native funnel**: a multi-screen onboarding-and-paywall
document drawn by the SDK's native renderer. Rovenue serves the published
document, the offering (products and packages), the experiment assignment,
and wires up analytics and purchase attribution automatically. See
[Show a Funnel](/docs/funnels/native/show-a-funnel) for the per-platform
rendering walkthrough.
`getFunnel` returns `null` when the placement resolves to nothing — it was
retired, targeted to "show nothing", or the identifier is unknown. This is
deliberate: a shipped app must never crash because a placement changed
server-side. Always handle the `null` branch.
## Fetch a placement [#fetch-a-placement]
```swift
if let funnel = try await Rovenue.shared.getFunnel(placementId: "onboarding_end", locale: "en") {
// Host funnel.session in RovenueFunnelView — see Show a Funnel
showFunnel(funnel)
} else {
// Placement resolved to nothing — continue without a funnel
}
```
```kotlin
val funnel = Rovenue.shared.getFunnel(placementId = "onboarding_end", locale = "en")
if (funnel != null) {
showFunnel(funnel) // host funnel.session in RovenueFunnelView
}
```
```tsx
import { RovenueFunnelView } from '@rovenue/react-native-sdk';
// There is deliberately no JS getFunnel — the native side resolves the
// placement itself and this view hosts the native renderer.
{}} />
```
```dart
// Like React Native, there is no Dart getFunnel — the platform side
// resolves the placement and this widget hosts the native view.
RovenueFunnelView(placementIdentifier: 'onboarding_end')
```
The optional `locale` selects which localization of the funnel document you
receive. If the requested locale isn't configured, the funnel's default
locale is served.
## How a placement resolves [#how-a-placement-resolves]
In the dashboard a placement is an ordered list of audience rows. The first
row whose audience matches the subscriber wins:
1. **Funnel** — that funnel is returned directly.
2. **A/B test** — a running experiment over funnels; the SDK deterministically
draws a variant (same subscriber + same experiment ⇒ same variant, every
time, on every device, even offline from cache) and returns that variant's
funnel.
3. **Nothing** — `getFunnel` returns `null` for this audience.
An "All users" row at the bottom acts as the catch-all; without one,
non-matching subscribers get `null`.
## Tracking and attribution [#tracking-and-attribution]
* Funnel screen events (`started`, `advanced`, `completed`) are emitted
automatically as the subscriber moves through the funnel. Views and unique
viewers appear on the placement's page in the dashboard.
* Purchase attribution is automatic: the funnel carries a
`presentedContext` snapshot, and the SDK attaches it to the next purchase.
Revenue is credited to the placement and funnel that converted
the subscriber. You never construct or send this yourself.
## Offline behavior [#offline-behavior]
The last successful response for each placement is cached on device. On
network failure the cached response is served, and because variant selection
is deterministic, a cached A/B test still yields the same variant the server
would have assigned.
## Relationship to offerings [#relationship-to-offerings]
A funnel references an offering; the funnel's offering is the same hydrated
shape returned by `getOfferings()` — pass any of its packages straight into
`purchase(pkg)`. Placements do not replace offerings; they decide *which*
funnel (and therefore which offering) a given subscriber sees at a given
location.
## Offline fallback file [#offline-fallback-file]
Placements can work offline using a bundled fallback file — the resolved
configuration for every active placement. The full reference, per-platform
setup, and the `servedFromFallback` flag live in
[Offline fallback](/docs/funnels/native/offline-fallback).
## Events [#events]
Funnel session events ride a **durable, at-least-once queue** on the device.
Events survive process kills and retries automatically on network recovery. If
the server returns 5xx or network fails, the event stays queued; on 2xx, it's
removed. This guarantees every completion is eventually reported, even if the
user finishes the funnel and immediately closes the app.
# Processing Purchases (/docs/guides/processing-purchases)
The SDK drives the full purchase lifecycle — fetching products from the store,
presenting them, triggering StoreKit 2 (iOS) or Play Billing 6 (Android), and
validating the receipt server-side — all in a single `purchase(pkg)` call. Your
app only decides which package to present.
To let the dashboard decide *which* offering a subscriber sees at a given
location — per audience, with A/B tests — see
[Placements](/docs/guides/placements).
Rovenue handles receipt validation server-side before resolving the purchase
promise. Your app receives verified entitlements and updated virtual-currency balances
in the result — no additional polling or webhook listener is needed on the
client. See [Methods — Purchases](/docs/reference/methods#purchases).
### Types on this page [#types-on-this-page]
```ts
type Offerings = {
current: Offering | null; // default offering; null if none configured
all: Record; // all offerings by identifier
};
type Offering = {
identifier: string; // e.g. "default" or "annual_promo"
isDefault: boolean; // true for the offering marked default in the dashboard
packages: Package[]; // ordered list of packages in this offering
};
type Package = {
identifier: string; // e.g. "$rc_monthly", "$rc_annual"
product: StoreProduct; // localised product from the store
};
type StoreProduct = {
id: string; // store product ID
type: ProductType; // "subscription" | "consumable" | "non_consumable"
displayName: string; // localised product name
priceString: string | null; // localised price string, e.g. "$4.99"
price: number | null; // raw price in the account currency
currencyCode: string | null; // ISO 4217 currency code, e.g. "USD"
};
type ProductType = 'subscription' | 'consumable' | 'non_consumable';
type PurchaseResult = {
entitlements: Entitlement[]; // verified entitlements after the purchase; empty when isDeferred
virtualCurrencies: Record; // updated virtual-currency balances (code → amount)
productId: string; // store product ID that was purchased
storeTransactionId: string; // store-issued transaction identifier; empty when isDeferred
isDeferred: boolean; // true when pending external approval (Ask-to-Buy) — not an error
};
```
| Type | Key fields | Description |
| ---------------- | ------------------------------------------------- | ---------------------------------------------------------- |
| `Offerings` | `current`, `all` | Container returned by `getOfferings()`. |
| `Offering` | `identifier`, `packages` | A named group of packages configured in the dashboard. |
| `Package` | `identifier`, `product` | A purchasable unit wrapping a `StoreProduct`. |
| `StoreProduct` | `type`, `priceString`, `price` | Live pricing data from the store; localised automatically. |
| `PurchaseResult` | `entitlements`, `virtualCurrencies`, `isDeferred` | Returned after server-validated purchase. |
> See [Types](/docs/reference/types) for the full `Entitlement` shape included in `PurchaseResult`.
***
## Purchase flow [#purchase-flow]
### Fetch offerings [#fetch-offerings]
Offerings are configured in the Rovenue dashboard and cached locally after the
first fetch. Fetch them at app launch or when your paywall opens so pricing is
always available instantly.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
// offerings.current — the default Offering (or null if none configured)
// offerings.all — Record for all named offerings
```
```swift
let offerings = try await Rovenue.shared.getOfferings()
// offerings.current — the default Offering?
// offerings.all — [String: Offering]
```
```kotlin
val offerings = Rovenue.shared.getOfferings()
// offerings.current — the default Offering?
// offerings.all — Map
```
### Present packages to the user [#present-packages-to-the-user]
Iterate over `offering.packages` to build your paywall UI. Each `Package`
exposes a `StoreProduct` with localised pricing directly from the store — use
`priceString` for display and `price` for analytics.
```tsx
import { Rovenue } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
const packages = offerings.current?.packages ?? [];
function Paywall() {
return (
{packages.map((pkg) => (
handlePurchase(pkg)}>
{pkg.product.displayName}
{pkg.product.priceString}
))}
);
}
```
```swift
let offerings = try await Rovenue.shared.getOfferings()
let packages = offerings.current?.packages ?? []
for pkg in packages {
print("\(pkg.product.displayName) — \(pkg.product.priceString ?? "?")")
}
```
```kotlin
val offerings = Rovenue.shared.getOfferings()
val packages = offerings.current?.packages ?: emptyList()
for (pkg in packages) {
println("${pkg.product.displayName} — ${pkg.product.priceString}")
}
```
`priceString` and `price` are fetched live from the store at runtime, so they
reflect the user's locale and currency without any extra work on your part.
### Call `purchase(pkg)` on selection [#call-purchasepkg-on-selection]
Pass the chosen `Package` to `Rovenue.purchase()`. The SDK presents the native
store sheet (StoreKit 2 on iOS, Play Billing on Android), waits for the
transaction to complete, and validates the receipt server-side before resolving.
The resolved `PurchaseResult` carries verified entitlements and updated virtual-currency balances —
no additional polling needed.
Rovenue decides server-side — based on your product configuration in the
dashboard — whether a purchase grants an entitlement (subscriptions, one-time
unlocks) or adds credits (consumables). Both product types go through the same
`purchase(pkg)` call.
```ts
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
async function handlePurchase(pkg) {
try {
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) {
// Ask-to-Buy / parental controls — not an error. Wait for
// 'ENTITLEMENTS_CHANGED' via addChangeListener before granting entitlements.
showPendingMessage();
return;
}
// result.entitlements — verified, up-to-date array
// result.virtualCurrencies — updated balances (for currency-pack products)
console.log('Purchase succeeded:', result.productId);
console.log('Transaction ID:', result.storeTransactionId);
navigateToSuccess();
} catch (e) {
if (!(e instanceof RovenueError)) throw e; // not an SDK error — re-throw
switch (e.kind) {
case 'PurchaseCanceled':
// User dismissed the store sheet — no action needed
break;
case 'ProductNotAvailable':
// Product ID not available in this store environment
showError('This product is not available. Please try again later.');
break;
case 'StoreProblem':
// Unexpected StoreKit / Play Billing error
showError('Store error. Please try again.');
break;
default:
throw e; // unexpected — re-throw
}
}
}
```
```swift
func handlePurchase(pkg: Package) async {
do {
let result = try await Rovenue.shared.purchase(pkg)
if result.isDeferred {
// Ask-to-Buy / deferred — inform the user and wait for a change event
showPendingMessage()
return
}
// result.entitlements — verified, up-to-date array
// result.virtualCurrencies — updated balances (for currency-pack products)
print("Purchase succeeded: \(result.productId)")
print("Transaction ID: \(result.storeTransactionId)")
navigateToSuccess()
} catch let e as RovenueError {
switch e.kind {
case .purchaseCanceled:
break // User dismissed the store sheet — no action needed
case .productNotAvailable:
showError("This product is not available. Please try again later.")
case .storeProblem:
showError("Store error. Please try again.")
default:
throw e
}
}
}
```
```kotlin
// `activity` is your current Activity — Play Billing requires it.
suspend fun handlePurchase(activity: Activity, pkg: Package) {
try {
val result = Rovenue.shared.purchase(activity, pkg)
if (result.isDeferred) {
// Deferred payment — inform the user and wait for a change event
showPendingMessage()
return
}
// result.entitlements — verified, up-to-date list
// result.virtualCurrencies — updated balances (for currency-pack products)
println("Purchase succeeded: ${result.productId}")
println("Transaction ID: ${result.storeTransactionId}")
navigateToSuccess()
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.PURCHASE_CANCELED -> { /* User dismissed the store sheet — no action needed */ }
ErrorKind.PRODUCT_NOT_AVAILABLE -> showError("This product is not available. Please try again later.")
ErrorKind.STORE_PROBLEM -> showError("Store error. Please try again.")
else -> throw e
}
}
}
```
**Errors to handle explicitly**
* `RovenueError`/`RovenueException` with `kind === 'PurchaseCanceled'` — the user dismissed the store sheet. Safe to ignore; do not show an error.
* `result.isDeferred` (not an error) — Ask-to-Buy (iOS) or a pending Play Billing transaction. Show a "waiting for approval" message and listen for an `ENTITLEMENTS_CHANGED` event via `addChangeListener`.
* `kind === 'ProductNotAvailable'` — the product ID is not available in this store environment. Check your dashboard configuration.
* `kind === 'StoreProblem'` — an unexpected StoreKit / Play Billing error. Retry or surface to the user.
See [Errors](/docs/reference/errors) for the full error reference.
### Restore purchases [#restore-purchases]
Apple's App Store guidelines require a **Restore Purchases** button in your
paywall UI. Call `restorePurchases()` to re-validate all prior transactions for
the current user — for example, after a reinstall or when a user switches
devices.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Triggered by a "Restore Purchases" button
await Rovenue.restorePurchases();
// Entitlements are refreshed; read from cache
const pro = Rovenue.entitlement('pro');
console.log('pro restored:', pro?.isActive);
```
```swift
// Triggered by a "Restore Purchases" button
try await Rovenue.shared.restorePurchases()
// Entitlements are refreshed; read from cache
let pro = await Rovenue.shared.entitlement("pro")
print("pro restored:", pro?.isActive ?? false)
```
```kotlin
// Triggered by a "Restore Purchases" button (needs the current Activity)
Rovenue.shared.restorePurchases(activity)
// Entitlements are refreshed; read from cache
val pro = Rovenue.shared.entitlement("pro")
println("pro restored: ${pro?.isActive}")
```
On completion, the SDK emits an `ENTITLEMENTS_CHANGED` change event with the
refreshed entitlement state. If you have a listener attached via
`addChangeListener`, it fires automatically — you do not need to poll. See
[Reacting to changes](/docs/guides/reacting-to-changes).
***
## Redeeming promotional offers (iOS) [#redeeming-promotional-offers-ios]
iOS allows app publishers to offer promotional discounts on subscriptions. These appear
in `StoreProduct.discounts` and can be passed to `purchase()` to apply a time-limited
discount at the moment of purchase. The SDK automatically requests a server-signed offer
signature (valid 24 hours) — no additional setup required.
### Overview [#overview]
* **Promotional offers** come from `StoreProduct.discounts` with `type` = `'promotional'` or `'winBack'`.
* **Introductory offers** (`type: 'introductory'`) are applied automatically by the App Store and **must NOT** be passed to `purchase()` — doing so throws.
* The server signs offers using your project's existing Apple In-App Purchase credentials (no new `.p8` file needed).
* A signature is valid for 24 hours; the SDK requests a fresh one per purchase automatically.
* **iOS:** Promotional offer redemption is supported via `purchase(..., promotionalOffer:)` or `purchase(..., promotionalOfferId:)`.
* **Android:** Promotional offers are not supported on native apps. React Native apps accept (but ignore) the `promotionalOfferId` option on Android.
**Introductory offers are automatic**
Do not pass offers with `type: 'introductory'` to `purchase()`. The App Store applies them
automatically based on eligibility. Attempting to pass one results in an error.
```ts
// ❌ Wrong
const intro = product.discounts.find(d => d.type === 'introductory');
await purchase(pkg, { promotionalOfferId: intro?.identifier });
// ✅ Correct
const promo = product.discounts.find(d => d.type === 'promotional' || d.type === 'winBack');
await purchase(pkg, { promotionalOfferId: promo?.identifier });
```
### Usage by platform [#usage-by-platform]
Pass the offer's `identifier` in the options object:
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
const product = pkg?.product;
// Find a promotional offer (skip 'introductory')
const promoOffer = product?.discounts?.find(
d => d.type === 'promotional' || d.type === 'winBack'
);
if (promoOffer) {
try {
const result = await Rovenue.purchase(pkg, {
promotionalOfferId: promoOffer.identifier,
});
console.log('Purchase with promo succeeded:', result.productId);
} catch (e) {
// Handle errors (see [Errors](/docs/reference/errors))
}
} else {
// No promotional offer available; purchase at full price
const result = await Rovenue.purchase(pkg);
}
```
**Note:** On Android, the `promotionalOfferId` parameter is present but ignored; the purchase proceeds at the standard price.
Pass a `Discount` object (or use the id-based overload):
```swift
let offerings = try await Rovenue.shared.getOfferings()
let pkg = offerings.current?.packages.first
let product = pkg?.product
// Find a promotional offer (skip 'introductory')
let promoOffer = product?.discounts.first { discount in
discount.type == .promotional || discount.type == .winBack
}
if let offer = promoOffer {
do {
// Pass the Discount object directly
let result = try await Rovenue.shared.purchase(product!, promotionalOffer: offer)
print("Purchase with promo succeeded: \(result.productId)")
// Or use the id-based overload:
// let result = try await Rovenue.shared.purchase(product!, promotionalOfferId: offer.identifier!)
} catch {
// Handle errors (see [Errors](/docs/reference/errors))
}
} else {
// No promotional offer available; purchase at full price
let result = try await Rovenue.shared.purchase(product!)
}
```
**Important:** Passing a `.introductory` offer throws an error with code `.ineligible` and message
"Introductory offers are applied automatically; do not pass them as a promotional offer."
Promotional offer redemption is **not supported** on native Android. The native Kotlin SDK's
`purchase()` method does not expose a promotional-offer parameter.
```kotlin
val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull()
if (pkg != null) {
try {
// Native Android uses the standard purchase flow; promotional offers are unavailable
val result = Rovenue.shared.purchase(activity, pkg)
println("Purchase succeeded: ${result.productId}")
} catch (e: Exception) {
// Handle errors (see [Errors](/docs/reference/errors))
}
}
```
**Note:** Promotional offer redemption is iOS-only. Google Play uses a different offer model
(`offerToken`) which the SDK does not currently expose for purchase. React Native apps on Android
will have the `promotionalOfferId` option accepted but ignored, falling back to standard pricing.
### Server prerequisites [#server-prerequisites]
Your project must have a valid Apple In-App Purchase `.p8` certificate file (from [App Store Connect](https://appstoreconnect.apple.com))
configured in the Rovenue dashboard under **Settings → Apple In-App Purchase**.
This certificate is already used for receipt validation; the same credential is reused to sign promotional offers.
If signing fails, the API returns error code `apple_offer_signing_unavailable` — check that your Apple credentials
are complete and valid.
***
## Selecting a subscription offer (Android) [#selecting-a-subscription-offer-android]
Android products can expose multiple subscription offers through `subscriptionOptions` — for example, a
monthly base plan and a monthly-with-free-trial offer. You can select a specific offer at purchase time
to redeem the user's chosen variant.
### Overview [#overview-1]
* **Subscription offers** come from `product.subscriptionOptions`, a list of `SubscriptionOption` objects available on the product.
* Each `SubscriptionOption` has a stable identity (`basePlanId` and `offerId`) that the SDK uses to resolve the live Play offer at purchase time.
* When no option is passed to `purchase()`, the SDK uses the default option (`product.defaultOption`) — the lowest-price base plan, not Play's arbitrary first offer.
* If the selected offer is no longer available on Google Play, purchase fails with `RovenueException` (Kotlin) or `RovenueError` (React Native) — refresh offerings and retry.
* **iOS:** the `subscriptionOption` parameter is ignored on iOS (iOS uses [promotional offers](/docs/guides/processing-purchases#redeeming-promotional-offers-ios) instead).
### Server-configured default [#server-configured-default]
The default offer (`product.defaultOption`) can be configured server-side in the Rovenue dashboard:
* Per-product configuration in the **Android** section: set an **Base plan ID** (and optionally an **Offer ID**).
* When configured, `product.defaultOption` resolves to this server-chosen plan instead of the lowest-price option.
* This ensures the advertised default price in your UI matches the actual plan the SDK purchases when the user selects a package without explicitly choosing an offer variant.
* When left blank, the SDK falls back to the lowest-priced base plan (current behavior).
* **Important:** an offer ID cannot be configured without a base plan ID — the API rejects orphaned offers.
* If the configured base plan or offer no longer exists on Google Play, the SDK gracefully falls back to the lowest-priced base plan at purchase time (does not fail).
* **Android-only:** iOS has no base-plan concept; these configuration fields are ignored on iOS.
### Free trials and introductory prices [#free-trials-and-introductory-prices]
Rovenue labels each Google Play charge from the order Google actually billed. A free-trial start is
recorded as a $0 `INITIAL` event, the first paid order after the trial as `TRIAL_CONVERSION` at the
amount the buyer paid, and an introductory period at its introductory price. While a free trial runs,
the subscription's status is `TRIAL`.
To read each order, grant the service account you saved under **Stores → Google Play** the Play
Console permission **View financial data, orders, and cancellation survey responses** (Users and
permissions → the service account → App permissions). Without it, Rovenue falls back to the live
subscription state only when that state provably describes the order, and otherwise records no
revenue event for it (the server log names the reason). Saving the credentials shows a warning on
the Stores card while the permission is missing.
### Usage by platform [#usage-by-platform-1]
Pass the chosen `SubscriptionOption` in the options object:
```ts
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
const product = pkg?.product;
// Get available subscription offers for this product
const options = product?.subscriptionOptions ?? [];
// Select the offer (e.g., the free-trial variant)
const selectedOption = options.find(opt => opt.tags.includes('free_trial'));
if (selectedOption && pkg) {
try {
const result = await Rovenue.purchase(pkg, {
subscriptionOption: selectedOption,
});
console.log('Purchase with offer succeeded:', result.productId);
} catch (e) {
if (e instanceof RovenueError && e.kind === 'ProductNotAvailable') {
// The selected offer is no longer available; refresh offerings and retry
showError('This offer is no longer available. Please try again.');
} else {
// Handle other errors (see [Errors](/docs/reference/errors))
}
}
} else if (pkg) {
// No specific offer selected; purchase the default option
const result = await Rovenue.purchase(pkg);
}
```
**Note:** On iOS, the `subscriptionOption` parameter is present but ignored; the purchase proceeds with the standard iOS flow.
Subscription offer selection is **not directly supported** on native Swift. The native Kotlin SDK exposes this
feature for Android; React Native apps can use the feature cross-platform.
```swift
let offerings = try await Rovenue.shared.getOfferings()
let pkg = offerings.current?.packages.first
let product = pkg?.product
if let product = product {
do {
// Native Swift does not expose subscriptionOptions
// Use the standard purchase flow; the selected offer is an Android-only feature
let result = try await Rovenue.shared.purchase(product)
print("Purchase succeeded: \(result.productId)")
} catch {
// Handle errors (see [Errors](/docs/reference/errors))
}
}
```
**Note:** Subscription offer selection is Android-only (Play Billing feature). If you need this functionality
on iOS, use React Native instead.
Pass the chosen `SubscriptionOption` to `purchase()`:
```kotlin
import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.ErrorKind
val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull()
val product = pkg?.product
if (product != null) {
// Get available subscription offers for this product
val options = product.subscriptionOptions ?: emptyList()
// Select the offer (e.g., the free-trial variant)
val selectedOption = options.find { opt -> opt.tags.contains("free_trial") }
if (selectedOption != null) {
try {
// Pass the chosen option to purchase
val result = Rovenue.shared.purchase(activity, product, selectedOption)
println("Purchase with offer succeeded: ${result.productId}")
} catch (e: RovenueException) {
if (e.kind == ErrorKind.PRODUCT_NOT_AVAILABLE) {
// The selected offer is no longer available; refresh offerings and retry
showError("This offer is no longer available. Please try again.")
} else {
// Handle other errors (see [Errors](/docs/reference/errors))
}
}
} else {
// No specific offer selected; purchase the default option
val result = Rovenue.shared.purchase(activity, product)
println("Purchase with default offer succeeded: ${result.productId}")
}
}
```
You can also pass the option when purchasing a `Package`:
```kotlin
val pkg = offerings.current?.packages?.firstOrNull()
if (pkg != null && selectedOption != null) {
val result = Rovenue.shared.purchase(activity, pkg, selectedOption)
}
```
**Handling stale or unavailable offers**
If the `SubscriptionOption` you selected is no longer available on Google Play (e.g., because the offer was
removed or expired), the SDK will raise `RovenueException` with `kind == ErrorKind.PRODUCT_NOT_AVAILABLE` at purchase time. The error is raised only
at the moment of purchase — not when building the UI.
**What to do:**
1. Catch `RovenueException` and check `kind == ErrorKind.PRODUCT_NOT_AVAILABLE` in your purchase handler.
2. Call `Rovenue.getOfferings()` again to refresh the product list.
3. Show the user a message like "This offer is no longer available" and let them select another.
```kotlin
import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.ErrorKind
try {
val result = Rovenue.shared.purchase(activity, product, selectedOption)
} catch (e: RovenueException) {
if (e.kind == ErrorKind.PRODUCT_NOT_AVAILABLE) {
// Refresh offerings
val refreshed = Rovenue.shared.getOfferings()
showError("This offer is no longer available. Please select another.")
} else {
// Handle other errors
}
}
```
***
**Related:** [Checking Entitlements](/docs/guides/entitlements) · [Reacting to
changes](/docs/guides/reacting-to-changes) · [Methods — Purchases](/docs/reference/methods#purchases) ·
[Types](/docs/reference/types) · [Errors](/docs/reference/errors)
# Reacting to Changes (/docs/guides/reacting-to-changes)
The SDK emits a `ChangeEvent` whenever its internal state changes — after a purchase, an identity update, or a virtual-currency balance change. Subscribe to drive UI updates without polling.
In React Native you rarely need the imperative listener directly — the hooks (`useEntitlement`, `useEntitlements`, `useCurrentUser`, `useVirtualCurrencies`, `useVirtualCurrency`) subscribe and unsubscribe automatically. Use the imperative API for non-component code or for Swift/Kotlin.
### Types on this page [#types-on-this-page]
`ChangeEvent` is an enum whose value tells you which cache to re-read. Full definition: [Types → ChangeEvent](/docs/reference/types#changeevent).
```ts
// React Native — string union
type ChangeEvent =
| 'ENTITLEMENTS_CHANGED'
| 'IDENTITY_CHANGED'
| 'VIRTUAL_CURRENCIES_CHANGED'
| 'REMOTE_CONFIG_CHANGED';
```
On **Swift** the change stream is `AsyncStream` and the cases are `.entitlementsChanged`, `.identityChanged`, `.virtualCurrenciesChanged`, `.remoteConfigChanged`.
On **Kotlin** the change stream is `SharedFlow` and the constants are `ChangeEvent.ENTITLEMENTS_CHANGED`, `ChangeEvent.IDENTITY_CHANGED`, `ChangeEvent.VIRTUAL_CURRENCIES_CHANGED`, `ChangeEvent.REMOTE_CONFIG_CHANGED`.
| Event value | When it fires |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `ENTITLEMENTS_CHANGED` | After the entitlement cache is refreshed — e.g. after a purchase, `refreshEntitlements()`, or a foreground sync. |
| `IDENTITY_CHANGED` | After `identify()` succeeds or `logOut()` resets the user. |
| `VIRTUAL_CURRENCIES_CHANGED` | After `refreshVirtualCurrencies()` or a server-side grant/spend that updates the cache. |
| `REMOTE_CONFIG_CHANGED` | After the remote-config cache is refreshed — e.g. after `refreshRemoteConfig()` or a foreground sync. |
***
### Subscribe to the change stream [#subscribe-to-the-change-stream]
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// addChangeListener returns an unsubscribe function — call it to clean up
const unsub = Rovenue.addChangeListener((event) => {
switch (event) {
case 'ENTITLEMENTS_CHANGED':
console.log('Entitlements updated — re-check access');
break;
case 'IDENTITY_CHANGED':
console.log('Identity changed — re-read current user');
break;
case 'VIRTUAL_CURRENCIES_CHANGED':
console.log('Virtual-currency balances changed — re-read balances');
break;
case 'REMOTE_CONFIG_CHANGED':
console.log('Remote config changed — re-read values');
break;
}
});
```
In React components, use the hooks instead — they handle subscription lifecycle automatically:
```tsx
import {
useEntitlement,
useEntitlements,
useCurrentUser,
useVirtualCurrencies,
useVirtualCurrency,
} from '@rovenue/react-native-sdk';
function ProBadge() {
const pro = useEntitlement('pro'); // re-renders on ENTITLEMENTS_CHANGED
return pro?.isActive ? : null;
}
```
```swift
import Rovenue
// Rovenue.shared.changes is an AsyncStream
// Consume it inside a Task that you own so you can cancel it later
let task = Task {
for await event in Rovenue.shared.changes {
switch event {
case .entitlementsChanged:
print("Entitlements updated — re-check access")
case .identityChanged:
print("Identity changed — re-read current user")
case .virtualCurrenciesChanged:
print("Virtual-currency balances changed — re-read balances")
case .remoteConfigChanged:
print("Remote config changed — re-read values")
}
}
}
```
```kotlin
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.ChangeEvent
// Rovenue.shared.changes is a SharedFlow
// Collect from a scope you own — see the warning below
viewModelScope.launch {
Rovenue.shared.changes.collect { event ->
when (event) {
ChangeEvent.ENTITLEMENTS_CHANGED -> println("Entitlements updated")
ChangeEvent.IDENTITY_CHANGED -> println("Identity changed")
ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> println("Virtual-currency balances changed")
ChangeEvent.REMOTE_CONFIG_CHANGED -> println("Remote config changed")
}
}
}
```
### Unsubscribe when done [#unsubscribe-when-done]
Always clean up your subscription to prevent memory leaks or stale callbacks.
```ts
// Call the function returned by addChangeListener
unsub();
// In a React effect:
useEffect(() => {
const unsub = Rovenue.addChangeListener((event) => {
// handle event
});
return () => unsub(); // cleanup on unmount
}, []);
```
```swift
// Cancel the Task you created in Step 1
task.cancel()
// In a SwiftUI view model, store the task and cancel on deinit:
deinit {
task.cancel()
}
```
```kotlin
// If you collected with launchIn, cancel the resulting Job:
val job = Rovenue.shared.changes
.onEach { event -> /* handle */ }
.launchIn(viewModelScope)
// Cancel explicitly if needed (viewModelScope cancels automatically on ViewModel cleared):
job.cancel()
```
**Kotlin:** collect `changes` from a coroutine scope **you** control (e.g. `viewModelScope`) — not from a scope the SDK owns. Calling `shutdown()` cancels the SDK's internal scope, which also cancels any collector running inside it. See [App lifecycle](/docs/guides/app-lifecycle) for shutdown sequencing.
### Re-read the updated cache after an event [#re-read-the-updated-cache-after-an-event]
A `ChangeEvent` is a signal to re-read — the event itself does not carry the new value. After receiving it, call the corresponding cache read to get the updated data.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const unsub = Rovenue.addChangeListener(async (event) => {
switch (event) {
case 'ENTITLEMENTS_CHANGED': {
const all = await Rovenue.entitlementsAll();
console.log('Active entitlements:', all.filter(e => e.isActive));
break;
}
case 'IDENTITY_CHANGED': {
const user = await Rovenue.currentUser();
console.log('Current user:', user.rovenueId, user.appUserId);
break;
}
case 'VIRTUAL_CURRENCIES_CHANGED': {
const balances = Rovenue.virtualCurrencies();
console.log('New balances:', balances);
break;
}
case 'REMOTE_CONFIG_CHANGED': {
const config = await Rovenue.getRemoteConfig();
console.log('Remote config:', config);
break;
}
}
});
```
```swift
let task = Task {
for await event in Rovenue.shared.changes {
switch event {
case .entitlementsChanged:
let all = await Rovenue.shared.entitlementsAll()
print("Active entitlements:", all.filter { $0.isActive })
case .identityChanged:
let user = await Rovenue.shared.currentUser()
print("Current user:", user.rovenueId, user.appUserId ?? "anonymous")
case .virtualCurrenciesChanged:
let balances = await Rovenue.shared.virtualCurrencyBalances()
print("New balances:", balances)
case .remoteConfigChanged:
let config = await Rovenue.shared.remoteConfigAllJSON()
print("Remote config:", config)
}
}
}
```
```kotlin
viewModelScope.launch {
Rovenue.shared.changes.collect { event ->
when (event) {
ChangeEvent.ENTITLEMENTS_CHANGED -> {
val all = Rovenue.shared.entitlementsAll()
println("Active: ${all.filter { it.isActive }}")
}
ChangeEvent.IDENTITY_CHANGED -> {
val user = Rovenue.shared.currentUser()
println("Current user: ${user.rovenueId} / ${user.appUserId}")
}
ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> {
val balances = Rovenue.shared.virtualCurrencyBalances()
println("New balances: $balances")
}
ChangeEvent.REMOTE_CONFIG_CHANGED -> {
val config = Rovenue.shared.remoteConfigAllJson()
println("Remote config: $config")
}
}
}
}
```
### Project webhook (v1) [#project-webhook-v1]
The project webhook URL receives one signed JSON POST per store notification, plus the deliveries Rovenue originates itself. Exactly two producers write v1 deliveries: every store notification (one per inbound notification), and every Rovenue-originated state change (written in the same transaction as the change). Receipts, restores, server-to-server transfers and CSV imports write no v1 delivery — every receipt-path fact is also announced by the store's own notification for the same transaction. Each body names both the store's own event and the state the purchase is in after Rovenue processed it:
```json
{
"eventType": "DID_CHANGE_RENEWAL_STATUS",
"event": "subscription.uncancelled",
"events": ["subscription.uncancelled"],
"subscriberId": "sub_123",
"purchaseId": "pur_456",
"store": "APPLE",
"productId": "prod_789",
"productIdentifier": "pro_monthly",
"status": "ACTIVE",
"expiresDate": "2030-01-01T00:00:00.000Z",
"autoRenewStatus": true,
"webhookEventId": "whe_abc",
"timestamp": "2026-10-02T12:00:00.000Z"
}
```
* `eventType` is the store's own event, unchanged — or, for a Rovenue-originated delivery, one of `EXPIRATION`, `RECONCILIATION`, `SCHEDULED_CANCEL`, `SCHEDULED_REVOKE`, `MANUAL_GRANT`, `MANUAL_REVOKE`, `OPERATOR_REFUND`.
* `events` names every key the delivery carries, in order: lifecycle keys first, then `subscription.product_changed` / `subscription.offer_redeemed`, then the revenue key(s) (`revenue.`). A recovery delivery reads `event: "subscription.recovered"` with `events: ["subscription.recovered", "revenue.RENEWAL"]`.
* `event` is `events[0]`, or `null` when the delivery names no key. That happens only when the source changed nothing a key describes: Apple `RENEWAL_EXTENDED`, `RENEWAL_EXTENSION`, `PRICE_INCREASE`, `REFUND_DECLINED`, `CONSUMPTION_REQUEST`, `TEST`; Google `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED`, `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED`, `SUBSCRIPTION_PRICE_CHANGE_UPDATED`; or a stale or withheld delivery. A cancel (auto-renew turned off) is not in that list — it delivers `event: "subscription.cancel_requested"` with `autoRenewStatus: false`, and the matching un-cancel delivers `subscription.uncancelled` with `autoRenewStatus: true`.
* `store`, `productId`, `productIdentifier`, `status`, `expiresDate` and `autoRenewStatus` are the purchase's state at delivery time. They are `null` when the notification names no purchase.
* Deliveries are deduplicated per inbound store notification (`webhookEventId`), so a store redelivery is sent once while a cancel and the later un-cancel are two deliveries.
* Deliveries Rovenue originates without a store notification carry `"webhookEventId": null`: the scheduled expiry (`eventType: "EXPIRATION"`, `event: "subscription.expired"`), a scheduled cancellation (`event: "subscription.cancel_requested"`), a comp grant (`eventType: "MANUAL_GRANT"`, `event: "revenue.INITIAL"`), an immediate comp revoke (`eventType: "MANUAL_REVOKE"`, `event: "subscription.expired"`, `events` ending in `revenue.CANCELLATION`) and an operator refund (`eventType: "OPERATOR_REFUND"`). They are sent once per purchase and event, not per store notification — do not use `webhookEventId` as their idempotency key.
Related reads: [Checking entitlements](/docs/guides/entitlements) · [Identifying users](/docs/guides/identifying-users) · [Virtual Currencies](/docs/guides/credits).
# Data Retention Policies (/docs/guides/retention-policies)
A plan's `retentionDays` is not one number for all of a project's data. Each kind of data has its own window: some tables have legal or accounting floors longer than the plan's number, some are removed a whole month at a time, analytics follow their own per-plan window, and customer records are never removed by age at all. This page states what actually happens. The dashboard's **Settings → Billing** page shows the same table, computed from the same source (`packages/shared/src/retention/effective.ts`).
## How long data is kept, per plan [#how-long-data-is-kept-per-plan]
| Data | Free | Indie | Studio | Enterprise |
| ---------------------------------------------------------------------------------------- | ----------------- | ----------------- | ----------------- | ------------------ |
| Webhook, delivery and Rovi logs; import files | 30 days | 90 days | 730 days | 1825 days |
| Audit log (in the database) | 30 days | 90 days | 730 days | 1825 days |
| MCP access log (ClickHouse) | 30 days | 90 days | 730 days | 1825 days |
| Revenue events and credit ledger (database) | at least 365 days | at least 365 days | at least 730 days | at least 1825 days |
| Revenue and credit analytics events (ClickHouse) | 365 days | 365 days | 730 days | 1825 days |
| Funnel sessions and answers | 30 days | 90 days | 730 days | 1825 days |
| Product analytics events (ClickHouse: experiment exposures, SDK sessions, funnel events) | 90 days | 365 days | 730 days | 1825 days |
| Customers, purchases and entitlements | until deleted | until deleted | until deleted | until deleted |
What the figures mean:
* **Floors win.** The plan's window is floored per table: the audit log and the MCP access log at 30 days (the free plan's own audit number is 7), revenue events and the credit ledger — in the database and in ClickHouse — at 365 days, product analytics at 90 days, funnel sessions and answers at 30 days. A plan, or an override, can never take a table below its floor.
* **Revenue and credit figures are minimums.** Those two tables are partitioned by month and the partitions are shared by every project on the instance, so they are removed a whole month at a time, and only at the **longest** window any project on the instance resolves. On a shared instance with a studio or enterprise project, a free project's revenue events live up to that project's window plus one month. See [Partition drops are instance-wide](#partition-drops-are-instance-wide).
* **The audit log is archived, not erased.** Entries past the window are moved out of the database into a proof bundle in the project's object storage, where they stay until the project is deleted. See [What a checkpoint row means](#what-a-checkpoint-row-means).
* **Analytics follow the plan.** The nightly sweep deletes each project's ClickHouse rows past its window (see [ClickHouse tables](#clickhouse-tables)). Product analytics have their own plan number (`analyticsDays`: 90 / 365 / 730 / 1825); ClickHouse revenue and credit events follow `retentionDays` floored at 365, the MCP access log `auditLogDays` floored at 30. Every stored ClickHouse table also carries `TTL + INTERVAL 1825 DAY` as a backstop for the longest window.
* **Exposures of a running experiment are kept.** An experiment that has not stopped (draft, running or paused) keeps all its exposures whatever their age; once it is completed, its old exposures go on the next nightly run.
* **A checkout in progress keeps its session — for 23 hours.** A funnel session whose purchase went `pending` less than 23 hours ago (Stripe expires an unpaid subscription checkout after 23 hours), and its answers, are not removed by age. An older pending purchase is an abandoned checkout and protects nothing: the session and its answers go at their window like any other. A payment that still arrives later (a one-time PaymentIntent has no Stripe expiry) is recorded from the purchase row, which is never removed by age.
* **Plan changes apply on the next nightly run.** Moving to a smaller plan shortens every window from the next run; moving to a larger plan cannot bring back what was already removed.
* **Customer records are never removed by age.** Subscribers, purchases, entitlements (`subscriber_access`), funnel purchases and claim tokens, and every other table without a policy below stay until the project is deleted. One customer can be erased on request: see [Erasure and project deletion](#erasure-and-project-deletion).
* **Removal is nightly.** The sweep runs at 03:00 UTC, so data can outlive its window by up to a day.
* **Backups.** Removed data survives in backups until they expire. The `backup` service takes a daily `pg_dump` and ClickHouse `BACKUP`, encrypted, into a private bucket; at the start of each daily run it deletes every backup that would turn 30 days old before the next run (29 days or older), so no backup is ever older than 30 days and removed data leaves the backups within 30 days. Restoring a backup re-applies every erasure and project deletion completed after it (see [Erasure and project deletion](#erasure-and-project-deletion)). A `backup.sh` snapshot taken by hand is kept until you delete it.
## Which tables have a policy [#which-tables-have-a-policy]
The registry lives at `packages/shared/src/retention/policies.ts` (`RETENTION_POLICIES`). Each entry names a physical table, the column that ages it, a reclaim strategy, the tier field its window comes from, a floor, and — for some tables — a default used when a project has neither a plan nor an override:
| Table | Ages on | Strategy | Window from | Floor | No-plan, no-override default |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------- | --------------- | -------- | ---------------------------- |
| `audit_logs` | `createdAt` | `CHECKPOINT_TRUNCATE` | `auditLogDays` | 30 days | none — kept |
| `credit_ledger` | `createdAt` | `DROP_PARTITION` (monthly) | `retentionDays` | 365 days | none — kept |
| `revenue_events` | `eventDate` | `DROP_PARTITION` (monthly) | `retentionDays` | 365 days | none — kept |
| `outgoing_webhooks` | `createdAt`, terminal rows only | `DELETE_ROWS` | `retentionDays` | 7 days | none — kept |
| `webhook_events` | `createdAt` | `DELETE_ROWS` | `retentionDays` | 7 days | 90 days |
| `copilot_messages` | `createdAt` | `DELETE_ROWS` | `retentionDays` | 7 days | 90 days |
| `integration_deliveries` | `createdAt`, terminal rows only | `DELETE_ROWS` | `retentionDays` | 7 days | 30 days |
| `funnel_answers` | `answeredAt`, not while the session's purchase went pending \< 23 h ago; always with its session (FK cascade) | `DELETE_ROWS` | `retentionDays` | 30 days | 548 days |
| `funnel_sessions` | `lastActivityAt`, not while its purchase went pending \< 23 h ago | `DELETE_ROWS` | `retentionDays` | 30 days | 548 days |
| `import_jobs` (files) | `finishedAt`, terminal jobs only | `EXTERNAL_WORKER` | `retentionDays` | 7 days | 7 days |
| `raw_revenue_events` (ClickHouse) | `eventDate` | `CLICKHOUSE_DELETE` | `retentionDays` | 365 days | none — kept to the TTL |
| `raw_credit_ledger` (ClickHouse) | `createdAt` | `CLICKHOUSE_DELETE` | `retentionDays` | 365 days | none — kept to the TTL |
| `raw_exposures` (ClickHouse) | `exposedAt`, not while the experiment has not stopped | `CLICKHOUSE_DELETE` | `analyticsDays` | 90 days | none — kept to the TTL |
| `raw_sdk_session_events` (ClickHouse) | `occurredAt` | `CLICKHOUSE_DELETE` | `analyticsDays` | 90 days | none — kept to the TTL |
| `raw_funnel_events` (ClickHouse) | `occurredAt` | `CLICKHOUSE_DELETE` | `analyticsDays` | 90 days | none — kept to the TTL |
| `raw_mcp_access` (ClickHouse) | `occurredAt` | `CLICKHOUSE_DELETE` | `auditLogDays` | 30 days | none — kept to the TTL |
`outgoing_webhooks` expires only terminal rows (`SENT`, `DEAD`, `DISMISSED`) and `integration_deliveries` only `succeeded`, `skipped` and `dead_letter` — a row still owed a delivery is never removed for being old. Partitioned `DELETE_ROWS` tables have their emptied partitions dropped afterwards.
The defaults exist only where a table was already removed for every project before this registry existed (a retired worker or pg\_partman's own retention); the registry carries that behaviour forward and invents no new window.
## How a project's window is resolved [#how-a-projects-window-is-resolved]
**Self-hosted instances (`HOST_MODE=self`) follow the enterprise windows.** Plan limits do not apply to self-hosting, so every project resolves against the `enterprise` row of `billing_tier_limits` — 1825 days everywhere — whatever its own billing row says (every dashboard-created project carries a `free` one). An operator shortens a window with an override. On Rovenue Cloud each project follows its own plan; only a project without a billing row — one inserted outside the dashboard, or whose row was deleted — falls through to rules 2–4.
For every (project, table) pair, the sweep applies these rules, in order:
1. **A project with a plan and a matching `billing_tier_limits` row** resolves normally: the plan's window (`retentionDays`, `auditLogDays` or `analyticsDays`, depending on the table), clamped down by any override, floored at the table's minimum. On a self-hosted instance the plan is always `enterprise`.
2. **A project with no plan but an explicit override** uses the override, floored only.
3. **A project with neither falls back to the table's default**, if it has one (see the table above), floored.
4. **A project with neither, on a table with no default, is skipped** for that table: it keeps everything. It is not defaulted to the free plan's window.
5. **A project whose (tier, cycle) has no `billing_tier_limits` row** — a reference-data gap — is skipped with its own reason and a warning, never treated as "no plan".
An override can only **shorten** a window, never lengthen it. There is no dashboard or API surface for overrides yet: an operator inserts a row into `project_retention_overrides` (`projectId`, `tableName`, `retentionDays`).
## Partition drops are instance-wide [#partition-drops-are-instance-wide]
`credit_ledger` and `revenue_events` are partitioned by month, and a partition holds every project's rows for that month. A partition cannot be dropped for one project only, so:
* A partition is dropped only when **every project on the instance has resolved a window** for that table in this run. One project without a resolved window (rules 4 and 5) **blocks the drop for everyone** — those rows are then kept until it is resolved or the project is deleted.
* When every project has resolved a window, the drop uses the **longest** of them, and a partition goes only once its whole month is older than that window. A row therefore lives at least its own project's window, and up to the longest window on the instance plus one month.
* Rows that land in a table's `DEFAULT` partition (no monthly partition existed for their date) are never dropped by age.
Each dropped partition writes one audit row into every affected project's chain before the `DROP TABLE` runs, recording that project's own row count.
## ClickHouse tables [#clickhouse-tables]
The six stored ClickHouse tables are swept by the same nightly job, per project:
* Every project's window is resolved with the rules above. Projects that resolve the **same** window for a table are deleted together — `DELETE … WHERE projectId IN (those projects) AND < cutoff` — one monthly partition at a time, so the first run on years of data is a series of partition-sized deletes rather than one table-wide one. A project with no resolved window is never in a group: its rows stay until the 1825-day TTL.
* The deletes run as the delete-only eraser user. Five tables take a lightweight `DELETE FROM` (the eraser holds `ALTER UPDATE(_row_exists)` on each of them, the row-mask column, and nothing else); `raw_revenue_events` carries a projection, which ClickHouse 24.3 refuses a lightweight delete on, so it takes `ALTER TABLE … DELETE IN PARTITION`. `verify-clickhouse-eraser` checks every statement at install/upgrade.
* If ClickHouse is down or refuses a delete, that table is counted as a failure (`rovenue_retention_sweep_skipped_total{reason="error"}`, a log line, the projects counted failed in the sweep's report) and nothing is recorded as done — the next night repeats the whole delete. Without ClickHouse configured at all the policies are skipped with reason `clickhouse-unconfigured`.
* Dashboard charts are clamped to these windows: a range longer than the project keeps (the Charts page's **All**, a 365-day overview range on a 90-day analytics plan) is cut to the window, and the view says so.
## What a checkpoint row means [#what-a-checkpoint-row-means]
`audit_logs` is an append-only hash chain, so expiring it cannot be a plain delete. `CHECKPOINT_TRUNCATE` does this, for every project whose window has a segment old enough to reclaim:
1. Export the aging segment as a [proof bundle](/docs/guides/audit-proof) — the same assembly the `/proof` dashboard endpoint uses.
2. Write that bundle to object storage and confirm the write.
3. Only then delete the segment's rows.
4. Chain a `retention.audit_checkpointed` row recording the cutoff, the number of rows deleted, the last deleted row's id and hash, and the bundle's storage key.
The bundle lives in the private import bucket (`IMPORT_STORAGE_BUCKET`) at `audit-checkpoints//.json`. **Nothing sweeps that prefix**: the archived entries stay there until the project is deleted, when the purge removes the whole prefix. If object storage is not configured, `CHECKPOINT_TRUNCATE` fails closed and the audit rows stay in the database.
## Import files [#import-files]
Uploaded RevenueCat/Adapty import files and their reports are removed by a separate nightly worker (`workers/import-retention.ts`) once the job's window has passed since it finished. It resolves its window through the same registry entry (`import_jobs`), deletes the objects, and stamps `filesDeletedAt` so a re-run never selects the job again. The `import_jobs` row itself stays.
## Other stores [#other-stores]
| Data | Kept for | What removes it |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------- | -------------------------- |
| Published outbox rows | 72 hours after publishing | hourly outbox cleanup |
| DSAR export files | downloadable 30 days; the file stays until the subscriber is erased or the project is deleted | erasure / project deletion |
| Dashboard accounts, sessions and sign-in methods | until the account is deleted | account deletion |
## Erasure and project deletion [#erasure-and-project-deletion]
**Erasing a customer** (dashboard **Anonymize**, or `POST /v1/dsar/erasure`) replaces their `appUserId` with a pseudonym, clears their attributes, deletes their rows from the ClickHouse tables keyed by subscriber, and deletes their DSAR export files. Their purchases, entitlements and credit-ledger entries stay, attached to the pseudonymous subscriber row. See [DSAR](/docs/guides/dsar).
**Deleting a project** removes its Postgres rows (funnel answers included — they cascade with their sessions), its ClickHouse rows and its storage objects (assets, imports, DSAR exports, audit checkpoint bundles). Its audit log entries stay, with the project link cleared, until the project's audit window (resolved at deletion; 1825 days on a self-hosted instance) has passed; the nightly sweep then removes them.
**After a restore.** Each completed erasure and project deletion leaves a marker in the private import bucket (`deletion-markers/`: the kind, the project id, the subscriber's or purge's internal id and the completion time — no app user id, attribute or email), kept for 37 days (the 30-day backup retention plus a margin). A database restore does not touch object storage, so after restoring a backup the operator runs `replay-deletions`, which re-applies every deletion completed since that backup was taken. See `docs/operations/backup-restore.md`, "Replay deletions".
The sweep itself is a nightly BullMQ repeatable job (`workers/retention-sweep.ts`, 03:00 UTC), safe to run on multiple API replicas — every reclaim is either scoped to the projects it names or, for a partition drop, gated on every project having resolved a window first.
# Introduction (/docs)
Rovenue ships three client SDKs — **Swift** (iOS/macOS/tvOS/watchOS),
**Kotlin** (Android/JVM), and **React Native** — all thin façades over one
shared **Rust core** (`librovenue`). The business logic (HTTP transport with
idempotency + retry, an on-device SQLite cache, foreground polling, receipt
validation, entitlements, credits, identity) lives in the core; each façade
exposes it idiomatically.
## What the SDK does [#what-the-sdk-does]
* Drives the store **purchase** end-to-end: fetches offerings, triggers the
native StoreKit 2 / Play Billing sheet, and validates receipts transparently.
* Checks a user's **entitlements** (cache-first, refreshed in the background).
* Tracks a **credit** balance and spends credits server-side, idempotently.
* Manages **identity** (anonymous → known user).
## What the SDK does NOT do [#what-the-sdk-does-not-do]
* **Bulk merges.** `identify()` auto-transfers a prior install's purchases to
the current device, but programmatic bulk merging (e.g. platform migrations)
is a server operation your backend triggers via the secret-key API.
## Funnels [#funnels]
[Funnels](/docs/funnels) are the one authoring product for everything before
the purchase — multi-screen onboarding, branching, and the paywall in a single
document, built once and served on the [web](/docs/funnels/web/hosting) or
[natively inside your app](/docs/funnels/native/show-a-funnel).
Pick your platform in the [Installation](/docs/getting-started/installation)
guide, then follow the [Quick Start](/docs/getting-started/quickstart).
# Adjust (/docs/integrations/adjust)
# Adjust [#adjust]
The Adjust integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to Adjust's server-to-server event API, so subscription revenue is attributed back to the install-time campaign, network, and cohort that drove it.
Unlike Amplitude, Mixpanel, and AppsFlyer, Adjust has **no built-in default event mapping** — every event you want forwarded needs its own Adjust event token, configured in the drawer's mapping step.
***
## 1. Get your credentials from Adjust [#1-get-your-credentials-from-adjust]
1. Open the **Adjust dashboard → App Settings** for the app you want Rovenue events attributed to.
2. Copy the **App token**.
3. For each Rovenue event you want to forward, create an **event** in Adjust (Adjust dashboard → Events) and copy its **event token** (a short alphanumeric string, e.g. `f0ob4r`). You'll paste these into Rovenue's mapping step in step 3 below.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Adjust** under **Attribution & MMP** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **App token** — paste the app token from step 1.
4. Click **Validate**.
> **Validate is shape-only.** Adjust's S2S event API is a write-only ingestion endpoint with no dedicated credential-check probe — there is no way to confirm an app token is *correct* without sending a real event with a real event token. Validate here only confirms the form is well-formed (app token present). The connection's **Delivery Log** (in the drawer's Deliveries tab) is the actual proof: trigger a real event and confirm it shows `succeeded` with an HTTP `200`.
5. Choose which events to forward.
6. On the **Mapping** step, enter the Adjust **event token** for each event you selected — the value you type there is sent verbatim as `event_token`. An event left blank on this step is never forwarded (skipped with `no_mapping`), since Adjust has no vendor-wide event vocabulary to fall back to.
***
## 3. Event mapping [#3-event-mapping]
`eventMapping[key].eventName` **is** the Adjust event token — there's no vendor name translation layer. Rovenue's public event keys are available for mapping, but none of them has a default:
| Rovenue event key | Adjust `event_token` |
| ------------------------------- | -------------------- |
| `revenue.INITIAL` | *(your own token)* |
| `revenue.TRIAL_CONVERSION` | *(your own token)* |
| `revenue.RENEWAL` | *(your own token)* |
| `revenue.CREDIT_PURCHASE` | *(your own token)* |
| `revenue.REFUND` | *(your own token)* |
| `revenue.CANCELLATION` | *(your own token)* |
| `subscription.trial.started` | *(your own token)* |
| `subscription.cancel_requested` | *(your own token)* |
| `subscription.expired` | *(your own token)* |
| `subscription.billing_issue` | *(your own token)* |
| `subscription.grace_period` | *(your own token)* |
| `subscription.uncancelled` | *(your own token)* |
| `subscription.product_changed` | *(your own token)* |
| `subscription.paused` | *(your own token)* |
| `subscription.recovered` | *(your own token)* |
| `subscription.revoked` | *(your own token)* |
| `subscription.offer_redeemed` | *(your own token)* |
Every forwarded event carries `deduplication_id` and `callback_params.outbox_event_id` (Rovenue's internal `outboxEventId`, for dedup — see below) and `callback_params.product_id` when known. For the six `revenue.*` keys only, it also carries `revenue`/`currency`; subscription-lifecycle events never carry revenue fields — they represent no money movement.
`revenue` is always sent as a **positive** number, including for `revenue.REFUND` — Adjust's S2S event API documents no convention for representing a refund as negative revenue (same finding as AppsFlyer).
***
## 4. Identity resolution [#4-identity-resolution]
Every forwarded event requires one advertising/device id. Rovenue resolves it in this order:
1. **`$adjustId`** (Adjust's own `adid`) — sent as `adid`.
2. **`$idfa`** (iOS advertising identifier) — sent as `idfa`, only if `$adjustId` is absent.
3. **`$gpsAdId`** (Android advertising identifier) — sent as `gps_adid`, only if both of the above are absent and the [advertising-id gate](/docs/integrations/subscriber-identity-attributes#advertising-ids) lets it through, which it currently never does.
If none of the three survive, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log). Advertising ids are default-deny: `$idfa` is sent only when the subscriber's `$attConsentStatus` is `authorized`, and `$gpsAdId` is never sent, because no Android opt-in signal exists. Without an authorized IDFA, this fallback narrows to `$adjustId`, so set it from the Adjust SDK to keep those users attributed.
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$adjustId`.
***
## 5. Deduplication [#5-deduplication]
Every forwarded event's `deduplication_id` and `callback_params.outbox_event_id` are both set to Rovenue's internal `outboxEventId`.
`deduplication_id` is **not** an enumerated parameter in Adjust's published S2S events reference — Adjust's dedup behaviour for S2S events is undocumented there. Rovenue sends it as a top-level echo of the same id already carried in `callback_params.outbox_event_id`, on the expectation that an unrecognised parameter is ignored safely; if Adjust does act on it, retries after a network failure or an at-least-once outbox replay dedupe at Adjust as well. Either way, Rovenue's own guarantees (an at-least-once outbox plus query-time idempotent revenue views) do not depend on Adjust honouring the field, and `callback_params.outbox_event_id` is always available in Adjust's raw-data exports for dedup on your side.
***
## 6. Verify events are arriving [#6-verify-events-are-arriving]
1. Open **Apps → Adjust → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `200`.
2. Cross-check in Adjust's own **raw data exports** or **Datascape**, filtered to one of the event tokens above.
3. If events are not appearing:
* Confirm the event has a token configured in the connection's mapping step — a blank mapping skips with `no_mapping`.
* Confirm the subscriber has one of `$adjustId`/`$idfa`/`$gpsAdId` set (missing all three skips with `no_user_data`).
* A `401`/`403` response means the app token is wrong or belongs to a different Adjust account.
* Ensure the event type you're sending is included in the connection's enabled events.
# Airbridge (/docs/integrations/airbridge)
# Airbridge [#airbridge]
The Airbridge integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to Airbridge's **Server-to-Server In-App Events API**, so purchases and subscription-lifecycle changes made in your app show up as Airbridge events, attributable back to the ad campaign that acquired the user.
Airbridge is a mobile attribution / marketing-measurement platform, not an analytics warehouse: this integration sends **events, not a full profile sync**. It does not push user properties and does not read anything back from Airbridge.
***
## Wire contract [#wire-contract]
Every forwarded event is a `POST` to:
```
https://api.airbridge.io/events/v2/apps/{app_name}/mobile-app/9360
```
authenticated with:
```
Authorization: Bearer YOUR_API_TOKEN
```
`{app_name}` is your Airbridge app's slug (the credential you paste below), used as a URL path segment. Each event carries `device.deviceUUID` (the subscriber's Airbridge Device ID — see [Identity resolution](#identity-resolution) below), `eventData.goal.category` (the mapped event name), and `eventData.goal.semanticAttributes.transactionID` set to Rovenue's own `outboxEventId`, so every Airbridge event traces back to its Rovenue source.
**Retries are deduplicated.** Every event also carries an `eventUUID`, derived deterministically (SHA-256, formatted as a UUID4) from that same `outboxEventId`. Rovenue's delivery queue is at-least-once, so the same event can be sent more than once after a network error or a `5xx` — a stable `eventUUID` is what lets Airbridge collapse those retries instead of counting the revenue twice.
**Set your package name.** Airbridge's contract also requires `app.packageName` — your app's OS bundle id (`com.example.app`) / Android package name. Rovenue sends the optional **Package name / bundle id** credential when you fill it in. Leaving it blank falls back to sending your `app_name` slug in its place, which is only correct for accounts whose Airbridge app slug happens to equal the bundle id — **setting the real value is strongly recommended**, and events may not attribute correctly without it.
***
## 1. Get your credentials from Airbridge [#1-get-your-credentials-from-airbridge]
1. In the Airbridge dashboard, open **Settings → Tokens**.
2. Copy your **App name** (the app's unique slug/ID).
3. Copy your **API Token**.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Airbridge** under **Attribution** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **App name** — paste the app slug from step 1.
* **API token** — paste the token from step 1.
* **Package name / bundle id (optional)** — your app's OS bundle id / Android package name (`com.example.app`). Optional, but strongly recommended: see [Wire contract](#wire-contract) above for what happens when it's blank.
4. Click **Validate**. Airbridge has no zero-footprint endpoint that verifies an `app_name` + `api_token` pair together (see [Validation](#validation) below), so this only checks that both fields are filled in — it sends nothing to Airbridge.
5. Choose which events to forward, optionally override the default mapping, then activate.
***
## 3. Event mapping [#3-event-mapping]
Every forwarded key becomes an Airbridge event category, named per the mapping table below (override any name in the drawer's mapping step). Six keys map onto Airbridge's own standard event vocabulary; the rest use a Rovenue-namespaced custom category name, since Airbridge has no built-in equivalent:
| Rovenue event key | Default Airbridge category |
| ------------------------------- | --------------------------------------- |
| `revenue.INITIAL` | `airbridge.subscribe` |
| `revenue.TRIAL_CONVERSION` | `airbridge.subscribe` |
| `revenue.RENEWAL` | `airbridge.ecommerce.order.completed` |
| `revenue.CREDIT_PURCHASE` | `airbridge.ecommerce.order.completed` |
| `revenue.REFUND` | `airbridge.ecommerce.order.canceled` |
| `revenue.CANCELLATION` | `airbridge.unsubscribe` |
| `subscription.trial.started` | `airbridge.startTrial` |
| `subscription.cancel_requested` | `rovenue_subscription_cancel_requested` |
| `subscription.expired` | `rovenue_subscription_expired` |
| `subscription.billing_issue` | `rovenue_subscription_billing_issue` |
| `subscription.grace_period` | `rovenue_subscription_grace_period` |
| `subscription.uncancelled` | `rovenue_subscription_uncancelled` |
| `subscription.product_changed` | `rovenue_subscription_product_changed` |
| `subscription.paused` | `rovenue_subscription_paused` |
| `subscription.recovered` | `rovenue_subscription_recovered` |
| `subscription.revoked` | `rovenue_subscription_revoked` |
| `subscription.offer_redeemed` | `rovenue_subscription_offer_redeemed` |
**On `revenue.REFUND`:** Airbridge documents a dedicated "Order Cancel" (`airbridge.ecommerce.order.canceled`) standard event. Its own guide frames the example around cancelling an order before fulfillment rather than a post-purchase refund specifically, so this is not a perfect semantic match — but it's Airbridge's own, only standard event for reversing a completed order's revenue, and mapping it (rather than dropping `revenue.REFUND`, as some other Rovenue integrations do when their vendor has no reversal convention at all) avoids leaving a refunded purchase permanently double-counted in Airbridge's own revenue/LTV rollups.
Every forwarded event carries `eventData.goal.customAttributes.rovenue_event` (the original Rovenue event key), so you can always trace an Airbridge event back to its Rovenue source.
**Amount/currency are included only when both are present and valid.** Airbridge's own "Order Complete" guide recommends always sending a currency with a purchase event — but Rovenue never guesses one (it does **not** default to `"USD"`). When a revenue event has an amount but no known currency (or an unparseable amount), the event is still forwarded — it's still useful for attribution — just without `eventData.goal.value`/`semanticAttributes.currency`.
***
## Identity resolution [#identity-resolution]
Every event needs an Airbridge Device ID. Rovenue requires the reserved subscriber attribute **`$airbridgeDeviceId`** — Airbridge's own device identifier (accepts a GAID, IDFA, IDFV, App Set ID, or a randomly generated UUID, per Airbridge's SDK). Set it on the subscriber via `setAttributes` alongside your other reserved identity attributes, once your app's Airbridge SDK has resolved a device id.
If `$airbridgeDeviceId` is not set on a subscriber, every event for that subscriber is skipped with reason `no_user_data` (visible in the connection's Delivery Log) — there is no fallback to a Rovenue-internal id, since a Rovenue id is not a valid Airbridge Device ID.
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$airbridgeDeviceId`.
***
## Validation [#validation]
Unlike OneSignal or Iterable (which have a dedicated, app-scoped, read-only credential-check endpoint), Airbridge's only genuinely zero-footprint `GET` endpoints (its Tracking Link list APIs) belong to an unrelated feature and aren't scoped by `app_name` — a successful response there would only prove the `api_token` authenticates, never that it's paired with *this* connection's `app_name`. Sending a real event to prove the pair together would itself be a live, attributable write to a real Airbridge app.
So, like the AppsFlyer integration, clicking **Validate** here only checks that both fields are filled in — **the first real event delivery is the live proof**. Check the connection's Delivery Log after activating to confirm events are landing.
***
## Verify events are arriving [#verify-events-are-arriving]
**Airbridge discards events older than 24 hours.** If an event's timestamp is more than a day in the past, Airbridge drops it *server-side* and still answers `2xx` — so Rovenue's Delivery Log shows a green `succeeded` row for an event that never landed. This is Airbridge's rule, not Rovenue's, and nothing in Rovenue can widen the window.
The practical consequence is for **backfill**: Rovenue lets you backfill historical events into any connection, but for Airbridge only the portion of the window inside the last 24 hours will actually arrive. Anything older is a silent no-op — green rows, nothing delivered. Backfill an Airbridge connection only for a recent window, and treat older ranges as unavailable rather than as a delivery failure to debug.
1. Open **Apps → Airbridge → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `2xx`.
2. In the Airbridge dashboard, look up the device by its Airbridge Device ID and check its event timeline for the mapped category names above.
3. If events are not appearing:
* A `401`/`403` response means the API token is wrong or doesn't belong to the configured app.
* A `400` response usually means a malformed request — check the Delivery Log's response body.
* A `429` response means you're being rate-limited; Rovenue retries these automatically, as it does `5xx` responses.
* Confirm the subscriber has `$airbridgeDeviceId` set (missing it skips silently with `no_user_data`).
* Ensure the event type you're sending is included in the connection's enabled events.
# Amplitude (/docs/integrations/amplitude)
# Amplitude [#amplitude]
The Amplitude integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to Amplitude's HTTP API v2, so subscription behavior shows up alongside the rest of your product analytics.
***
## 1. Get your API key from Amplitude [#1-get-your-api-key-from-amplitude]
1. Open your Amplitude project's **Settings → Projects**.
2. Select the project you want Rovenue events to land in.
3. Copy the **API Key** shown under **General**.
4. Note your project's **data residency** (US or EU) — it's set at project creation and shown on the same settings page.
> The API key only grants event-ingestion write access; it is not a secret in the same sense as an API secret key, but treat it as sensitive.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Amplitude** under **Product analytics** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **API key** — paste the API key from step 1.
* **Region** — `us` (default) or `eu`, matching your project's data residency. Leave blank for `us`.
4. Click **Validate** — Rovenue sends a minimal, clearly-tagged probe event (`[Rovenue] Credential Check`) to Amplitude's ingestion endpoint to confirm the API key is accepted.
5. Choose which events to forward, optionally override the default vendor event names, then activate.
***
## 3. Event mapping [#3-event-mapping]
Amplitude has no built-in "purchase" event — Rovenue's public event keys are forwarded as your Amplitude `event_type`, using the following default names (overridable per-connection in the drawer's mapping step):
| Rovenue event key | Amplitude `event_type` |
| ------------------------------- | ---------------------- |
| `revenue.INITIAL` | `purchase_initial` |
| `revenue.TRIAL_CONVERSION` | `trial_conversion` |
| `revenue.RENEWAL` | `renewal` |
| `revenue.CREDIT_PURCHASE` | `credit_purchase` |
| `revenue.REFUND` | `refund` |
| `revenue.CANCELLATION` | `cancellation` |
| `subscription.trial.started` | `trial_started` |
| `subscription.cancel_requested` | `cancel_requested` |
| `subscription.expired` | `subscription_expired` |
| `subscription.billing_issue` | `billing_issue` |
| `subscription.grace_period` | `grace_period` |
| `subscription.uncancelled` | `uncancelled` |
| `subscription.product_changed` | `product_changed` |
| `subscription.paused` | `paused` |
| `subscription.recovered` | `recovered` |
| `subscription.revoked` | `revoked` |
| `subscription.offer_redeemed` | `offer_redeemed` |
Every forwarded event carries `event_properties.rovenue_event` set to the original Rovenue event key (e.g. `revenue.RENEWAL`), so you can always trace an Amplitude event back to its Rovenue source regardless of how the vendor event name has been overridden, plus `event_properties.product_id`.
### Revenue fields [#revenue-fields]
For the six `revenue.*` keys only, Rovenue also populates Amplitude's [revenue-tracking fields](https://amplitude.com/docs/apis/analytics/http-v2): `revenue`, `price` (both equal to the event's amount), `quantity` (always `1`), `revenueType` (set to the Rovenue event key), and `currency`. Subscription-lifecycle events never carry these fields — they represent no money movement.
**`revenue.REFUND` is sent with a NEGATIVE `revenue`/`price`**, per Amplitude's own convention for representing refunds in revenue reports. Rovenue's internal ledger always stores refund amounts as positive; the sign flip happens only at the Amplitude wire boundary.
***
## 4. Identity resolution [#4-identity-resolution]
Amplitude requires either a `user_id` or a `device_id` on every event. Rovenue resolves `user_id` using this fallback chain, in order:
1. `$amplitudeUserId` — the subscriber attribute you should set once you've called Amplitude's own `identify()`/`setUserId()` on the client, so revenue events join the correct identified Amplitude user.
2. `appUserId` — your app's own subscriber id, if the subscriber has one.
3. The Rovenue-internal subscriber id, as a last resort so revenue is never silently dropped for lack of an explicit Amplitude identity.
`device_id` is populated only from `$amplitudeDeviceId`, and omitted otherwise (Amplitude derives its own hashed device id from `user_id` when `device_id` is absent).
Set these via the SDK's `setAttributes` call. See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$amplitudeUserId` and `$amplitudeDeviceId`.
If neither `$amplitudeUserId`, `appUserId`, nor a subscriber id is available, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
***
## 5. Deduplication [#5-deduplication]
Every forwarded event carries `insert_id` set to Rovenue's internal `outboxEventId`. Amplitude ignores subsequent events with the same `insert_id` on the same `device_id` within a 7-day window, so retries after a network failure or an at-least-once outbox replay do not double-count revenue.
***
## 6. Verify events are arriving [#6-verify-events-are-arriving]
1. Open your project in the Amplitude web app and use **User Lookup** or a live funnel/chart filtered to one of the vendor event names above.
2. Trigger a real event from your app (a renewal, a trial start, etc.) — Amplitude typically shows events within a minute.
3. If events are not appearing:
* Verify the API key and region are correct (use **Validate** in the drawer — a `us`/`eu` mismatch sends events to the wrong data center, where they will never appear).
* Check **Apps → Amplitude → Delivery Log** in Rovenue for HTTP status codes and error messages.
* Ensure the event type you're sending is included in the connection's enabled events.
* A `400` response with `"Invalid API key"` means the credentials are wrong or belong to a different Amplitude project than you expect.
# Apple external purchases (/docs/integrations/apple-external-purchase)
Apple sends an `EXTERNAL_PURCHASE_TOKEN` notification when a purchase happens
through an external purchase link — the EU DMA and US link-entitlement flows.
Rovenue records it, and this page is explicit about how little that means.
## What the notification actually contains [#what-the-notification-actually-contains]
```
externalPurchaseToken: {
externalPurchaseId?: string
tokenCreationDate?: number
appAppleId?: number
}
```
Apple documents `data`, `summary` and `externalPurchaseToken` as **mutually
exclusive** — a payload carries exactly one. So an `EXTERNAL_PURCHASE_TOKEN`
notification has **no transaction, no product, no price, and nothing that
identifies one of your users**. `appAppleId` is your app; `externalPurchaseId`
is Apple's opaque id for the purchase.
It tells you *a* purchase happened in your app. It does not tell you whose.
## What Rovenue does [#what-rovenue-does]
Records the token against the project, keyed by `externalPurchaseId` so a
redelivery is idempotent.
## What Rovenue deliberately does not do [#what-rovenue-deliberately-does-not-do]
* **No subscriber is attached.** There is nothing in the payload to attach it
to, and inferring one from timing or from the most recent active user would
produce attribution that looks authoritative and is a guess.
* **No revenue is recorded.** Apple did not process this purchase and does not
send its amount. A placeholder figure would flow into MRR, LTV and proceeds
and corrupt all three.
* **No integration event is emitted.** Rovenue's subscription event stream is
keyed by subscriber, and there is no subscriber here.
If you want an external purchase to affect a user's entitlements or your
revenue reporting, report it yourself: your own checkout knows which user
bought and what they paid, and that is the only place both facts exist.
## Not implemented [#not-implemented]
* The **External Purchase Server API** — Rovenue does not report tokens back
to Apple on your behalf, and does not track Apple's reporting deadlines.
* **Per-region commission accounting** for external purchases.
## How this was verified [#how-this-was-verified]
Against Apple's documented payload shape and the decoder in
`@apple/app-store-server-library`, **not against Apple**. Rovenue's development
environment has no external-purchase entitlement, so this path has not been
exercised end to end with a live notification. Treat it accordingly, and tell
us what you see if you have the entitlement and can.
# AppsFlyer (/docs/integrations/appsflyer)
# AppsFlyer [#appsflyer]
The AppsFlyer integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to AppsFlyer's server-to-server in-app-event API, so subscription revenue is attributed back to the install-time campaign, media source, and cohort that drove it.
***
## 1. Get your credentials from AppsFlyer [#1-get-your-credentials-from-appsflyer]
1. Open **AppsFlyer → App settings → App settings (General)** for the app you want Rovenue events attributed to.
2. Copy the **Dev key** — a single dev key is shared across all of an account's apps.
3. Note the app's **App ID**:
* iOS: the numeric **App Store ID** (e.g. `1234567890`).
* Android: the **package name** (e.g. `com.yourcompany.yourapp`).
If you have both an iOS and an Android app sharing this Rovenue project, note both app ids — Rovenue picks the right one per event automatically (see [App id selection](#3-app-id-selection) below).
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **AppsFlyer** under **Attribution & MMP** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Dev key** — paste the dev key from step 1.
* **iOS app ID** *(optional)* — the App Store ID, if you have an iOS app.
* **Android app ID** *(optional)* — the package name, if you have an Android app.
* At least one of the two app ids is required; leave the other blank if you only ship on one platform.
4. Click **Validate**.
5. Choose which events to forward, optionally override the default `af_`-prefixed vendor event names, then activate.
> **Validate is shape-only.** AppsFlyer's S2S in-app-event API has no dedicated credential-check endpoint — there is no way to confirm a dev key or app id is *correct* without sending a real, attributable event. Validate here only confirms the form is well-formed (dev key present, at least one app id present). The connection's **Delivery Log** (in the drawer's Deliveries tab) is the actual proof: trigger a real event and confirm it shows `succeeded` with an HTTP `200`.
***
## 3. App id selection [#3-app-id-selection]
AppsFlyer's in-app-event endpoint is per-app (`/inappevent/{appId}`), so Rovenue needs to pick exactly one app id per event when a connection has both configured:
1. **Both app ids configured, platform known** — the subscriber's first-install `platform` attribute (`ios` or `android`, set automatically by the SDK) selects the matching app id.
2. **Only one app id configured** — that one is used for every event, regardless of platform. This is the common case for a single-platform app.
3. **Both app ids configured, but platform is absent, unrecognized, or `web`** — there's no basis to choose, so the event is skipped with reason `no_platform_app_id` rather than guessing which app it belongs to.
***
## 4. Event mapping [#4-event-mapping]
AppsFlyer's `af_`-prefixed vocabulary is its own documented in-app-event set. Rovenue's public event keys map to it by default (overridable per-connection in the drawer's mapping step):
| Rovenue event key | AppsFlyer `eventName` |
| ------------------------------- | ------------------------- |
| `revenue.INITIAL` | `af_purchase` |
| `revenue.TRIAL_CONVERSION` | `af_subscribe` |
| `revenue.RENEWAL` | `af_subscription_renewal` |
| `revenue.CREDIT_PURCHASE` | `af_credit_purchase` |
| `revenue.REFUND` | `af_refund` |
| `revenue.CANCELLATION` | `af_cancel` |
| `subscription.trial.started` | `af_start_trial` |
| `subscription.cancel_requested` | `af_cancel_requested` |
| `subscription.expired` | `af_subscription_expired` |
| `subscription.billing_issue` | `af_billing_issue` |
| `subscription.grace_period` | `af_grace_period` |
| `subscription.uncancelled` | `af_uncancel` |
| `subscription.product_changed` | `af_product_change` |
| `subscription.paused` | `af_paused` |
| `subscription.recovered` | `af_recovered` |
| `subscription.revoked` | `af_revoked` |
| `subscription.offer_redeemed` | `af_offer_redeemed` |
Every forwarded event's `eventValue` JSON always carries `af_order_id` (Rovenue's internal `outboxEventId`, for dedup — see below) and `product_id` when known. For the six `revenue.*` keys only, it also carries `af_revenue` (the event's amount) and `af_currency`; the top-level `eventCurrency` field is set the same way. Subscription-lifecycle events never carry revenue fields — they represent no money movement.
`af_revenue` is always sent as a **positive** number, including for `revenue.REFUND` — AppsFlyer's in-app-event API has no documented convention for representing a refund as negative revenue (unlike Amplitude/Mixpanel).
***
## 5. Identity resolution [#5-identity-resolution]
Every forwarded event requires an `appsflyer_id` — AppsFlyer's own device-level identifier. Rovenue reads it from the **`$appsflyerId`** subscriber attribute; there is no fallback chain, since a Rovenue-internal id or your own app-user id has no meaning inside AppsFlyer's attribution graph.
Set it from your host app once you have the SDK's AppsFlyer UID (typically available from AppsFlyer's own SDK's `getAppsFlyerUID` / `onConversionDataSuccess` callback):
```
setAttributes({ "$appsflyerId": appsFlyerUID })
```
If `$appsflyerId` is not set, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
`customer_user_id` is populated from your app's own subscriber id (`appUserId`) when the subscriber has one, so you can cross-reference an AppsFlyer event back to your own user records — it's optional and omitted when not available.
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$appsflyerId`.
***
## 6. Deduplication [#6-deduplication]
Every forwarded event's `eventValue.af_order_id` is set to Rovenue's internal `outboxEventId`. Combined with AppsFlyer's own event-level deduplication, retries after a network failure or an at-least-once outbox replay do not double-count revenue.
***
## 7. Verify events are arriving [#7-verify-events-are-arriving]
1. Open **Apps → AppsFlyer → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `200`.
2. Cross-check in AppsFlyer's own **Raw data** / **Postbacks** view, filtered to one of the vendor event names above.
3. If events are not appearing:
* Confirm the subscriber has `$appsflyerId` set (missing it skips silently with `no_user_data`).
* If both app ids are configured, confirm the subscriber's platform is known — an unknown/absent/`web` platform with both ids configured skips with `no_platform_app_id`.
* A `401`/`403` response means the dev key is wrong, or belongs to a different AppsFlyer account than the app id.
* Ensure the event type you're sending is included in the connection's enabled events.
# Braze (/docs/integrations/braze)
# Braze [#braze]
The Braze integration forwards Rovenue revenue events (purchases, trial conversions, renewals, cancellations) as Braze **purchases** and subscription-lifecycle events (trials, grace period, billing issues, plan changes) as Braze **custom events**, via Braze's `users/track` REST endpoint — so a subscriber's Rovenue purchase and subscription history is available inside Braze for lifecycle campaigns, canvases, and segmentation.
Braze is a customer-engagement platform, not an analytics warehouse: this integration sends **events, not a full profile sync**. It does not push subscriber attributes, and it does not read anything back from Braze.
***
## 1. Get your credentials from Braze [#1-get-your-credentials-from-braze]
1. In the Braze dashboard, open **Settings → APIs and Identifiers → API Keys** and create (or reuse) a key with the **`users.track`** permission.
2. Copy the **REST API Key**.
3. Note your workspace's **REST endpoint** — shown on the same page, or in **Settings → APIs and Identifiers** (e.g. `https://rest.iad-01.braze.com`). This is specific to the Braze instance/cluster your workspace lives on; it is *not* the same as your SDK endpoint.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Braze** under **CRM & lifecycle** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **REST API key** — paste the key from step 1.
* **REST endpoint** — paste the endpoint from step 1 (e.g. `https://rest.iad-01.braze.com`). Only `https://rest..braze.com` and `https://rest..braze.eu` hosts are accepted — Rovenue rejects any other host, both when you save the connection and again on every delivery.
4. Click **Validate**.
> **Validate creates a real, deletable probe profile.** Braze's `users/track` endpoint has no dedicated read-only credential-check call that would prove your key actually has `users.track` permission — a read-only check would pass even for a key that can't send events. Rovenue instead sends one clearly-tagged event (`rovenue_credential_check`) to a single, stable Braze profile (`external_id: "rovenue-credential-probe"`). This profile is reused across every re-validation — clicking **Validate** again updates the same profile and event rather than creating a new one, so it counts toward your Braze Monthly Active Users **once**, not on every click. You can safely delete this profile from Braze at any time; doing so does not affect your connection.
5. Choose which events to forward, optionally override the default mapping, then activate.
***
## 3. Event mapping [#3-event-mapping]
Revenue keys ride Braze's `purchases` array, which has no vendor event-name concept of its own (a purchase is keyed by `product_id`, not a name) — so the mapping table below only matters for the `rovenue_event` tag stamped on each purchase, and for the six `subscription.*` lifecycle keys, which become genuine Braze custom events since Braze has no built-in vocabulary for subscription lifecycle:
| Rovenue event key | Braze wire shape | `name` / tag |
| ------------------------------- | ---------------- | --------------------------------------- |
| `revenue.INITIAL` | `purchases[]` | `revenue.INITIAL` |
| `revenue.TRIAL_CONVERSION` | `purchases[]` | `revenue.TRIAL_CONVERSION` |
| `revenue.RENEWAL` | `purchases[]` | `revenue.RENEWAL` |
| `revenue.CREDIT_PURCHASE` | `purchases[]` | `revenue.CREDIT_PURCHASE` |
| `revenue.CANCELLATION` | `purchases[]` | `revenue.CANCELLATION` |
| `subscription.trial.started` | `events[]` | `rovenue_subscription_trial_started` |
| `subscription.cancel_requested` | `events[]` | `rovenue_subscription_cancel_requested` |
| `subscription.expired` | `events[]` | `rovenue_subscription_expired` |
| `subscription.billing_issue` | `events[]` | `rovenue_subscription_billing_issue` |
| `subscription.grace_period` | `events[]` | `rovenue_subscription_grace_period` |
| `subscription.uncancelled` | `events[]` | `rovenue_subscription_uncancelled` |
| `subscription.product_changed` | `events[]` | `rovenue_subscription_product_changed` |
| `subscription.paused` | `events[]` | `rovenue_subscription_paused` |
| `subscription.recovered` | `events[]` | `rovenue_subscription_recovered` |
| `subscription.revoked` | `events[]` | `rovenue_subscription_revoked` |
| `subscription.offer_redeemed` | `events[]` | `rovenue_subscription_offer_redeemed` |
Every forwarded purchase and event carries `properties.rovenue_event` (the original Rovenue event key) and `properties.outbox_event_id`, so you can always trace a Braze record back to its Rovenue source.
**A purchase is only sent when both a currency and a valid amount are known.** Rovenue never guesses a currency (it does **not** default to `"USD"`). Braze's purchase object *requires* `currency` and `price`, so unlike the OneSignal/Airbridge/Singular integrations there is no "send the event without the money" option on a purchase. Instead, when a revenue event has no known currency (or an unparseable amount), Rovenue forwards it through Braze's **custom events** array rather than `purchases` — named `rovenue_revenue_INITIAL`, `rovenue_revenue_RENEWAL`, and so on, carrying the same `properties.rovenue_event` / `properties.outbox_event_id` tags. The event still reaches Braze and can still trigger a Canvas; it just carries no dollar figure. This matters more here than elsewhere: `purchases` is append-only with no reversal convention, so a purchase mislabeled `USD` could never be corrected.
**`revenue.REFUND` is not forwarded.** Braze's `purchases` object (`product_id`/`currency`/`price`/`time`) is an append-only revenue record with no documented reversal or negative-price refund convention — sending a refund through it would double-count revenue in Braze's own reporting rather than reverse it. Refunds are skipped with reason `no_mapping` and never reach Braze.
***
## 4. Identity resolution [#4-identity-resolution]
Every purchase or event object sent to Braze needs exactly one primary identifier (Braze rejects an object carrying more than one). Rovenue resolves it in this order:
1. **`appUserId`** — your app's own subscriber id, if the subscriber has one. Sent as `external_id`.
2. **`$brazeAliasName`** — used **instead of** `external_id`, only when `appUserId` is absent. Sent as `user_alias: { alias_name, alias_label: "rovenue" }`, so you can resolve a subscriber to an existing Braze user profile without ever setting an external id.
3. The Rovenue-internal subscriber id, as a last resort so revenue is never silently dropped. Sent as `external_id`.
If none of the three is available, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$brazeAliasName`.
***
## 5. Verify events are arriving [#5-verify-events-are-arriving]
1. Open **Apps → Braze → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `200`/`201`.
2. In Braze, look up the resolved user profile (by `external_id` or alias) and check its **Purchases** or **Custom Events** timeline for the mapped names above.
3. If events are not appearing:
* A `401`/`403` response means the REST API key is wrong or lacks the `users.track` permission.
* A `400` response usually means a malformed request — check the Delivery Log's response body.
* Confirm the subscriber has `appUserId`, `$brazeAliasName`, or a resolvable Rovenue subscriber id set (missing all three skips silently with `no_user_data`).
* Ensure the event type you're sending is included in the connection's enabled events, and that `revenue.REFUND` isn't the only thing you're testing with — it's intentionally never forwarded (see above).
# Discord (/docs/integrations/discord)
# Discord [#discord]
The Discord integration posts a short, human-readable message to a Discord channel for every Rovenue event you enable — revenue events (purchases, trial conversions, renewals, refunds, cancellations), subscription-lifecycle events (trials, grace period, billing issues, plan changes), funnel-session events (started, advanced, paid, completed, abandoned, dismissed, custom actions), and credit-ledger events. It's a notification channel, not a data pipeline — like Slack, it subscribes to **all four** Rovenue fanout topics and every one of the 30 public event keys, since there's no reason to scope a Discord alert to a single event family the way an analytics or attribution tool would. The message builder is the exact same one Slack uses (`buildChatMessageText`), so both channels format events identically — only the outer envelope differs (`{ "text": "..." }` for Slack, `{ "content": "..." }` for Discord).
***
## 1. Create an incoming webhook in Discord [#1-create-an-incoming-webhook-in-discord]
1. In Discord, open the server settings for the channel you want events posted to, go to **Integrations → Webhooks**, and click **New Webhook** (or reuse an existing one) — see [Discord's Execute Webhook documentation](https://discord.com/developers/docs/resources/webhook#execute-webhook) if you haven't done this before.
2. Name the webhook and confirm the target channel.
3. Click **Copy Webhook URL** — it looks like `https://discord.com/api/webhooks/123456789012345678/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.
> Treat this URL as a secret: anyone who has it can post messages to your channel.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Discord** under **Communication & alerts** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Incoming webhook URL** — paste the URL from step 1.
4. Click **Validate**.
> **Validate posts a real message.** Unlike a zero-footprint credential check, Rovenue confirms a Discord incoming webhook by actually posting `Rovenue connected ✅` to the configured channel — the same way you'd confirm a Discord integration by hand. This is intentional (it's the standard way to prove a Discord webhook works) but it means clicking **Validate** again posts the message again; it is not deduplicated the way Amplitude's/Mixpanel's credential probes are.
5. Choose which events to forward, optionally review the mapping step, then activate.
***
## 3. Event mapping [#3-event-mapping]
Discord has no vendor-specific event vocabulary of its own — every one of Rovenue's 30 public event keys, including the seven `funnel.session.*` keys, maps to itself. The mapping step exists only if you want to relabel what shows up in the connection's Delivery Log; it has no effect on the Discord message content.
***
## 4. Message format [#4-message-format]
Each event becomes a single Discord message (`{ "content": "..." }`, no embeds), prefixed with an emoji per event family:
| Event family | Prefix | Example |
| -------------------------------------------- | -------------- | ---------------------------------------------------------------------- |
| `revenue.*` | `:moneybag:` | `:moneybag: revenue.RENEWAL — 9.99 USD · prod_gold · subscriber sub_…` |
| `subscription.*` (+ `subscriber.identified`) | `:repeat:` | `:repeat: subscription.trial.started · subscriber sub_…` |
| `credit.ledger.appended` | `:coin:` | `:coin: credit.ledger.appended · subscriber sub_…` |
| `funnel.session.*` | `:footprints:` | `:footprints: funnel.session.started · subscriber sub_…` |
The `subscriber` segment shows only the **first 4 characters** of the Rovenue subscriber id, followed by an ellipsis — enough to recognize repeat activity from the same subscriber in a channel, without printing an identifier a reader could act on. A `funnel.session.*` message never shows an amount, even for `.paid` (money is only ever reported on `revenue.*`), and the subscriber segment follows the webhook's `subscriberId`. A native (device) event shows it whenever the device sent its Rovenue id, identified or anonymous. A web event shows it on `funnel.session.completed` only. A GDPR-erased subject's events never show it.
**No PII ever appears in a Discord message.** The message builder only ever reads the event key, amount, currency, product id, and (masked) subscriber id off the event — it never reads `identityContext` (email, phone, IP, user agent) or subscriber attributes (arbitrary host-app-set key/values), regardless of what a subscriber's profile carries.
***
## 5. At-least-once delivery and duplicate messages [#5-at-least-once-delivery-and-duplicate-messages]
Like Slack, Discord has no consumer-side dedup key, so the pipeline's at-least-once delivery can rarely duplicate a message (a crash between POST and status write) — accepted for a notification channel.
***
## 6. Verify events are arriving [#6-verify-events-are-arriving]
1. Trigger a real event from your app (a purchase, a trial start, a funnel session) and check the configured Discord channel — messages typically arrive within a few seconds.
2. If messages are not appearing:
* Check **Apps → Discord → Delivery Log** in Rovenue for HTTP status codes and error messages.
* A `404` response (Discord's "Unknown Webhook") means the webhook was deleted in Discord — recreate it and update the connection's credentials.
* A `401` or `403` response means the webhook's token is invalid or revoked — recreate the webhook and update the connection's credentials.
* A `400` response means the request was malformed; this indicates a bug rather than a misconfiguration and should not recur on retry.
* A `429` response means Discord is rate-limiting the webhook; Rovenue's delivery queue retries automatically with backoff.
* Ensure the event type you're sending is included in the connection's enabled events.
* Rovenue only ever POSTs to `https://discord.com/api/webhooks/...` or `https://discordapp.com/api/webhooks/...` — a webhook URL hosted anywhere else, or at any other path, is rejected at both credential-validation time and delivery time.
# Firebase / GA4 (/docs/integrations/firebase-ga4)
# Firebase / GA4 [#firebase--ga4]
The Firebase / GA4 integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to a **Firebase app stream** via the [GA4 Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4), so subscription behavior shows up alongside the rest of your Firebase Analytics / BigQuery export data.
> **Scope note — Firebase app streams only.** This integration authenticates with `firebase_app_id`, which only works for a **Firebase app stream** (an iOS/Android/Unity app registered in a Firebase project). A **GA4 web stream** — authenticated with `measurement_id` + a browser `client_id` instead — is a different Measurement Protocol contract and is a **documented non-goal for this wave**. If you need to forward events to a GA4 web property, use the [Custom Webhook](/docs/integrations/outbound-webhooks) provider and adapt the payload yourself.
***
## 1. Get your credentials from Firebase and Google Analytics [#1-get-your-credentials-from-firebase-and-google-analytics]
1. In the [Firebase console](https://console.firebase.google.com), open **Project settings → General** and copy the **App ID** for the specific app (iOS/Android) you want events attributed to — this is `firebase_app_id`. It looks like `1:1234567890:android:abc123` or `1:1234567890:ios:abc123`.
2. In Google Analytics (the GA4 property linked to that Firebase project), open **Admin → Data Streams**, select the Firebase app stream, then **Measurement Protocol API secrets**.
3. Click **Create** to mint a new secret and copy it — this is `api_secret`.
> The Measurement Protocol API secret only grants event-ingestion write access to this one data stream; it is not a full Google Analytics credential.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Firebase / GA4** under **Product analytics** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Measurement Protocol API secret** — from step 1.
* **Firebase app ID** — from step 1.
4. Click **Validate** — Rovenue POSTs a minimal, clearly-tagged probe event to GA4's **validation server** (`/debug/mp/collect`), which per Google's own docs does not count toward your reports, and fails the check if GA4 reports any `validationMessages`.
* **Caveat, straight from Google's docs**: the validation server does **not** verify that `api_secret` or `firebase_app_id` are actually correct — only that the event body is well-formed. A well-formed but wrong secret/app-id pair still passes Validate. Double-check both values against the Firebase console / GA4 Admin screen.
5. Choose which events to forward, optionally override the default vendor event names, then activate.
***
## 3. Event mapping [#3-event-mapping]
GA4 constrains custom event names to the regex `^[A-Za-z]\w*$` (must start with a letter; letters, digits, and underscores only — no dots or hyphens). Rovenue's public event keys are forwarded as your GA4 event `name`, using the following default names (overridable per-connection in the drawer's mapping step):
| Rovenue event key | GA4 event `name` |
| ------------------------------- | --------------------------------------- |
| `revenue.INITIAL` | `purchase` |
| `revenue.RENEWAL` | `purchase` |
| `revenue.TRIAL_CONVERSION` | `purchase` |
| `revenue.CREDIT_PURCHASE` | `purchase` |
| `revenue.REFUND` | `refund` |
| `revenue.CANCELLATION` | `rovenue_cancellation` |
| `subscription.trial.started` | `rovenue_subscription_trial_started` |
| `subscription.cancel_requested` | `rovenue_subscription_cancel_requested` |
| `subscription.expired` | `rovenue_subscription_expired` |
| `subscription.billing_issue` | `rovenue_subscription_billing_issue` |
| `subscription.grace_period` | `rovenue_subscription_grace_period` |
| `subscription.uncancelled` | `rovenue_subscription_uncancelled` |
| `subscription.product_changed` | `rovenue_subscription_product_changed` |
| `subscription.paused` | `rovenue_subscription_paused` |
| `subscription.recovered` | `rovenue_subscription_recovered` |
| `subscription.revoked` | `rovenue_subscription_revoked` |
| `subscription.offer_redeemed` | `rovenue_subscription_offer_redeemed` |
Unlike Amplitude/Mixpanel's free-form event names, four distinct Rovenue revenue keys (`INITIAL`/`RENEWAL`/`TRIAL_CONVERSION`/`CREDIT_PURCHASE`) all collapse onto GA4's own recommended `purchase` event — GA4 has no separate vocabulary to distinguish an initial purchase from a renewal. Every subscription-lifecycle key is instead sent as a `rovenue_`-prefixed custom event, since GA4 has no standard vocabulary for subscription lifecycle at all.
Every forwarded event carries `params.rovenue_event` set to the original Rovenue event key (e.g. `revenue.RENEWAL`), so you can always trace a GA4 event back to its Rovenue source regardless of how the vendor event name has been overridden, plus `params.product_id` and `params.transaction_id`.
### Revenue fields [#revenue-fields]
For the six `revenue.*` keys only, Rovenue also populates `params.value` and `params.currency`. Subscription-lifecycle events never carry these fields — they represent no money movement.
**`revenue.REFUND` is sent with a POSITIVE `value`, alongside the distinct `refund` event name** — this is the **opposite** convention from Amplitude/Mixpanel (which negate the amount and reuse a generic event name). GA4's own documented [refund model](https://support.google.com/analytics/answer/9267735) represents a refund as its own `refund` event carrying the positive amount being refunded; GA4 nets this against prior `purchase` events server-side when computing revenue metrics. Rovenue's internal ledger always stores refund amounts as positive, so no sign flip happens at this wire boundary (unlike Amplitude/Mixpanel).
***
## 4. Identity resolution [#4-identity-resolution]
Every event requires GA4's `app_instance_id` — the Firebase Analytics SDK's own per-install identifier. **There is no fallback chain here** (unlike Amplitude/Mixpanel's `appUserId`/subscriber-id fallbacks): GA4's Measurement Protocol for a Firebase app stream has no concept of an arbitrary user id, so Rovenue cannot substitute anything else and land the event against the correct GA4 user.
Set the `$firebaseAppInstanceId` subscriber attribute from your app using the Firebase SDK's own instance-id accessor (e.g. `Analytics.appInstanceID()` on iOS, `FirebaseAnalytics.getAppInstanceId()` on Android), then call the Rovenue SDK's `setAttributes`. See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference.
If `$firebaseAppInstanceId` is not set, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
***
## 5. Deduplication [#5-deduplication]
Every forwarded event carries `params.transaction_id` set to Rovenue's internal `outboxEventId`. GA4 uses `transaction_id` to dedup repeated purchase/refund events, so retries after a network failure or an at-least-once outbox replay do not double-count revenue.
***
## 6. Verify events are arriving [#6-verify-events-are-arriving]
1. Open **DebugView** in Google Analytics (Admin → DebugView), or wait for the event to appear in standard GA4 reports (can take up to 24 hours outside DebugView).
2. Trigger a real event from your app (a renewal, a trial start, etc.).
3. If events are not appearing:
* Re-check `api_secret` and `firebase_app_id` directly against the Firebase console / GA4 Admin screen — remember, **Validate does not catch a wrong-but-well-formed pair** (see the caveat in step 2).
* Check **Apps → Firebase / GA4 → Delivery Log** in Rovenue for HTTP status codes and error messages. Note that GA4's live collect endpoint (`/mp/collect`) returns success codes even for a malformed event body — a "successful" delivery in the log does not by itself prove the event was accepted by GA4's processing pipeline.
* Ensure the event type you're sending is included in the connection's enabled events.
* Ensure the subscriber has `$firebaseAppInstanceId` set — without it, events are skipped before ever reaching GA4.
# Iterable (/docs/integrations/iterable)
# Iterable [#iterable]
The Iterable integration forwards Rovenue revenue events (purchases, trial conversions, renewals, cancellations) via Iterable's **`commerce/trackPurchase`** endpoint, and subscription-lifecycle events (trials, grace period, billing issues, plan changes) as Iterable **custom events** via `events/track` — so a subscriber's Rovenue purchase and subscription history is available inside Iterable for email/SMS/push campaigns, Workflow triggers, and revenue reporting.
Iterable is a cross-channel marketing platform, not an analytics warehouse: this integration sends **events, not a full profile sync**. It does not push user properties/tags, and it does not read anything back from Iterable.
***
## A note on identity: your subscribers' email addresses are sent to Iterable [#a-note-on-identity-your-subscribers-email-addresses-are-sent-to-iterable]
**Unlike ad-platform integrations (Meta CAPI, TikTok Events), which only ever receive a hashed identifier, Iterable receives your subscribers' plaintext email addresses when email is used as the identity key.** This is not a Rovenue design choice — it is how Iterable's own data model works: Iterable is an email/SMS/push platform, and its `trackPurchase`/`events/track` endpoints identify a user by `email` or `userId`, not by a hash. If you enable this integration and email is the identity Rovenue resolves for a subscriber (see below), that subscriber's email address is transmitted to Iterable as their user key.
If you'd rather not send plaintext email, set the reserved **`$iterableUserId`** attribute instead (see [Identity resolution](#4-identity-resolution) below) — Rovenue prefers it over email whenever it's present.
***
## 1. Get your credentials from Iterable [#1-get-your-credentials-from-iterable]
1. In the Iterable dashboard, open **Settings → API Keys**.
2. Create (or reuse) a **Server-side API key**.
3. Note whether your Iterable project lives in the US or EU data region — this determines which **Region** to select in Rovenue.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Iterable** under **CRM & lifecycle** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **API key** — paste the server-side API key from step 1.
* **Region** — `us` (default) or `eu`. Leave blank for US.
4. Click **Validate**. This sends a real, read-only `GET /api/lists` request — it does not write anything.
5. Choose which events to forward, optionally override the default mapping, then activate.
***
## 3. Event mapping [#3-event-mapping]
Revenue keys ride Iterable's `commerce/trackPurchase` endpoint, which has no vendor event-name concept of its own (a purchase is an item list with a total, not a named event) — the mapping table below is used only as the fallback item **name** when a purchase has no known `productId`. The six `subscription.*` lifecycle keys become genuine Iterable custom events, since Iterable has no built-in vocabulary for subscription lifecycle:
| Rovenue event key | Iterable wire shape | Default name / label |
| ------------------------------- | ------------------------ | --------------------------------------- |
| `revenue.INITIAL` | `commerce/trackPurchase` | `purchase_initial` |
| `revenue.TRIAL_CONVERSION` | `commerce/trackPurchase` | `trial_conversion` |
| `revenue.RENEWAL` | `commerce/trackPurchase` | `renewal` |
| `revenue.CREDIT_PURCHASE` | `commerce/trackPurchase` | `credit_purchase` |
| `revenue.CANCELLATION` | `commerce/trackPurchase` | `cancellation` |
| `subscription.trial.started` | `events/track` | `rovenue_subscription_trial_started` |
| `subscription.cancel_requested` | `events/track` | `rovenue_subscription_cancel_requested` |
| `subscription.expired` | `events/track` | `rovenue_subscription_expired` |
| `subscription.billing_issue` | `events/track` | `rovenue_subscription_billing_issue` |
| `subscription.grace_period` | `events/track` | `rovenue_subscription_grace_period` |
| `subscription.uncancelled` | `events/track` | `rovenue_subscription_uncancelled` |
| `subscription.product_changed` | `events/track` | `rovenue_subscription_product_changed` |
| `subscription.paused` | `events/track` | `rovenue_subscription_paused` |
| `subscription.recovered` | `events/track` | `rovenue_subscription_recovered` |
| `subscription.revoked` | `events/track` | `rovenue_subscription_revoked` |
| `subscription.offer_redeemed` | `events/track` | `rovenue_subscription_offer_redeemed` |
Every `trackPurchase` and `events/track` request carries `id` set to Rovenue's own outbox event id, which Iterable uses as its own idempotency key for that record — re-delivering the same event updates the existing Iterable record instead of duplicating it. Every `events/track` request also carries `dataFields.rovenue_event` (the original Rovenue event key) and `dataFields.product_id` when known, so you can trace an Iterable custom event back to its Rovenue source.
**`total`/item `price` are populated from the revenue event's amount whenever it's present and numeric — independent of whether a currency is known.** Iterable's `trackPurchase` contract has no currency field at all (verified against RevenueCat's own Iterable integration's published sample payloads, corroborated by third-party Iterable API clients), so unlike some other Rovenue integrations, there is no currency to gate on: Rovenue never sends a currency to Iterable, and the purchase total is not withheld just because currency is unknown.
**`revenue.REFUND` is not forwarded.** Iterable's own API reference documents no negative-total or reversal convention for `trackPurchase`. As corroborating evidence, RevenueCat's own Iterable integration — a directly comparable subscription-revenue forwarder — routes its "cancellation" event through the Custom Events API rather than attempting a `trackPurchase` reversal, and states plainly that purchase-revenue reporting in Iterable is not expected to stay accurate across refunds. Rovenue follows the same pattern: refunds are skipped with reason `no_mapping` and never reach Iterable's `trackPurchase`. If you need refund visibility in Iterable, use a Workflow keyed off your own systems, or reach out about wiring refunds through `events/track` as a non-revenue custom event.
***
## 4. Identity resolution [#4-identity-resolution]
Every purchase or event object sent to Iterable needs exactly one identity key: a `userId` or an `email`. Rovenue resolves it in this order:
1. **`$iterableUserId`** — an explicit Iterable user id, if you've called Iterable's `identify()` (or equivalent) for this subscriber and set the reserved attribute. Sent as `user.userId` / `userId`.
2. The subscriber's **email address** — read from Rovenue's own identity context (set via `identify()` with an email, or webhook-sourced Stripe/store customer email) or the reserved **`$email`** attribute. Sent as `user.email` / `email`. **This is plaintext, not hashed** — see the note at the top of this page.
There is no fallback to a Rovenue-internal subscriber id: it is neither a valid Iterable `userId` nor an email address, so sending it as either would create a garbage profile in Iterable or be rejected outright. If neither `$iterableUserId` nor an email is available, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$iterableUserId`.
***
## 5. Verify events are arriving [#5-verify-events-are-arriving]
1. Open **Apps → Iterable → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `200`.
2. In Iterable, look up the user by email or userId and check their **Purchases** or **Events** timeline for the mapped names above. Custom event names must be allow-listed in your Iterable project (**Settings → Custom Events**) or "Allow new custom events" must be enabled, or Iterable will reject them.
3. If events are not appearing:
* A `401` response means the API key is wrong or scoped to the wrong region.
* A `400` response usually means a malformed request — check the Delivery Log's response body.
* A `429` response means you're rate limited; Rovenue retries these automatically.
* Confirm the subscriber has `$iterableUserId` or a resolvable email set (missing both skips silently with `no_user_data`).
* Ensure the event type you're sending is included in the connection's enabled events, and that `revenue.REFUND` isn't the only thing you're testing with — it's intentionally never forwarded (see above).
# Meta Conversions API (CAPI) (/docs/integrations/meta-capi)
# Meta Conversions API (CAPI) [#meta-conversions-api-capi]
The Meta CAPI integration forwards Rovenue subscription and revenue events directly to Meta's server-side Conversions API, enabling reliable attribution for mobile campaigns without relying solely on client-side pixels.
***
## 1. Get your credentials from Meta [#1-get-your-credentials-from-meta]
1. Open [Meta Events Manager](https://business.facebook.com/events_manager).
2. Select your **Data Source** (Pixel).
3. Go to **Settings → API Access**.
4. Copy:
* **Access Token** — a long-lived system user token (recommended) or a page access token.
* **Pixel ID** — the numeric identifier shown at the top of the Data Source settings page (e.g. `123456789012345`).
> Store these values securely. The access token grants write access to your Pixel.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Meta CAPI** in the integrations grid and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Pixel ID** — paste the Pixel ID from step 1.
* **Access Token** — paste the access token from step 1.
4. Click **Validate** — Rovenue calls the Meta CAPI test endpoint to verify the credentials are accepted.
5. Proceed through the remaining drawer steps (event mapping, test event code, enable).
6. Click **Save** to activate the integration.
***
## 3. Identity context & consent [#3-identity-context--consent]
When your SDK passes an `identityContext` with an event, the following fields are forwarded to Meta CAPI:
| Field | Forwarded as | Hashing |
| ---------------- | --------------------------------- | --------------------------------------------- |
| `email` | `user_data.em` | SHA-256 (lowercased + trimmed before hashing) |
| `phone` | `user_data.ph` | SHA-256 (digits only before hashing) |
| `externalId` | `user_data.external_id` | SHA-256 (trimmed before hashing) |
| `ip` | `user_data.client_ip_address` | Forwarded verbatim — not hashed |
| `userAgent` | `user_data.client_user_agent` | Forwarded verbatim — not hashed |
| `fbp` / `fbc` | `user_data.fbp` / `user_data.fbc` | Forwarded verbatim — not hashed |
| `ttclid` / `ttp` | Not sent to Meta CAPI | N/A (TikTok-specific fields) |
**Consent is your responsibility.**
Rovenue acts as a **data processor**. Your application is the **data controller**. You must obtain the end-user's informed consent before passing any PII in `identityContext`. GDPR, CCPA, KVKK, and similar regulations place the consent obligation on the controller (you), not the processor (Rovenue).
Additional guarantees:
* Rovenue **never persists** `identityContext` fields at rest. PII is hashed in-process; raw values are not written to any database.
* `response_body` from Meta CAPI is stored but **truncated to 4096 bytes**.
* The wire format uses camelCase JSON; each envelope carries a numeric `version` field (currently `1`) and a stable `eventId` for downstream dedupe.
***
## 4. Verify events are arriving [#4-verify-events-are-arriving]
1. Go back to **Meta Events Manager**.
2. Select your Data Source and open the **Test Events** tab.
3. In the Rovenue drawer's **Test** step, you set a `test_event_code` (e.g. `TEST12345`). Trigger a real event from your app or use the Rovenue **Send Test Event** button.
4. Within a few seconds, the Test Events tab should show the event with your test event code, confirming the full pipeline is working.
If events are not appearing:
* Verify the Pixel ID and Access Token are correct (use **Validate** in the drawer).
* Check **Apps → Meta CAPI → Delivery Log** in Rovenue for HTTP status codes and error messages.
* Ensure `enabledEvents` in the connection includes the event type you are sending.
# Mixpanel (/docs/integrations/mixpanel)
# Mixpanel [#mixpanel]
The Mixpanel integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to Mixpanel's Import Events API, so subscription behavior shows up alongside the rest of your product analytics.
***
## 1. Create a Service Account in Mixpanel [#1-create-a-service-account-in-mixpanel]
Mixpanel's Import Events API does **not** accept a plain project token for this integration — it requires a **Service Account** with Owner or Admin permissions on the project.
1. In Mixpanel, open your project's **Settings → Service Accounts**.
2. Click **Add Service Account**, give it Owner or Admin role, and create it.
3. Copy the generated **username** and **secret** — the secret is shown only once.
4. Note your project's **Project ID** (also under **Settings → Project Settings**).
5. Note your project's **data residency** (US or EU) — set at project creation, shown on the same settings page.
> Rovenue only ever writes events through this Service Account — it does not read, modify, or delete anything else in your Mixpanel project.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Mixpanel** under **Product analytics** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Service account username** — from step 1.
* **Service account secret** — from step 1.
* **Project ID** — from step 1.
* **Region** — `us` (default) or `eu`, matching your project's data residency. Leave blank for `us`.
4. Click **Validate** — Rovenue sends a real, clearly-tagged test event (deduplicated across repeat clicks) to Mixpanel's Import Events API to confirm the credentials are accepted for this project.
5. Choose which events to forward, optionally override the default vendor event names, then activate.
***
## 3. Event mapping [#3-event-mapping]
Mixpanel has no built-in "purchase" event — Rovenue's public event keys are forwarded as your Mixpanel `event` name, using the following default names (overridable per-connection in the drawer's mapping step). These are the same default names Rovenue uses for the Amplitude integration:
| Rovenue event key | Mixpanel `event` |
| ------------------------------- | ---------------------- |
| `revenue.INITIAL` | `purchase_initial` |
| `revenue.TRIAL_CONVERSION` | `trial_conversion` |
| `revenue.RENEWAL` | `renewal` |
| `revenue.CREDIT_PURCHASE` | `credit_purchase` |
| `revenue.REFUND` | `refund` |
| `revenue.CANCELLATION` | `cancellation` |
| `subscription.trial.started` | `trial_started` |
| `subscription.cancel_requested` | `cancel_requested` |
| `subscription.expired` | `subscription_expired` |
| `subscription.billing_issue` | `billing_issue` |
| `subscription.grace_period` | `grace_period` |
| `subscription.uncancelled` | `uncancelled` |
| `subscription.product_changed` | `product_changed` |
| `subscription.paused` | `paused` |
| `subscription.recovered` | `recovered` |
| `subscription.revoked` | `revoked` |
| `subscription.offer_redeemed` | `offer_redeemed` |
Every forwarded event carries `properties.rovenue_event` set to the original Rovenue event key (e.g. `revenue.RENEWAL`), so you can always trace a Mixpanel event back to its Rovenue source regardless of how the vendor event name has been overridden, plus `properties.product_id`.
### Revenue fields [#revenue-fields]
For the six `revenue.*` keys only, Rovenue also populates `properties.amount` and `properties.currency`. Subscription-lifecycle events never carry these fields — they represent no money movement.
**`revenue.REFUND` is sent with a NEGATIVE `amount`**, mirroring the same convention Rovenue uses for the Amplitude integration, so a refund reads as negative revenue in any Mixpanel report that sums this property. Rovenue's internal ledger always stores refund amounts as positive; the sign flip happens only at the Mixpanel wire boundary.
***
## 4. Identity resolution [#4-identity-resolution]
Every Mixpanel event requires a `distinct_id`. Rovenue resolves it using this fallback chain, in order:
1. `$mixpanelDistinctId` — the subscriber attribute you should set once you've called Mixpanel's own `identify()` on the client, so revenue events join the correct identified Mixpanel user profile.
2. `appUserId` — your app's own subscriber id, if the subscriber has one.
3. The Rovenue-internal subscriber id, as a last resort so revenue is never silently dropped for lack of an explicit Mixpanel identity.
Set these via the SDK's `setAttributes` call. See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$mixpanelDistinctId`.
If neither `$mixpanelDistinctId`, `appUserId`, nor a subscriber id is available, the event is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
***
## 5. Deduplication [#5-deduplication]
Every forwarded event carries `properties.$insert_id` set to Rovenue's internal `outboxEventId`. Mixpanel treats events sharing the same `(event, time, distinct_id, $insert_id)` tuple as duplicates and surfaces only one of them in queries, so retries after a network failure or an at-least-once outbox replay do not double-count revenue.
***
## 6. Verify events are arriving [#6-verify-events-are-arriving]
1. Open your project in Mixpanel and use **Events** (Data Management) or a live report filtered to one of the vendor event names above.
2. Trigger a real event from your app (a renewal, a trial start, etc.) — Mixpanel's Import API typically ingests events within a few minutes.
3. If events are not appearing:
* Verify the service account username/secret, project ID, and region are correct (use **Validate** in the drawer — a `us`/`eu` mismatch sends events to the wrong data center, where they will never appear).
* Check **Apps → Mixpanel → Delivery Log** in Rovenue for HTTP status codes and error messages.
* Ensure the event type you're sending is included in the connection's enabled events.
* A `401` response means the service account credentials (or project ID) are wrong. A `400` response means an event failed Mixpanel's strict validation — the response body includes the failing field and its `$insert_id`.
# OneSignal (/docs/integrations/onesignal)
# OneSignal [#onesignal]
The OneSignal integration forwards Rovenue revenue events (purchases, trial conversions, renewals, refunds, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) as OneSignal **custom events**, via OneSignal's `custom_events` REST endpoint — so you can enter subscribers into Journeys, drive Wait Until steps, and personalize messages based on their Rovenue purchase and subscription history.
OneSignal is a push/messaging platform, not an analytics warehouse: this integration sends **events, not a full profile sync**. It does not push user properties/tags, and it does not read anything back from OneSignal.
***
## Chosen surface: the Custom Events API [#chosen-surface-the-custom-events-api]
OneSignal offers two ways a server could push subscription data into a user's timeline: a contemporary **Custom Events API** (`POST /apps/{app_id}/custom_events`), or the older **Tags** (user-properties) update. This integration uses the **Custom Events API** — it is documented, generally available on `api.onesignal.com`, and is purpose-built for exactly this ("track user behavior... to trigger Journeys, control flow with Wait Until steps, personalize messages, and measure conversions"), unlike Tags, which model static key/value properties rather than a stream of timestamped events. The fallback (Tags) is not used.
Authentication uses OneSignal's modern per-app REST API key with the `Key ` prefix:
```
Authorization: Key YOUR_REST_API_KEY
```
***
## 1. Get your credentials from OneSignal [#1-get-your-credentials-from-onesignal]
1. In the OneSignal dashboard, open your app, then **Settings → Keys & IDs**.
2. Copy the **OneSignal App ID** (a UUID).
3. Copy the app's **REST API Key**.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **OneSignal** under **CRM & lifecycle** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **App ID** — paste the App ID from step 1.
* **REST API key** — paste the key from step 1.
4. Click **Validate**. This sends a real, read-only `GET /apps/{app_id}` request — it does not write anything and is not counted or billed, unlike some other providers' credential probes.
5. Choose which events to forward, optionally override the default mapping, then activate.
***
## 3. Event mapping [#3-event-mapping]
Every forwarded key becomes a OneSignal custom event, named per the mapping table below (override any name in the drawer's mapping step):
| Rovenue event key | Default OneSignal event name |
| ------------------------------- | ---------------------------- |
| `revenue.INITIAL` | `purchase_initial` |
| `revenue.TRIAL_CONVERSION` | `trial_conversion` |
| `revenue.RENEWAL` | `renewal` |
| `revenue.CREDIT_PURCHASE` | `credit_purchase` |
| `revenue.REFUND` | `refund` |
| `revenue.CANCELLATION` | `cancellation` |
| `subscription.trial.started` | `trial_started` |
| `subscription.cancel_requested` | `cancel_requested` |
| `subscription.expired` | `subscription_expired` |
| `subscription.billing_issue` | `billing_issue` |
| `subscription.grace_period` | `grace_period` |
| `subscription.uncancelled` | `uncancelled` |
| `subscription.product_changed` | `product_changed` |
| `subscription.paused` | `paused` |
| `subscription.recovered` | `recovered` |
| `subscription.revoked` | `revoked` |
| `subscription.offer_redeemed` | `offer_redeemed` |
Every forwarded event carries a `payload` with `rovenue_event` (the original Rovenue event key) and `outbox_event_id`, so you can always trace a OneSignal custom event back to its Rovenue source. `revenue.*` events also carry `product_id` when known.
**Amount/currency are included only when both are present and valid.** Unlike a structured purchase record, OneSignal's custom-events payload has no required monetary schema — so when a revenue event has an amount but the subscriber's currency isn't known, Rovenue never guesses a currency (it does **not** default to `"USD"`). In that case the event is still forwarded (it's still useful for triggering a Journey), just without the `amount`/`currency` fields.
***
## 4. Identity resolution — `$onesignalId` is required [#4-identity-resolution--onesignalid-is-required]
Every custom event needs a OneSignal user id. Rovenue requires the reserved subscriber attribute **`$onesignalId`** — OneSignal's own internal user id (`OneSignal ID`), obtained from the OneSignal SDK once a device is registered (e.g. `OneSignal.User.onesignalId` in the current SDKs' User Model). Set it on the subscriber via `setAttributes` alongside your other reserved identity attributes.
If `$onesignalId` is not set on a subscriber, every event for that subscriber is skipped with reason `no_user_data` (visible in the connection's Delivery Log) — there is no fallback to a Rovenue-internal id, since Rovenue has no way to derive OneSignal's own user id from anything else it holds.
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$onesignalId`.
***
## 5. Verify events are arriving [#5-verify-events-are-arriving]
1. Open **Apps → OneSignal → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `200`/`202`.
2. In OneSignal, look up the user by their OneSignal ID and check their timeline / any Journey configured with a Custom Event trigger for the mapped names above.
3. If events are not appearing:
* A `401`/`403` response means the REST API key is wrong or doesn't belong to the configured App ID.
* A `400` response usually means a malformed request — check the Delivery Log's response body.
* A `429` response includes a `Retry-After`; Rovenue retries these automatically.
* Confirm the subscriber has `$onesignalId` set (missing it skips silently with `no_user_data`).
* Ensure the event type you're sending is included in the connection's enabled events.
# Outbound Webhooks (/docs/integrations/outbound-webhooks)
# Outbound Webhooks [#outbound-webhooks]
The **Custom Webhook** integration POSTs a JSON event to an HTTPS endpoint you control whenever a subscription, revenue, funnel-session, or credit-ledger event happens in your project. It is signed with the same scheme [Svix](https://www.svix.com) uses, so most existing Svix-compatible webhook libraries verify it without modification.
***
## 1. Add a webhook endpoint [#1-add-a-webhook-endpoint]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Custom Webhook** in the integrations grid and click it.
3. Enter your endpoint **URL** (must be `https://` in production) and choose which events to receive.
4. Click **Save**. The response includes a **signing secret** in the form `whsec_...` — copy it now. It is shown once, at creation time; Rovenue only stores it encrypted and later shows a truncated hint (e.g. `example.com · …ab12`).
5. Use the **Delivery Log** on the connection to inspect past attempts, HTTP status codes, and response bodies, and to manually **redeliver** a specific past event.
***
## 2. Payload envelope [#2-payload-envelope]
Every delivery body is JSON with this shape:
```json
{
"id": "cksubEvt1a2b3c4d5e6f7g8h9",
"type": "revenue.RENEWAL",
"created": "2026-08-24T12:00:00.000Z",
"apiVersion": "2026-08-24",
"projectId": "ckprojectAbCdEfGhIjKlMn",
"data": { }
}
```
| Field | Description |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | The originating event's stable identifier. Use this for [idempotency](#7-idempotency--at-least-once-delivery) — see below for how it behaves on retries and manual redeliver. |
| `type` | One of the [event keys](#3-event-catalog) below. |
| `created` | ISO-8601 timestamp of when the underlying event occurred (not when it was delivered). |
| `apiVersion` | A date-versioned string identifying the payload schema, currently `2026-08-24`. Bump only accompanies a documented breaking change to the envelope or `data` shape. |
| `projectId` | The Rovenue project the event belongs to. |
| `data` | Event-specific payload — see below. |
### `data` shape [#data-shape]
For revenue events (`revenue.*`), `data` is a fixed shape:
```json
{
"kind": "RENEWAL",
"amount": "9.99",
"currency": "USD",
"subscriberId": "cksubscriber1a2b3c4d5e6f7g",
"productId": "com.example.app.monthly",
"externalId": "your-app-user-id"
}
```
* `amount` is a decimal string in the major currency unit (e.g. `"9.99"`, not cents).
* `currency` is the ISO 4217 code.
* `externalId` is the `externalId` you passed to the SDK's identify call, if any — no other PII (email, phone, IP, etc.) is ever included in a webhook payload.
* `reason` is present only on some `revenue.REACTIVATION` events — see [one-time purchase revenue keys](#one-time-purchase-revenue-keys) below. Every other revenue event omits the key entirely rather than sending it as `null`.
For every other event type (`subscription.*`, `subscriber.identified`, `credit.ledger.appended`), `data` is that event's domain payload passed through as-is.
For funnel events (`funnel.session.*`), `data` is a curated, camelCase object — not a passthrough of the internal payload. A field that has no value for that event (an empty native `sessionId`, a web event's missing `placementId`) is **omitted entirely**, never sent as `null` or `""`:
```json title="web — funnel.session.started"
{
"sessionId": "cksession1a2b3c4d5e6f7g8h",
"funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
"versionId": "ckversion1a2b3c4d5e6f7g8h",
"target": "web",
"pageId": "welcome"
}
```
```json title="native (device) — funnel.session.advanced"
{
"funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
"revision": 2,
"target": "native",
"pageId": "onboarding-2",
"placementId": "ckplacement1a2b3c4d5e6f7g",
"variantId": "ckvariant1a2b3c4d5e6f7g8h",
"experimentKey": "onboarding-copy-test"
}
```
```json title="web — funnel.session.custom_action"
{
"sessionId": "cksession1a2b3c4d5e6f7g8h",
"funnelId": "ckfunnel1a2b3c4d5e6f7g8h9i",
"versionId": "ckversion1a2b3c4d5e6f7g8h",
"target": "web",
"pageId": "pricing",
"actionId": "open_faq",
"params": {
"faqId": "refunds",
"source": "sticky-footer"
}
}
```
A web event never carries `revision` (only native events do), and carries `subscriberId` on `funnel.session.completed` only: that is the event fired when a paid visitor claims the purchase in the app, so it is the first point where a web session has a subscriber. `started`, `advanced`, `paid`, `abandoned` and `custom_action` never have one.
A native event has **no `sessionId`**, because the device has no presentation id (see the note under `funnel.session.dismissed` below). It carries `subscriberId` whenever the device sent its Rovenue id with the event. Identifying is not required: every install has a Rovenue id, anonymous users included. The id on the webhook is the Rovenue subscriber id that the device's id resolved to. A GDPR-erased subject's events carry **no** `subscriberId`: they still arrive, but unattributed. `pageId` is the page the event is about: the landing page of an arrival, the opening page of a `started`, or the page a custom action fired on; `fromPageId` (web-only, forward moves) names the page it moved *from*. `funnel.session.paid` carries no amount — money is always reported separately on the `revenue.*` keys above.
`params` (only present on `funnel.session.custom_action`) is the author-defined key/value map attached to the action in the funnel document, and it is bounded before it reaches the wire — not a raw passthrough. In order: only string values survive (anything else is dropped); a key longer than **64 characters** is dropped; the surviving keys are sorted and only the first **32** are kept (a deterministic cut, not "whichever happen to survive"); and each kept value is truncated to **512 characters**. These bounds only make the body size predictable — a real action's `params` map is far smaller — but a receiver that expects every key it configured in the funnel to always be present should know keys can be silently dropped, and values silently truncated, under this rule.
**Security note — native funnel events are client-asserted.** A device funnel event (`started`, `advanced`, `completed`, `dismissed`) is submitted with the project's **public** SDK key, the same key that ships inside the app binary. Anyone holding that key — which is by design not a secret — can submit a native funnel event that never actually happened on a real device. Web funnel events (and every `revenue.*` event, including `funnel.session.paid`) are produced server-side and carry no such exposure. Do not treat a native funnel webhook as proof of anything a client could fake; if a receiver needs a trustworthy signal, use `revenue.*` or a web funnel event instead.
***
## 3. Event catalog [#3-event-catalog]
> Kept in sync with `ROVENUE_EVENT_KEYS` in `packages/shared/src/integrations.ts` — that file is the source of truth if this list and the code ever drift.
| Event key | Fires when |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `revenue.INITIAL` | The first paid transaction for a subscriber (new subscription). Also the type recorded for any one-time purchase made before 2026-09-04 — see [one-time purchase revenue keys](#one-time-purchase-revenue-keys) below. |
| `revenue.TRIAL_CONVERSION` | A trial converts to a paid subscription. |
| `revenue.RENEWAL` | A recurring subscription renews for another billing period. |
| `revenue.CREDIT_PURCHASE` | A consumable in-app product is purchased — Apple, Google, or a one-time Stripe funnel purchase of a consumable product. See [one-time purchase revenue keys](#one-time-purchase-revenue-keys) below. |
| `revenue.NON_RENEWING_PURCHASE` | A non-consumable one-time purchase — Apple, Google, or a one-time Stripe funnel purchase of a non-consumable product. See [one-time purchase revenue keys](#one-time-purchase-revenue-keys) below. |
| `revenue.REACTIVATION` | A lapsed subscriber returns — a win-back offer redemption or a plain resubscribe on any store — or an accounting reversal of a previously-applied refund (Apple only). All three stores — see [one-time purchase revenue keys](#one-time-purchase-revenue-keys) below for how the two cases are told apart internally. |
| `revenue.REFUND` | A refund is recorded (`data.amount` is always a positive value). |
| `revenue.CANCELLATION` | The subscription ended — the $0 marker written when a subscription reaches its end of term. It books no money and grants no credits. |
| `subscription.trial.started` | A new trial period begins. |
| `subscriber.identified` [^not-yet-emitted] | An SDK `identify()` call binds a Rovenue subscriber to your app-user id. |
| `subscription.cancel_requested` | A subscriber requests cancellation (the subscription may still be active until period end). |
| `subscription.expired` | A subscription reaches the `EXPIRED` state. |
| `subscription.billing_issue` | A renewal payment fails and the subscriber does **not** keep access while the store retries (Apple `DID_FAIL_TO_RENEW` *without* the `GRACE_PERIOD` subtype, Apple `GRACE_PERIOD_EXPIRED`, Google `SUBSCRIPTION_ON_HOLD`, Stripe `invoice.payment_failed`). The grace-period half of Apple's `DID_FAIL_TO_RENEW` announces `subscription.grace_period` instead — see the row below. |
| `subscription.grace_period` | A renewal payment fails but the subscriber keeps access while the store retries (Google `SUBSCRIPTION_IN_GRACE_PERIOD`, Apple `DID_FAIL_TO_RENEW` with subtype `GRACE_PERIOD` — Apple sends that subtype only when the app has a billing grace period configured — and Stripe `past_due` while smart retries run). |
| `subscription.uncancelled` | A subscriber re-enables auto-renew after previously cancelling (Google `SUBSCRIPTION_RESTARTED`, Apple `DID_CHANGE_RENEWAL_STATUS` in the "switched back on" direction, Stripe cancel-at-period-end undo). |
| `subscription.product_changed` | A subscription's product or price changes outside a cancellation. Delivered in two phases: an *announcement* that a change is coming (Apple `DID_CHANGE_RENEWAL_PREF` other than its `UPGRADE` subtype, Google `SUBSCRIPTION_DEFERRED` — a bare price change announces nothing), and an *effective* delivery carrying `data.phase` = `"effective"` on Apple, Google and Stripe alike, when the product on the purchase actually moved. See [plan-change phases](#plan-change-phases) below. |
| `subscription.paused` | A subscriber pauses their subscription (Google `SUBSCRIPTION_PAUSED`, Stripe paused subscription). Apple has no pause. |
| `subscription.resumed` | A paused subscription grants access again (Google restart/recovery from pause, Stripe pause end). Google and Stripe — Apple has no pause to resume from. |
| `subscription.recovered` | A billing failure resolves and the subscription grants access again (Google `SUBSCRIPTION_RECOVERED`; on Apple and Stripe, any store write that moves a purchase out of `BILLING_ISSUE` into an access-granting state). All three stores — see the coverage note below. |
| `subscription.revoked` | Access ends immediately rather than lapsing at end of term — a refund, a family-sharing removal, a store-initiated revocation (Apple `REVOKE`, Google `SUBSCRIPTION_REVOKED`), or a Stripe charge refund that ends access mid-term. Distinct from `subscription.expired`, which is an end-of-term expiry. |
| `subscription.offer_redeemed` | A subscriber redeems a promotional offer, an offer code, or a win-back offer (Apple `OFFER_REDEEMED`). **Apple only** — see the coverage note below. Distinct from `subscription.uncancelled` (the subscription may have fully lapsed) and from `subscription.product_changed` (the redeemed offer is often for the same product). |
| `credit.ledger.appended` | A row is appended to the append-only credit ledger (a grant or a spend). |
| `funnel.session.started` | A funnel session begins — web (a visitor opens a funnel) or native (a device presents a funnel). |
| `funnel.session.advanced` | A session arrives at a page, moving forward — web or native. A native session's first screen fires this too (there is no separate native "arrived at the opening screen" event, so its `advanced` count includes the first screen). |
| `funnel.session.paid` | A purchase completes inside a web funnel. **Web only today** — carries no amount; see [`revenue.*`](#3-event-catalog) above for money. |
| `funnel.session.completed` | A session reaches the funnel's end — web or native. |
| `funnel.session.abandoned` | A web session times out server-side with no further activity. **Web only today** — a native session has no equivalent server-side timeout; see `dismissed` below for how a device close is reported instead. |
| `funnel.session.dismissed` | The user closes the funnel on the device. **Native only** — a device close is a different meaning from the web `abandoned` timeout above, so it is never sent for a web session. |
| `funnel.session.custom_action` | An author-defined action fires inside a funnel page. **Web only today.** |
You choose which of these keys are delivered to a given endpoint when you configure the connection. Only the **Custom Webhook**, **Slack**, and **Discord** integrations offer the full catalog above, funnel events included — other, more narrowly-scoped providers (Meta CAPI, TikTok Events, analytics/attribution providers) do not. How a funnel session becomes these keys — device events versus web session events, the outbox → Redpanda → ClickHouse pipeline, and the dashboard Sessions tab — lives on [Events & Analytics](/docs/funnels/events-and-analytics).
[^not-yet-emitted]: Defined in the catalog and selectable on a connection, but not yet emitted by the backend — coming with SDK identify instrumentation.
***
### One-time purchase revenue keys [#one-time-purchase-revenue-keys]
Before 2026-09-04, every one-time (non-subscription) purchase — a consumable
or non-consumable in-app purchase on Apple or Google — was recorded with the
same type as a new subscription, `revenue.INITIAL`. Stripe funnel purchases
of a one-time package recorded no revenue event at all. Both are fixed: a
one-time purchase is now typed by what it actually is, and it reaches the
Stripe funnel path too.
The type is decided by one rule, in one place
(`services/revenue/one-time-type.ts`), keyed only on the product's
`ProductType`:
| `ProductType` | Revenue type |
| ---------------- | --------------------------------------------------------------------------------------------- |
| `CONSUMABLE` | `revenue.CREDIT_PURCHASE` |
| `NON_CONSUMABLE` | `revenue.NON_RENEWING_PURCHASE` |
| `SUBSCRIPTION` | unchanged — the caller's own `INITIAL` / `RENEWAL` / `TRIAL_CONVERSION` classification stands |
**Producer table** — which store, and which code path, writes each of
these two keys:
| Revenue key | Apple | Google | Stripe (funnel) | CSV import |
| ------------------------------- | ---------------------------------------- | ----------------------------------------------------- | --------------------------------------------- | ----------------------------------------------- |
| `revenue.CREDIT_PURCHASE` | Consumable IAP, receipt verification | Consumable one-time product, receipt verification | One-time purchase of a consumable package | A historical consumable purchase, on import |
| `revenue.NON_RENEWING_PURCHASE` | Non-consumable IAP, receipt verification | Non-consumable one-time product, receipt verification | One-time purchase of a non-consumable package | A historical non-consumable purchase, on import |
All four producers — Apple and Google receipt verification, the Stripe
funnel's one-time purchase completion, and the RevenueCat/Adapty CSV
importer — call the same rule above, so a purchase is typed identically
regardless of which path recorded it.
**`revenue.CREDIT_PURCHASE` means "a consumable IAP was bought," not "a
credit grant was recorded."** A `CONSUMABLE` product configured with no
currency-grant rows behind it grants nothing at all — a project
misconfiguration — and is still filed as a credit purchase. The type is a
property of the product, not of what the purchase happened to grant.
**History is not rewritten.** A one-time purchase recorded before
2026-09-04 keeps `revenue.INITIAL` — it is not retyped to
`revenue.CREDIT_PURCHASE` or `revenue.NON_RENEWING_PURCHASE`. If you're
reconciling historical numbers against numbers recorded after this date,
older one-time purchases will only ever appear under `revenue.INITIAL`.
**`revenue.REACTIVATION` fires on all three stores** and carries two
different economic meanings under one key: a lapsed subscriber returning
(a win-back offer redemption or a plain resubscribe), and an accounting
reversal — Apple un-does a refund that had already been applied. Only
Apple produces the reversal case; every Google and Stripe
`revenue.REACTIVATION` is the win-back case. (A billing-issue recovery is
not a reactivation: it is billed as the retry it is — a `revenue.RENEWAL`
or `revenue.TRIAL_CONVERSION` — with `subscription.recovered` beside it.)
Internally, the underlying revenue event is tagged
`metadata.reason: "refund_reversed"` (the exported
`REACTIVATION_REVERSAL_REASON` constant in
`apps/api/src/services/apple/apple-webhook.ts`) for the reversal case only;
an unmarked one is the win-back case. **This tag reaches the delivered
Custom Webhook payload as `data.reason`** — a reversal's `data` carries
`"reason": "refund_reversed"` in addition to the fixed shape described
[above](#data-shape); a win-back's `data` omits the `reason` key entirely
(never `reason: null`), so a Custom Webhook receiver can tell the two
`revenue.REACTIVATION` cases apart from the delivered payload alone.
**Delivery change for existing connections.** Connections that already
receive `revenue.INITIAL` were widened by migration `0123` to also receive
`revenue.CREDIT_PURCHASE` and `revenue.NON_RENEWING_PURCHASE` — no action
is needed to keep receiving purchases you were already being told about.
`revenue.REACTIVATION` is opt-in: it is a genuinely new signal, so it is
not added to any existing connection automatically.
This also changes the vendor event name an existing connection sends for a
one-time purchase, because the vendor mapping is keyed on the revenue type,
not on "a purchase happened." On **META\_CAPI**, a consumable IAP that used
to deliver as `Subscribe` (via `revenue.INITIAL`) now delivers as
`Purchase` (via `revenue.CREDIT_PURCHASE`); on **TIKTOK\_EVENTS**,
`Subscribe` becomes `CompletePayment`. Non-consumable one-time purchases
move the same way, from `Subscribe`/`Subscribe` to `Purchase`/
`CompletePayment` respectively.
Why: a purchase that never becomes a subscription must not be reported to
an ad platform as a subscription — doing so corrupts the platform's
optimisation, which learns from what it's told converts. The consequence
an advertiser will actually notice: conversion volume on a campaign
optimised for `Subscribe` events drops on the day this ships, because
one-time purchases that used to count toward it now report as `Purchase`/
`CompletePayment` instead.
***
### Per-store coverage [#per-store-coverage]
Every lifecycle key below fires on all three stores except the two pause keys
and `subscription.offer_redeemed`:
| Event | Apple | Google | Stripe |
| ------------------------------- | ----- | ------ | ------ |
| `subscription.trial.started` | ✅ | ✅ | ✅ |
| `subscription.cancel_requested` | ✅ | ✅ | ✅ |
| `subscription.expired` | ✅ | ✅ | ✅ |
| `subscription.billing_issue` | ✅ | ✅ | ✅ |
| `subscription.grace_period` | ✅ | ✅ | ✅ |
| `subscription.recovered` | ✅ | ✅ | ✅ |
| `subscription.revoked` | ✅ | ✅ | ✅ |
| `subscription.product_changed` | ✅ | ✅ | ✅ |
| `subscription.uncancelled` | ✅ | ✅ | ✅ |
| `subscription.paused` | — | ✅ | ✅ |
| `subscription.resumed` | — | ✅ | ✅ |
| `subscription.offer_redeemed` | ✅ | — | — |
`subscription.recovered` reaches all three stores, but by two different routes.
Google sends a native `SUBSCRIPTION_RECOVERED` notification. Apple and Stripe send no
recovery notification at all, so theirs is not read off an event type — it is read off
the purchase's own prior state: the handler captures the status under the same row lock
it writes with, and emits the key only when that prior status was `BILLING_ISSUE` and
the incoming one grants access. This is not the guess it replaced. An ordinary renewal
of an already-active subscription has no `BILLING_ISSUE` prior state, so it cannot
produce a false recovery. **A dunning flow that ends on `subscription.recovered` now
works on iOS and web as well as Android.**
`subscription.grace_period` is no longer Google-only. Apple sends
`DID_FAIL_TO_RENEW` with subtype `GRACE_PERIOD` when the app has a billing grace
period configured, and that half announces `grace_period` rather than
`billing_issue` — because the subscriber still has entitlement, exactly as in the
Google case. Apple deliveries without that subtype, and `GRACE_PERIOD_EXPIRED`, stay
`billing_issue`. Stripe announces `grace_period` while an invoice is `past_due`
and smart retries are running; once Stripe stops covering it (`unpaid`,
`incomplete`), the key is `billing_issue`.
`subscription.paused` / `subscription.resumed` have no Apple row because Apple
has no pause: there is nothing to pause and nothing to resume from. Google
pauses natively; Stripe pauses by subscription status.
`subscription.offer_redeemed` is the mirror case and Apple-only for the same
reason. Apple has a dedicated `OFFER_REDEEMED` notification type; Google
delivers an offer redemption as an ordinary purchase/recovery notification with
the offer named inside the purchase resource rather than as its own event type,
and Stripe has no equivalent event at all. **If you rely on
`subscription.offer_redeemed` to stop a win-back campaign, that campaign is
iOS-only today.**
### Plan-change phases [#plan-change-phases]
One real plan change can deliver `subscription.product_changed` twice, and the
`phase` field inside `data` is how you tell the two deliveries apart:
* **No `phase` field — the announcement.** The store says a change is coming. The
product may not have moved at all (a price change on the same product is one of
these), and for a scheduled downgrade it will not move for weeks. Carries
`storeEventType` and `webhookEventId`. A missing `phase` means "announcement", not
"unknown" — the field is never retrofitted onto this producer.
* **`phase` = `"effective"` — the change has taken effect.** Emitted from inside the
purchase write itself, only when the product on the purchase actually moved.
Carries `previousProductId`, `productId` and `changeType`. Apple, Google and Stripe
all produce it.
Google's deferred-change flow is the case that genuinely produces both: an
announcement at deferral, then an effective delivery at the renewal that applies it.
They are separate events with distinct ids, so `webhook-id` dedup does not collapse
them. Apple's `UPGRADE` subtype is the opposite case: Apple has already charged the
subscriber and applied the new tier, so announcing a change as *coming* would be
false — no announcement is sent for it and you receive only the effective delivery.
Billing-state consumers should act on `phase` = `"effective"`; "your plan changes on
the 14th" notifications want the announcement.
### Zero-value charges [#zero-value-charges]
A revenue envelope whose type grants (any of `INITIAL`, `TRIAL_CONVERSION`,
`REACTIVATION`, `RENEWAL` and the one-time types) but whose amount is 0 — a
trial start, a comp, a $0 lead-in or a $0 renewal — reaches only the
customer's own endpoints and chat channels: **Custom Webhook, Slack and
Discord**. Ad and attribution providers never see a $0 "Subscribe": they get
the fact through `subscription.trial.started` (`StartTrial`) instead. If you
consume webhooks through an analytics CRM (Amplitude, Mixpanel, Braze), the
$0 rows are not delivered there; `subscription.*` still is.
### CSV import is history [#csv-import-is-history]
Rows imported from a RevenueCat/Adapty CSV export reach analytics
(ClickHouse ingests every revenue message that carries a revenue id) but
never reach integrations and never produce a project-webhook (v1) delivery:
an import backfills the past, it does not announce the present. Imported
rows grant no credits either. A refunded source row still records its charge
plus its `REFUND` row, so imported gross and net match what the live path
books; subscription charges are labelled by the same cross-store rule —
`TRIAL_CONVERSION` for the first paid row after a trial row,
`REACTIVATION` for a paid row whose predecessor lapsed.
## 4. Verifying signatures [#4-verifying-signatures]
Every delivery carries both a `webhook-*` and a Svix-compatible `svix-*` alias for the same three headers, so you can use whichever your HTTP library or an existing Svix SDK expects:
| Header | Alias | Description |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------------------ |
| `webhook-id` | `svix-id` | The event id — same value as the payload's `id` field. |
| `webhook-timestamp` | `svix-timestamp` | Unix seconds (as a string) when the request was signed. |
| `webhook-signature` | `svix-signature` | One or more space-separated `v1,` signatures — see [rotation](#5-secret-rotation) below. |
### Signature scheme [#signature-scheme]
```
toSign = `${webhook-id}.${webhook-timestamp}.${rawRequestBody}`
secret = base64decode(whsecValue without the "whsec_" prefix)
signature = base64( HMAC-SHA256(secret, toSign) )
header = `v1,${signature}`
```
Notes:
* `rawRequestBody` is the exact bytes received on the wire — parse it for verification only *after* checking the signature, since re-serializing JSON can change byte-for-byte content.
* During a secret rotation, `webhook-signature` (and `svix-signature`) carry **one `v1,...` candidate per currently active secret**, space-separated. Verification passes if **any** candidate matches — try each one.
* Reject requests whose `webhook-timestamp` is too far from your own clock (5 minutes is a reasonable tolerance) to defend against replay of an intercepted request.
### Node.js [#nodejs]
```js
const crypto = require("node:crypto");
function verifyRovenueWebhook({ id, timestamp, signatureHeader, rawBody, secrets, toleranceSeconds = 300 }) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error("timestamp outside tolerance");
}
const toSign = `${id}.${timestamp}.${rawBody}`;
const candidates = signatureHeader.split(" ");
for (const secret of secrets) {
const encoded = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
const key = Buffer.from(encoded, "base64");
const expected = crypto.createHmac("sha256", key).update(toSign).digest();
for (const part of candidates) {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) continue;
const candidate = Buffer.from(sig, "base64");
if (candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)) {
return true;
}
}
}
throw new Error("no matching signature");
}
module.exports = { verifyRovenueWebhook };
```
Pass every secret you currently have on file for the endpoint (typically one, or two during a rotation window) as the `secrets` array.
### Python [#python]
```python
import hashlib
import hmac
import base64
import time
def verify_rovenue_webhook(id_, timestamp, signature_header, raw_body, secrets, tolerance_seconds=300):
now = int(time.time())
if abs(now - int(timestamp)) > tolerance_seconds:
raise ValueError("timestamp outside tolerance")
to_sign = f"{id_}.{timestamp}.{raw_body}".encode()
candidates = signature_header.split(" ")
for secret in secrets:
encoded = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
key = base64.b64decode(encoded)
expected = hmac.new(key, to_sign, hashlib.sha256).digest()
for part in candidates:
version, _, sig = part.partition(",")
if version != "v1" or not sig:
continue
candidate = base64.b64decode(sig)
if hmac.compare_digest(candidate, expected):
return True
raise ValueError("no matching signature")
```
***
## 5. Secret rotation [#5-secret-rotation]
* Rotating an endpoint's secret mints a brand-new `whsec_...` value and returns it once, the same as at creation.
* The **previous** secret stays valid for **24 hours** after rotation. During that grace window, `webhook-signature` contains a `v1,...` entry signed with each active secret, so a receiver that hasn't picked up the new value yet can still verify.
* After 24 hours the old secret is dropped from the header entirely — update your stored secret before then.
***
## 6. Delivery, retries & timeouts [#6-delivery-retries--timeouts]
* Delivery is **at-least-once** — always design your handler to be safe to run more than once for the same event (see [idempotency](#7-idempotency--at-least-once-delivery)).
* Each attempt has a **15-second wall-clock cap** covering connect, headers and the whole response body; anything slower is aborted, treated as a failed attempt and retried per the schedule below. That includes a 2xx whose body is still arriving when the cap hits: the attempt counts as failed and the event is delivered again, which is one more reason handlers must be idempotent.
* **2xx** — delivery is marked successful, no retry.
* **3xx** — redirects are never followed; treated as a permanent failure (no retry).
* **408, 425, 429** — treated as "try again": retried.
* Other **4xx** — treated as a permanent rejection: sent straight to the dead-letter state, no further attempts.
* **5xx** (or any other unexpected status) — retried.
### Retry schedule [#retry-schedule]
Up to 8 attempts total (the first attempt plus 7 retries), spread over roughly 31 hours:
| Attempt | Wait since previous attempt | Elapsed since first attempt |
| ------- | --------------------------- | --------------------------- |
| 1 | — | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 1h | 1h 12m 30s |
| 6 | 6h | 7h 12m 30s |
| 7 | 12h | 19h 12m 30s |
| 8 | 12h | ≈31h 12m 30s |
If attempt 8 still fails (or a non-retriable status is returned earlier), the delivery moves to a **dead-letter** state and is shown as failed in the Delivery Log. You can manually **redeliver** it from there at any time — redelivery is not limited by the retry schedule, only by how long the originating event is retained.
**Redelivery window:** roughly **3 days from the time of the event**. Past that the originating event is pruned and redelivery returns a `410` — the delivery row stays in the log as a record, but there is nothing left to replay. That window is deliberately more than a day longer than the ≈31-hour retry schedule above, so a fully-exhausted dead-letter is always still redeliverable when you find it.
***
## 7. Idempotency & at-least-once delivery [#7-idempotency--at-least-once-delivery]
Dedupe on the `webhook-id` header (equivalently, the payload's `id` field): store it once you've successfully processed a delivery, and skip any delivery whose id you've already seen.
`webhook-id` is stable for a given underlying event — it does not change across automatic retries, and it also does **not** change when you manually redeliver that same past event from the Delivery Log, since redelivery replays the original stored event rather than minting a new one. A manual redeliver should therefore be treated by your handler exactly like a very late retry of the same event, not as a new occurrence.
This also holds for a **web** funnel event: its `id` is stable across an automatic retry, a manual redeliver, and a replay of the underlying event stream, and it is the same value that identifies the event internally, so it can be used to join a webhook delivery back to other data about the same event.
**Manual redeliver of a native (device) funnel event does not currently work.** Clicking redeliver on a `funnel.session.started` / `.advanced` / `.completed` / `.dismissed` delivery that originated from a device returns a `410` ("event expired") even when the event is well within the redelivery window — a known, parked gap in how the Delivery Log looks up the originating event for that one aggregate. Automatic retries of a still-in-flight native delivery are unaffected; only the manual **redeliver** action on an already-completed or dead-lettered native delivery is broken. Redelivering a web-originated funnel event works as described above.
***
## 8. Endpoint requirements [#8-endpoint-requirements]
* The URL must use `https://`. Plain `http://` is only accepted against a non-production Rovenue deployment (self-hosted local/dev), never in production.
* The URL may not embed userinfo (`https://user:pass@host/...`).
* The target host must resolve to a publicly routable address — loopback, private (RFC 1918), link-local (including the `169.254.169.254` cloud metadata address), unique-local, and carrier-grade-NAT ranges are all rejected, both at save time and at delivery time (so a hostname that starts resolving privately after being saved still gets blocked).
# Singular (/docs/integrations/singular)
# Singular [#singular]
The Singular integration forwards Rovenue revenue events (purchases, trial conversions, renewals, cancellations) and subscription-lifecycle events (trials, grace period, billing issues, plan changes) to Singular's **Server-to-Server EVENT API**, so purchases and subscription-lifecycle changes made in your app show up as Singular events, attributable back to the ad campaign that acquired the user.
Singular is a mobile attribution / marketing-measurement platform, not an analytics warehouse: this integration sends **events, not a full profile sync**. It does not push user properties and does not read anything back from Singular.
***
## Wire contract [#wire-contract]
Singular has two EVENT endpoint versions. This integration picks between them per event, based on which device identifier is available on the subscriber (see [Identity resolution](#identity-resolution)):
| When the subscriber has | Endpoint | Device field |
| ----------------------------------------------- | ------------------------------------------ | ------------ |
| `$singularDeviceId` (Singular Device ID / SDID) | `POST https://s2s.singular.net/api/v2/evt` | `sdid` |
| `$idfa` (no SDID) | `POST https://s2s.singular.net/api/v1/evt` | `idfa` |
| `$gpsAdId` (no SDID, no IDFA) | `POST https://s2s.singular.net/api/v1/evt` | `aifa` |
All request parameters — including your SDK key (`a`) and app ID (`i`) — are sent as `application/x-www-form-urlencoded` **POST body** fields, never as a query string. Every event also carries `n` (the mapped event name), `is_revenue_event`/`amt`/`cur` on revenue events (see [Event mapping](#3-event-mapping)), and an `e` field — a JSON object with `outbox_event_id` and `rovenue_event` — so every Singular event traces back to its Rovenue source. Singular does not deduplicate events server-side; Rovenue's own delivery pipeline is the real dedup guarantee.
**Known limitations:**
* **The subscriber's IP address (`ip`) is only sent when Rovenue already has it**, and is never derived from Singular's `use_ip` fallback — that would attribute the event to Rovenue's own server IP rather than your subscriber's device, corrupting Singular's geolocation-based attribution. In practice, most **server- or webhook-driven** revenue events (a purchase your backend reports, or a store webhook Rovenue relays) have no client HTTP request in the loop and therefore no known IP, so `ip` is frequently omitted and Singular may reject those events with `missing argument: ip`. If you want these attributed, pass `identityContext.ip` (the real device/customer IP, not your server's) on your own calls to [`POST /v1/events`](/docs/reference/methods#track) — Rovenue forwards it through to Singular whenever it's present.
* **`att_authorization_status`** (Apple's App Tracking Transparency status) is documented by Singular as "Always required" for iOS — but Rovenue does not fabricate a value. It is sent only when the subscriber's `$attConsentStatus` reserved attribute is set (mapped to Singular's numeric codes: `notDetermined`→`0`, `restricted`→`1`, `denied`→`2`, `authorized`→`3`), and only on the iOS branch of the identity ladder. If `$attConsentStatus` isn't set, the field is omitted rather than defaulted to `0`.
* The platform field (`p`) is always `iOS`/`Android` when the IDFA/GAID ladder branch matched, but is only sent for the SDID branch when the subscriber's `platform` attribute is set to a recognized value — Rovenue never guesses a platform.
***
## 1. Get your credentials from Singular [#1-get-your-credentials-from-singular]
1. In the Singular dashboard, open **Developer Tools → SDK Integration → SDK Keys**.
2. Copy your **SDK Key** — not the Reporting API Key, which Singular's own EVENT endpoint rejects.
3. Copy your app's **App ID** — the OS bundle id (iOS, e.g. `com.example.app`) or package name (Android) you registered the app with in Singular. This is required on every event (`i`); there is no way to deliver without it.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Singular** under **Attribution** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **App ID (bundle id / package name)** — paste the app id from step 1.
* **SDK key** — paste the SDK key from step 1.
4. Click **Validate** (see [Validation](#validation) below — it only checks that both fields are filled in, it sends nothing to Singular).
5. Choose which events to forward, optionally override the default mapping, then activate.
***
## 3. Event mapping [#3-event-mapping]
Every forwarded key becomes a Singular event name, per the mapping table below (override any name in the drawer's mapping step). Three keys map onto Singular's own standard event vocabulary; the rest use a short, Rovenue-namespaced custom event name, since Singular has no built-in equivalent:
| Rovenue event key | Default Singular event name |
| ------------------------------- | --------------------------- |
| `revenue.INITIAL` | `sng_subscribe` |
| `revenue.TRIAL_CONVERSION` | `sng_subscribe` |
| `revenue.RENEWAL` | `sng_ecommerce_purchase` |
| `revenue.CREDIT_PURCHASE` | `sng_ecommerce_purchase` |
| `revenue.REFUND` | *(not mapped — see below)* |
| `revenue.CANCELLATION` | `rovenue_cancellation` |
| `subscription.trial.started` | `sng_start_trial` |
| `subscription.cancel_requested` | `rovenue_cancel_requested` |
| `subscription.expired` | `rovenue_expired` |
| `subscription.billing_issue` | `rovenue_billing_issue` |
| `subscription.grace_period` | `rovenue_grace_period` |
| `subscription.uncancelled` | `rovenue_uncancelled` |
| `subscription.product_changed` | `rovenue_product_changed` |
| `subscription.paused` | `rovenue_paused` |
| `subscription.recovered` | `rovenue_recovered` |
| `subscription.revoked` | `rovenue_revoked` |
| `subscription.offer_redeemed` | `rovenue_offer_redeemed` |
Singular's own EVENT endpoint reference caps event names at 32 ASCII characters — that's why the lifecycle names above drop the word "subscription" (Rovenue's usual `rovenue_subscription_` convention would exceed the limit for several of these keys).
**On `revenue.REFUND`:** Singular's full standard-event vocabulary has no reversal, cancellation, or refund event, and its EVENT endpoint documents no negative-amount convention for `amt`. The only cancellation-adjacent idea in Singular's own docs ("Order Canceled") is listed as a freeform, non-standard suggestion with no reserved name or reversal semantics — so, unlike the Airbridge integration (which has a real dedicated reversal event to map to), `revenue.REFUND` is intentionally left unmapped here and is skipped rather than forwarded.
Every forwarded event carries `e.rovenue_event` (the original Rovenue event key) and `e.outbox_event_id`, so you can always trace a Singular event back to its Rovenue source.
**Amount/currency are included only when both are present and valid.** Rovenue never guesses a currency (it does **not** default to `"USD"`). When a revenue event has an amount but no known currency (or an unparseable amount), the event is still forwarded — with `is_revenue_event=true` and no amount — rather than dropped; Singular's own docs describe this exact pattern for "a revenue event that has no amount."
***
## Identity resolution [#identity-resolution]
Every event needs a Singular device identifier. Rovenue checks, in order:
1. **`$singularDeviceId`** — Singular's own Device ID (SDID), obtained from the Singular SDK once it has initialized. This is Singular's current, recommended identifier (it works across iOS, Android, web, and more) and routes the event through Singular's newer V2 EVENT endpoint.
2. **`$idfa`** — Apple's Identifier for Advertisers, if no SDID is set. Routes through the legacy V1 endpoint.
3. **`$gpsAdId`** — Android's Google Advertising ID, if neither of the above is set. Also routes through V1.
Advertising ids are default-deny (see [Advertising ids](/docs/integrations/subscriber-identity-attributes#advertising-ids)): `$idfa` is used only when the subscriber's `$attConsentStatus` is `authorized`, and `$gpsAdId` is never used, because no Android opt-in signal exists. Set `$singularDeviceId` to attribute every other user.
If none of these survive for a subscriber, every event for that subscriber is skipped with reason `no_user_data` (visible in the connection's Delivery Log).
See [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes) for the full reserved-attribute reference, including `$singularDeviceId`, `$idfa`, and `$gpsAdId`.
***
## Validation [#validation]
Singular's EVENT/SESSION endpoints are write-only and reachable with just an SDK key — Singular's own docs state that device-level data "cannot be deleted after ingestion," so there is no zero-footprint, read-only way to confirm an SDK key/App ID pair is valid without sending a real, permanent, attributable event.
So, like the AppsFlyer and Airbridge integrations, clicking **Validate** here only checks that both fields are filled in — **the first real event delivery is the live proof**. Check the connection's Delivery Log after activating to confirm events are landing.
***
## Verify events are arriving [#verify-events-are-arriving]
1. Open **Apps → Singular → Delivery Log** in the Rovenue dashboard and check for `succeeded` rows with HTTP `200` and a body `status` of `"ok"`.
2. In the Singular SDK Console, look up the test device by its device identifier and confirm the mapped event names above are appearing.
3. If events are not appearing:
* Singular's EVENT API always answers HTTP `200` — a rejected request still shows as `200` with a body `{"status":"error","reason":"..."}`. Check the Delivery Log's response body for the `reason` string.
* A reason containing "missing argument", "invalid platform", "no device ID supplied", or "should have an ... param" means the request itself needs fixing (Rovenue treats these as permanent failures and does not retry them).
* `missing argument: ip` is expected for server/webhook-driven events with no known subscriber IP — see the "Known limitations" note in [Wire contract](#wire-contract) for how to send one.
* Any other error reason, or a non-`200` HTTP status (`429`/`5xx`), is retried automatically.
* Confirm the subscriber has `$singularDeviceId`, `$idfa`, or `$gpsAdId` set (missing all three skips silently with `no_user_data`).
* Ensure the event type you're sending is included in the connection's enabled events.
# Slack (/docs/integrations/slack)
# Slack [#slack]
The Slack integration posts a short, human-readable message to a Slack channel for every Rovenue event you enable — revenue events (purchases, trial conversions, renewals, refunds, cancellations), subscription-lifecycle events (trials, grace period, billing issues, plan changes), funnel-session events (started, advanced, paid, completed, abandoned, dismissed, custom actions), and credit-ledger events. It's a notification channel, not a data pipeline: unlike Amplitude/Mixpanel/AppsFlyer/Adjust, it subscribes to **all four** Rovenue fanout topics and every one of the 30 public event keys, since there's no reason to scope a Slack alert to a single event family the way an analytics or attribution tool would.
***
## 1. Create an incoming webhook in Slack [#1-create-an-incoming-webhook-in-slack]
1. In Slack, create (or reuse) an app and enable **Incoming Webhooks** for your workspace — see [Slack's incoming webhooks guide](https://api.slack.com/messaging/webhooks) if you haven't done this before.
2. Click **Add New Webhook to Workspace**, choose the channel Rovenue events should post to, and authorize it.
3. Copy the generated webhook URL — it looks like `https://hooks.slack.com/services/T000/B000/xxxxxxxxxxxxxxxxxxxxxxxx`.
> Treat this URL as a secret: anyone who has it can post messages to your channel.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **Slack** under **Communication & alerts** and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Incoming webhook URL** — paste the URL from step 1.
4. Click **Validate**.
> **Validate posts a real message.** Unlike a zero-footprint credential check, Rovenue confirms a Slack incoming webhook by actually posting `Rovenue connected :white_check_mark:` to the configured channel — the same way you'd confirm a Slack integration by hand. This is intentional (it's the standard way to prove a Slack webhook works) but it means clicking **Validate** again posts the message again; it is not deduplicated the way Amplitude's/Mixpanel's credential probes are.
5. Choose which events to forward, optionally review the mapping step, then activate.
***
## 3. Event mapping [#3-event-mapping]
Slack has no vendor-specific event vocabulary of its own — every one of Rovenue's 30 public event keys, including the seven `funnel.session.*` keys, maps to itself. The mapping step exists only if you want to relabel what shows up in the connection's Delivery Log; it has no effect on the Slack message text.
***
## 4. Message format [#4-message-format]
Each event becomes a single Slack message (`{ "text": "..." }`, no Block Kit), prefixed with an emoji per event family:
| Event family | Prefix | Example |
| -------------------------------------------- | -------------- | ---------------------------------------------------------------------- |
| `revenue.*` | `:moneybag:` | `:moneybag: revenue.RENEWAL — 9.99 USD · prod_gold · subscriber sub_…` |
| `subscription.*` (+ `subscriber.identified`) | `:repeat:` | `:repeat: subscription.trial.started · subscriber sub_…` |
| `credit.ledger.appended` | `:coin:` | `:coin: credit.ledger.appended · subscriber sub_…` |
| `funnel.session.*` | `:footprints:` | `:footprints: funnel.session.started · subscriber sub_…` |
The `subscriber` segment shows only the **first 4 characters** of the Rovenue subscriber id, followed by an ellipsis — enough to recognize repeat activity from the same subscriber in a channel, without printing an identifier a reader could act on. A `funnel.session.*` message never shows an amount, even for `.paid` (money is only ever reported on `revenue.*`), and the subscriber segment follows the webhook's `subscriberId`. A native (device) event shows it whenever the device sent its Rovenue id, identified or anonymous. A web event shows it on `funnel.session.completed` only. A GDPR-erased subject's events never show it.
**No PII ever appears in a Slack message.** The message builder only ever reads the event key, amount, currency, product id, and (masked) subscriber id off the event — it never reads `identityContext` (email, phone, IP, user agent) or subscriber attributes (arbitrary host-app-set key/values), regardless of what a subscriber's profile carries.
***
## 5. At-least-once delivery and duplicate messages [#5-at-least-once-delivery-and-duplicate-messages]
Slack has no consumer-side dedup key, so the pipeline's at-least-once delivery can rarely duplicate a message (crash between POST and status write) — accepted for a notification channel.
***
## 6. Verify events are arriving [#6-verify-events-are-arriving]
1. Trigger a real event from your app (a purchase, a trial start, a funnel session) and check the configured Slack channel — messages typically arrive within a few seconds.
2. If messages are not appearing:
* Check **Apps → Slack → Delivery Log** in Rovenue for HTTP status codes and error messages.
* A `404` or `410` response (Slack's `no_service` / `no_active_hooks`) means the webhook was deleted or deactivated in Slack — recreate it and update the connection's credentials.
* A `400` response (Slack's `invalid_payload`) means the request was malformed; this indicates a bug rather than a misconfiguration and should not recur on retry.
* Ensure the event type you're sending is included in the connection's enabled events.
* Rovenue only ever POSTs to `https://hooks.slack.com/...` — a webhook URL hosted anywhere else is rejected at both credential-validation time and delivery time.
# Subscriber Identity Attributes (/docs/integrations/subscriber-identity-attributes)
# Subscriber Identity Attributes [#subscriber-identity-attributes]
Rovenue reserves a set of `$`-prefixed subscriber attribute keys for well-known identity and attribution fields. Reserved keys are validated on write (each value is capped at 500 characters) and are available to [outbound integrations](/docs/integrations/outbound-webhooks) for identity resolution.
Set them the same way as any other attribute, via the SDK's `setAttributes` call or from your backend by calling `POST /v1/subscribers/:appUserId/attributes` with your **secret** key (the path segment is your app user id, or the device's rovenueId).
***
## Vendor-id attributes [#vendor-id-attributes]
These six keys mirror the reserved attribute names used by RevenueCat, so a migration from RevenueCat can carry the same attribute keys over unchanged:
| Key | Purpose |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `$appsflyerId` | AppsFlyer's device-level `appsflyer_id`, used to attribute revenue back to an AppsFlyer campaign. |
| `$adjustId` | Adjust's `adid` device identifier, used to attribute revenue back to an Adjust campaign. |
| `$firebaseAppInstanceId` | Firebase/Google Analytics' `app_instance_id`, used to join subscriber events with a Firebase Analytics or BigQuery export. |
| `$mixpanelDistinctId` | Mixpanel's `distinct_id`, used to associate revenue events with an existing Mixpanel user profile. |
| `$amplitudeDeviceId` | Amplitude's `device_id`, used to associate revenue events with an anonymous Amplitude device profile. |
| `$amplitudeUserId` | Amplitude's `user_id`, used to associate revenue events with an identified Amplitude user. |
All six accept any non-empty string value up to 500 characters — Rovenue does not validate vendor-specific id formats.
These five keys extend the same convention to the Wave-2 outbound integrations (OneSignal, Braze, Iterable, Airbridge, Singular):
| Key | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `$onesignalId` | OneSignal's internal user id (OneSignal ID), obtained via `OneSignal.User.onesignalId`, used to target push notifications for this subscriber. |
| `$brazeAliasName` | Braze's alias name, used to resolve this subscriber to an existing Braze user profile without an external user id. |
| `$iterableUserId` | Iterable's `userId`, used to associate revenue events with an existing Iterable user profile. |
| `$airbridgeDeviceId` | Airbridge's device identifier, used to attribute revenue back to an Airbridge campaign. |
| `$singularDeviceId` | Singular's device identifier, used to attribute revenue back to a Singular campaign. |
All five accept any non-empty string value up to 500 characters — Rovenue does not validate vendor-specific id formats.
***
## Advertising ids [#advertising-ids]
| Key | Purpose | Leaves Rovenue toward an integration |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `$idfa` | Apple's Identifier for Advertisers. | Only when `$attConsentStatus` is `authorized`. |
| `$attConsentStatus` | App Tracking Transparency status: `notDetermined`, `restricted`, `denied` or `authorized` (any other value is rejected on write). | Read by the gate, and sent to Singular as `att_authorization_status`. |
| `$gpsAdId` | Google's Android advertising id. | Never. No Rovenue SDK or API carries an explicit Android opt-in or "limit ad tracking" signal. |
| `$idfv` | Apple's identifier for vendor. It is not an advertising id. | No integration sends it. |
Advertising ids are **default-deny**: an ad id travels only with an explicit authorization signal. A missing status (the ATT prompt was never shown, or an older app build never reported it), `notDetermined`, `restricted` and `denied` all keep `$idfa` inside Rovenue. ATT is an iOS permission, so an `authorized` status does not let `$gpsAdId` through. The ids are still stored on the subscriber and appear in its data export. They are removed only from integration deliveries. To keep attributing users without an advertising id, set the attribution vendor's own device id (`$adjustId`, `$singularDeviceId`, `$appsflyerId`, `$airbridgeDeviceId`).
***
## Notes [#notes]
* Setting a vendor-id attribute does not, by itself, forward any data anywhere — it only makes the id available for integrations (such as the Wave-1 AppsFlyer/Adjust/Firebase/Mixpanel/Amplitude outbound integrations) to read when resolving a subscriber's identity on the destination platform.
* An unrecognised `$`-prefixed key is still treated as reserved and rejected, so typos in a vendor-id key name fail the write instead of being silently stored as an unknown attribute.
* Subscriber attributes deliberately never appear in outbound webhook payloads — vendor ids and other attributes are read only at delivery time, by the integrations that need them. See [Outbound Webhooks](/docs/integrations/outbound-webhooks) for the PII-free payload contract that keeps them out.
# TikTok Events API (/docs/integrations/tiktok-events)
# TikTok Events API [#tiktok-events-api]
The TikTok Events integration forwards Rovenue subscription and revenue events to the TikTok Events API (server-side), enabling reliable attribution for TikTok campaigns without depending solely on the client-side pixel.
***
## 1. Get your credentials from TikTok [#1-get-your-credentials-from-tiktok]
1. Open [TikTok Events Manager](https://ads.tiktok.com/i18n/events_manager).
2. Select your **Data Source** (Web/App Pixel or SDK).
3. Go to **Manage → Access Token** (or **Settings → Access Token** depending on your interface version).
4. Copy:
* **Access Token** — the long-lived token for server-side API calls.
* **Pixel Code** — the alphanumeric identifier shown at the top of the Data Source page (e.g. `C8ABCDEF1234567890`).
> Keep these values secure. The access token grants write access to your Events data source.
***
## 2. Add the integration in Rovenue [#2-add-the-integration-in-rovenue]
1. In the Rovenue dashboard, navigate to **Apps** (left sidebar).
2. Find **TikTok Events** in the integrations grid and click it.
3. The side drawer opens. Fill in the **Credentials** step:
* **Pixel Code** — paste the Pixel Code from step 1.
* **Access Token** — paste the access token from step 1.
4. Click **Validate** — Rovenue calls the TikTok Events API test endpoint to verify the credentials are accepted.
5. Proceed through the remaining drawer steps (event mapping, test event code, enable).
6. Click **Save** to activate the integration.
***
## 3. Identity context & consent [#3-identity-context--consent]
When your SDK passes an `identityContext` with an event, the following fields are forwarded to TikTok Events API:
| Field | Forwarded as | Hashing |
| ---------------- | ----------------------------------- | --------------------------------------------- |
| `email` | `user_data.em` | SHA-256 (lowercased + trimmed before hashing) |
| `phone` | `user_data.ph` | SHA-256 (digits only before hashing) |
| `externalId` | `user_data.external_id` | SHA-256 (trimmed before hashing) |
| `ip` | `context.ip` | Forwarded verbatim — not hashed |
| `userAgent` | `context.user_agent` | Forwarded verbatim — not hashed |
| `ttclid` / `ttp` | `context.ttclid` / `properties.ttp` | Forwarded verbatim — not hashed |
| `fbp` / `fbc` | Not sent to TikTok Events | N/A (Meta-specific fields) |
**Consent is your responsibility.**
Rovenue acts as a **data processor**. Your application is the **data controller**. You must obtain the end-user's informed consent before passing any PII in `identityContext`. GDPR, CCPA, KVKK, and similar regulations place the consent obligation on the controller (you), not the processor (Rovenue).
Additional guarantees:
* Rovenue **never persists** `identityContext` fields at rest. PII is hashed in-process; raw values are not written to any database.
* `response_body` from TikTok Events API is stored but **truncated to 4096 bytes**.
* The wire format uses camelCase JSON; each envelope carries a numeric `version` field (currently `1`) and a stable `eventId` for downstream dedupe.
***
## 4. Verify events are arriving [#4-verify-events-are-arriving]
1. Go back to **TikTok Events Manager**.
2. Select your Data Source and open the **Diagnostics** tab (sometimes labelled **Test Events**).
3. In the Rovenue drawer's **Test** step, you set a `test_event_code`. Trigger a real event from your app or use the Rovenue **Send Test Event** button.
4. The Diagnostics tab should show the event within a few seconds, confirming the full pipeline is working.
If events are not appearing:
* Verify the Pixel Code and Access Token are correct (use **Validate** in the drawer).
* Check **Apps → TikTok Events → Delivery Log** in Rovenue for HTTP status codes and error messages.
* Ensure `enabledEvents` in the connection includes the event type you are sending.
* TikTok Events API returns a `code` field in its JSON response — code `0` means success; any other value is an error. The delivery log shows the raw response body.
# Android (Kotlin) (/docs/platforms/android-kotlin)
### Add the dependency [#add-the-dependency]
Add the SDK to your module-level `build.gradle.kts`:
```kotlin
dependencies {
implementation("io.rovenue:sdk:0.16.0")
}
```
Or in Groovy DSL (`build.gradle`):
```groovy
dependencies {
implementation 'io.rovenue:sdk:0.16.0'
}
```
The SDK drives Play Billing natively. Receipt processing and server-side validation happen automatically via the Rovenue API — you do not need to call Play Billing directly.
### Configure the SDK [#configure-the-sdk]
Call `Rovenue.configure` once in your `Application.onCreate()`, before any component starts:
```kotlin
import android.app.Application
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.generated.LogLevel
class App : Application() {
override fun onCreate() {
super.onCreate()
val versionName = packageManager
.getPackageInfo(packageName, 0)
.versionName
Rovenue.configure(
apiKey = "rov_pub_YOUR_API_KEY",
logLevel = LogLevel.INFO,
appVersion = versionName,
context = this,
)
}
}
```
Pass `context` (the `Application` itself works, or any `Context` — the SDK
keeps `context.applicationContext`). Without it, `getOfferings()` still
works, but `purchase()` and `restorePurchases()` throw
`RovenueException(kind = ErrorKind.STORE_PROBLEM)` — Play Billing needs a
`Context` to bind to, and there is no fallback.
**Self-hosting?** `baseUrl` is optional and defaults to the hosted endpoint `https://api.rovenue.io`. Override it to point at your own deployment, e.g. `Rovenue.configure(apiKey = "rov_pub_YOUR_API_KEY", baseUrl = "https://api.acme.com", appVersion = versionName)`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
Register `App` in your `AndroidManifest.xml`:
```xml
```
### Types on this page [#types-on-this-page]
```kotlin
// Kotlin mirror of Rovenue.configure's parameters — there is no separate
// RovenueConfig type on this platform, only named parameters.
fun configure(
apiKey: String,
baseUrl: String? = null, // optional — defaults to https://api.rovenue.io
logLevel: LogLevel = LogLevel.WARN, // OFF | ERROR | WARN | INFO | DEBUG | TRACE
appVersion: String? = null,
context: Context? = null, // required for purchase()/restorePurchases()
environment: String? = null,
)
```
| Field | Type | Description |
| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `String` | Public SDK key from your Rovenue dashboard. Prefix `rov_pub_`. |
| `baseUrl` | `String?` | Optional. Defaults to `https://api.rovenue.io`. Override only when self-hosting (`https://`; `http://` allowed for `localhost` only). |
| `logLevel` | `LogLevel` | Verbosity of SDK logs. Defaults to `WARN` — turn up to `INFO`/`DEBUG` locally, never ship `DEBUG`/`TRACE` in production. |
| `appVersion` | `String?` | Overrides the version read from the package manager. |
| `context` | `Context?` | An Android `Context` (the SDK stores `context.applicationContext`). Pass it — `purchase()` and `restorePurchases()` throw `STORE_PROBLEM` without it. |
| `environment` | `String?` | Optional environment label forwarded with requests. |
Call `configure` in `Application.onCreate` — not inside an `Activity`. Calling
any other SDK method before `configure` has run throws a plain
`IllegalStateException` — **not** a `RovenueException` — so `catch (e:
RovenueException)` will not observe it. This is a programming error to fix by
ordering (always `configure` first), not a condition to catch. See [Calling
the SDK before configure()](/docs/reference/errors#calling-the-sdk-before-configure).
### Wire foreground signals [#wire-foreground-signals]
Use `ProcessLifecycleOwner` from `androidx.lifecycle` to observe the process-level foreground state. Add the observer in `Application.onCreate`, after the `configure` call:
```kotlin
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import dev.rovenue.sdk.Rovenue
class App : Application() {
override fun onCreate() {
super.onCreate()
val versionName = packageManager
.getPackageInfo(packageName, 0)
.versionName
Rovenue.configure(
apiKey = "rov_pub_YOUR_API_KEY",
appVersion = versionName,
context = this,
)
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Rovenue.shared.setForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
Rovenue.shared.setForeground(false)
}
}
)
}
}
```
`ProcessLifecycleOwner` fires `onStart`/`onStop` at the process level — not per-Activity. This is the correct granularity for the SDK's polling cadence.
***
## Purchases [#purchases]
Use `getOfferings()` to fetch products configured in your Rovenue dashboard, then `purchase(pkg)` to drive the full Play Billing flow. The SDK validates the receipt server-side and resolves with updated entitlements.
```kotlin
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.generated.ErrorKind
val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull()
if (pkg != null) {
try {
// `activity` is your current Activity — Play Billing requires it.
val result = Rovenue.shared.purchase(activity, pkg)
if (result.isDeferred) {
// Ask-to-Buy / parental-control hold — no entitlements yet. The SDK
// reconciles it in the background; collect Rovenue.shared.changes for
// ChangeEvent.ENTITLEMENTS_CHANGED to know when it resolves.
} else {
// result.entitlements and result.virtualCurrencies are already up to date
}
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.PURCHASE_CANCELED -> Unit // User dismissed the store sheet — no action needed
else -> throw e
}
}
}
// Re-validate all prior transactions (e.g. after reinstall):
Rovenue.shared.restorePurchases(activity)
```
```kotlin
// Kotlin mirror of PurchaseResult
data class PurchaseResult(
val entitlements: List,
val virtualCurrencies: Map,
val productId: String,
val storeTransactionId: String,
val isDeferred: Boolean, // true for an Ask-to-Buy / parental-control hold
)
data class Entitlement(
val id: String,
val isActive: Boolean,
val productIdentifier: String,
val store: String,
val expiresIso: String?, // ISO 8601 — present for time-limited entitlements
)
```
| Field | Type | Description |
| -------------------- | ------------------- | ---------------------------------------------------------------------------------------- |
| `entitlements` | `List` | Updated entitlement list after the purchase. |
| `virtualCurrencies` | `Map` | Updated virtual-currency balances after the purchase (currency code → amount). |
| `productId` | `String` | The product identifier that was purchased. |
| `storeTransactionId` | `String` | The Play Store transaction ID. |
| `isDeferred` | `Boolean` | `true` when the purchase is pending external approval — no entitlements are granted yet. |
### Who acknowledges a Play purchase [#who-acknowledges-a-play-purchase]
The SDK is the only party that acknowledges (subscriptions, non-consumables) or consumes (consumables) a Google Play purchase; the Rovenue server never does. It does so on the device, only after the server has verified the receipt, and it re-checks the token's state with Play first, so a purchase that is already acknowledged is left alone. Once the server has verified the purchase, `purchase()` returns its `PurchaseResult` and never throws: if Play cannot take the acknowledgement right then (the device went offline, Play Billing is unavailable), the SDK retries a few times and then leaves it to the next reconcile, which runs on every app launch and foreground — well inside Play's three-day window before it auto-refunds an unacknowledged purchase. A verification failure is never acknowledged, so Play refunds that purchase as intended.
There is no `PurchaseCancelledException`/`PurchasePendingException` — every SDK method throws the single `RovenueException`, discriminated by `.kind`. And there is no `PURCHASE_PENDING` kind at all: as of 0.16.0, a deferred purchase does not throw; it returns normally with `isDeferred = true`. See [Reference: Errors](/docs/reference/errors) for the full 24-value `ErrorKind` taxonomy and [Processing Purchases](/docs/guides/processing-purchases) for the complete purchase API and the `StoreProduct` shape.
***
## Funnels [#funnels]
`getFunnel(placementId, locale)` resolves a placement into the funnel the
current subscriber should see (see [Placements &
Funnels](/docs/guides/placements)). The session is already open
when it returns — hand it to a `FunnelDriver` and host `RovenueFunnelView`
(see [Show a Funnel](/docs/funnels/native/show-a-funnel)):
```kotlin compile=body
val funnel = Rovenue.shared.getFunnel(placementId = "onboarding", locale = "en")
if (funnel != null) {
// null when the placement has no assignment — render nothing.
// Hand `funnel` to a FunnelDriver and host RovenueFunnelView (Show a Funnel).
println(funnel)
}
```
Purchase attribution is automatic: the funnel's `presentedContext` is
attached to the next purchase for you.
See [Placements](/docs/guides/placements) for how placements resolve to a funnel or an A/B experiment variant.
***
## Observing changes [#observing-changes]
`Rovenue.shared.changes` is a `SharedFlow` of change events. Collect it from a coroutine scope tied to your component's lifecycle:
```kotlin
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import dev.rovenue.sdk.Rovenue
// Inside an Activity or Fragment:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Rovenue.shared.changes.collect { event ->
// event: ChangeEvent (ENTITLEMENTS_CHANGED | IDENTITY_CHANGED | VIRTUAL_CURRENCIES_CHANGED)
println("Rovenue change: $event")
}
}
}
```
Collect `Rovenue.shared.changes` from an `Activity` or `ViewModel` scope — not from inside the SDK's own internal scope. Calling `Rovenue.shared.shutdown()` from inside an active collector's coroutine may interrupt the collector mid-flight.
***
## Suspend functions [#suspend-functions]
SDK methods such as `identify`, `logOut`, `refreshEntitlements`, and `refreshVirtualCurrencies` are suspend functions. Call them from a coroutine:
```kotlin
lifecycleScope.launch {
// After your auth flow completes:
Rovenue.shared.identify("user_123")
// When your user signs out:
Rovenue.shared.logOut()
val entitlements = Rovenue.shared.refreshEntitlements()
}
```
See [Identifying Users](/docs/guides/identifying-users) for the full identity model, including what happens when `identify` and `logOut` are called.
***
## Funnels [#funnels-1]
A placement can serve a native funnel: `getFunnel`, then a `FunnelDriver`
bound to `RovenueFunnelView` (`packages/sdk-kotlin/src/main/kotlin/dev/rovenue/sdk/Rovenue.kt:631`) — full path: [Show a funnel](/docs/funnels/native/show-a-funnel).
```kotlin compile=body
import dev.rovenue.sdk.funnelui.FunnelDriver
import dev.rovenue.sdk.funnelui.UniffiFunnelSession
val funnel = Rovenue.shared.getFunnel(placementId = "onboarding")
if (funnel != null) {
val driver = FunnelDriver(UniffiFunnelSession(funnel.session))
println(driver.stateJson())
}
```
***
## What's next [#whats-next]
* [Identifying Users](/docs/guides/identifying-users) — link your backend user to the Rovenue subscriber.
* [Entitlements](/docs/guides/entitlements) — read and gate on active subscriptions.
* [Virtual Currencies](/docs/guides/credits) — read virtual-currency balances and grant/spend via server-side APIs.
* [Processing Purchases](/docs/guides/processing-purchases) — full purchase API and error handling.
* [App Lifecycle](/docs/guides/app-lifecycle) — foreground/background wiring in depth.
* [Reference: Methods](/docs/reference/methods) — full method reference.
* [Reference: Types](/docs/reference/types) — all SDK types in one place.
* [SDK API Reference](/docs/reference/sdk-reference) — the generated Dokka reference for every public Kotlin type and method, at `/api/sdk-kotlin/`.
# Flutter (/docs/platforms/flutter)
### Install the package [#install-the-package]
Add the app-facing package to your `pubspec.yaml`:
```yaml
dependencies:
rovenue_flutter: ^0.16.0
```
Then fetch it:
```bash
flutter pub get
```
`rovenue_flutter` is a federated plugin — `rovenue_flutter_ios` and `rovenue_flutter_android` are pulled in automatically as platform implementations and wrap the same Swift and Kotlin SDKs (and the shared Rust core, `librovenue`) that back the native iOS and Android SDKs. Android needs no further setup.
**iOS: the CocoaPods dependency isn't published yet.** `rovenue_flutter_ios`'s podspec depends on the `Rovenue` pod (`packages/sdk-swift`), which has not been published to CocoaPods Trunk — its podspec still carries a placeholder release-archive checksum. Until that pod ships, `flutter pub get` alone will not resolve on iOS for an app outside this monorepo. In the meantime, override the dependency in your `ios/Podfile` to point at a local checkout of `sdk-swift`:
```ruby
target 'Runner' do
use_frameworks!
pod 'Rovenue', :path => '/path/to/sdk-swift'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
```
Remove the override once `Rovenue` is published to Trunk. `rovenue_flutter_ios` also raises the deployment target to iOS 16.0 to match the `Rovenue` pod's own floor.
### Configure the SDK [#configure-the-sdk]
Call `Rovenue.instance.configure` once, before any other SDK method — the earliest point in `main()` is the right place:
```dart
import 'package:flutter/material.dart';
import 'package:rovenue_flutter/rovenue_flutter.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
await Rovenue.instance.configure(
apiKey: 'rov_pub_YOUR_API_KEY',
);
runApp(const MyApp());
}
```
**Self-hosting?** `baseUrl` is optional and defaults to the hosted endpoint `https://api.rovenue.io`. Override it to point at your own deployment, e.g. `Rovenue.instance.configure(apiKey: 'rov_pub_YOUR_API_KEY', baseUrl: 'https://api.acme.com')`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
### Types on this page [#types-on-this-page]
```dart
Future configure({
required String apiKey, // public SDK key — prefix rov_pub_
String? baseUrl, // optional — defaults to https://api.rovenue.io
RovenueLogLevel logLevel = RovenueLogLevel.warn,
String? appVersion, // override auto-detected version
String? environment,
});
```
| Field | Type | Description |
| ------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `String` | Public SDK key from your Rovenue dashboard. Prefix `rov_pub_`. |
| `baseUrl` | `String?` | Optional. Defaults to `https://api.rovenue.io`. Override only when self-hosting (`https://`; `http://` allowed for `localhost` only). |
| `logLevel` | `RovenueLogLevel` | Verbosity of SDK logs (`off`/`error`/`warn`/`info`/`debug`/`trace`). Defaults to `warn`. |
| `appVersion` | `String?` | Overrides the version auto-read from the app bundle. |
| `environment` | `String?` | Optional environment label forwarded with requests. |
### Wire foreground signals [#wire-foreground-signals]
The SDK uses `setForeground` to manage its internal polling cadence. Wire it to `WidgetsBindingObserver`:
```dart
import 'package:flutter/material.dart';
import 'package:rovenue_flutter/rovenue_flutter.dart';
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State createState() => _MyAppState();
}
class _MyAppState extends State with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
Rovenue.instance.setForeground(true);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
Rovenue.instance.setForeground(state == AppLifecycleState.resumed);
}
@override
Widget build(BuildContext context) => const MaterialApp(home: RootScreen());
}
```
`Rovenue.instance.shutdown()` stops all background work (polling, in-flight refreshes). Reserve it for full app teardown — do **not** call it on every background transition. `setForeground(false)` already pauses polling when the app backgrounds.
***
## Identifying users [#identifying-users]
Anonymous subscribers get a permanent id automatically. Call `identify` once your own auth flow completes, to link that install to your backend user:
```dart
await Rovenue.instance.identify('user_123');
// When your user signs out:
await Rovenue.instance.logOut();
```
Unlike the iOS and Android examples, `packages/sdk-flutter/example` does
not call `identify()` — it demonstrates the anonymous-subscriber path only.
The method exists and works identically on Flutter; add this step if your
app has its own auth. See [Identifying Users](/docs/guides/identifying-users)
for the full identity model.
***
## Entitlements [#entitlements]
```dart
final pro = await Rovenue.instance.entitlement('pro');
if (pro?.active ?? false) {
// unlock the feature
}
final all = await Rovenue.instance.entitlementsAll();
```
```dart
// Entitlement
class Entitlement {
final String id;
final bool active;
final String? expiresAt; // ISO 8601 — absent for non-expiring grants
final String? productId;
}
```
Listen for server-pushed updates with `Rovenue.instance.changes` and re-read on any event:
```dart
final sub = Rovenue.instance.changes.listen((event) {
// event.kind: entitlementsChanged | identityChanged |
// virtualCurrenciesChanged | remoteConfigChanged
Rovenue.instance.entitlementsAll().then((updated) {
// update your local state with `updated`
});
});
// Cancel on teardown:
await sub.cancel();
```
`entitlementsAll()` is a local cache read — never call `refreshEntitlements()`
from inside this listener. `refreshEntitlements()` hits the network and, on
success, re-emits `entitlementsChanged` itself, which would re-invoke this
same listener forever.
See [Entitlements](/docs/guides/entitlements) and [Reacting to Changes](/docs/guides/reacting-to-changes) for the full guides.
***
## Offerings & purchases [#offerings--purchases]
Use `getOfferings()` to fetch products configured in your Rovenue dashboard, then `purchase(product)` to drive the full purchase flow. The SDK triggers the native StoreKit 2 / Play Billing sheet, validates the receipt server-side, and resolves with updated entitlements and virtual-currency balances. On Android the SDK acknowledges or consumes the Play purchase on the device after the server verified it, and `purchase` resolves even when Play cannot take that acknowledgement yet — see [Who acknowledges a Play purchase](/docs/platforms/android-kotlin#who-acknowledges-a-play-purchase).
```dart
import 'package:rovenue_flutter/rovenue_flutter.dart';
final offerings = await Rovenue.instance.getOfferings();
final products = [
for (final offering in offerings.offerings)
for (final package in offering.packages) package.product,
];
if (products.isNotEmpty) {
try {
final result = await Rovenue.instance.purchase(products.first);
// result.entitlements and result.virtualCurrencies are already up to date
} on RovenueException catch (e) {
if (e.kind == RovenueErrorKind.purchaseCanceled) {
// User dismissed the store sheet — no action needed
} else {
rethrow;
}
}
}
```
To re-validate all prior transactions (e.g. after a reinstall):
```dart
await Rovenue.instance.restorePurchases();
```
`purchase` also accepts an optional Android `SubscriptionOption` for base-plan/offer selection: `Rovenue.instance.purchase(product, option: option)`.
See [Processing Purchases](/docs/guides/processing-purchases) for the full API reference including all error kinds and the `StoreProduct` shape.
***
## Funnels [#funnels]
Placements serve native funnels, drawn by `RovenueFunnelView` (see
[Placements](/docs/guides/placements)). There is no
Dart `getFunnel` — the platform side resolves the placement itself:
```dart compile=body
import 'package:rovenue_flutter/rovenue_flutter.dart';
final onboarding = RovenueFunnelView(placementIdentifier: 'onboarding_end');
```
Purchase attribution is automatic: the funnel's `presentedContext` is
attached to the next `purchase()` for you.
On iOS the funnel draws in the device's language only if your app declares
that language: iOS hands an app `Locale.current` from its declared
localizations, so an app that declares only English reaches the SDK as
`en_XX` (`en_TR` on a Turkish device) and shows the funnel's English copy.
Add `CFBundleLocalizations` (for example `en`, `tr`) to
`ios/Runner/Info.plist`, or add the localizations to the Runner project in
Xcode. Android needs nothing.
***
## Funnel attribution [#funnel-attribution]
Resolve a funnel token from a deep link, an explicit install claim, or — on iOS — the clipboard:
```dart compile=body
import 'package:rovenue_flutter/rovenue_flutter.dart';
const incomingUrl = '';
// From a deep link / Universal Link handler:
final claim = await Rovenue.instance.claimFromUrl(incomingUrl);
// Or resolve one explicitly:
final installClaim = await Rovenue.instance.claimInstall();
// iOS only — always resolves null on Android (see the callout below):
final pastedClaim = await Rovenue.instance.claimFromClipboard();
await Rovenue.instance.claimViaEmail('user@example.com');
if (claim != null) {
// claim.subscriberId, claim.funnelAnswersJson (JSON-encoded string)
}
```
Claims resolved asynchronously on the native side (a deferred deep link or a clipboard token picked up after `configure`) arrive on the `funnelClaims` stream, which replays the most recent claim to a new listener:
```dart compile=body
final sub = Rovenue.instance.funnelClaims.listen((claim) {
// handle claim.subscriberId / claim.funnelAnswersJson
});
```
`claimFromClipboard` is an iOS-only path. On Android it always resolves `null` without reading the clipboard — Android's deferred-attribution source is the Play Install Referrer, which `claimInstall` reads for you. The method exists on both platforms so shared code needs no branching, but only `claimInstall` can recover a deferred Android claim.
Flutter's funnel claim is single-shot: `claimFromUrl`/`claimInstall`/`claimFromClipboard` each make one resolution attempt and return (or throw). The React Native SDK's `resolveFunnelClaim` additionally chains a retry/fallback sequence across multiple claim sources — that chain has not been ported to Flutter. Callers that need the same fallback behavior should compose it themselves from the primitives above.
***
## Error handling [#error-handling]
Every public method throws `RovenueException` — never a raw `PlatformException`:
```dart
import 'package:rovenue_flutter/rovenue_flutter.dart';
try {
await Rovenue.instance.identify('user_123');
} on RovenueException catch (e) {
switch (e.kind) {
case RovenueErrorKind.networkUnavailable:
case RovenueErrorKind.timeout:
// e.retryable is true for these — safe to retry
break;
case RovenueErrorKind.invalidApiKey:
case RovenueErrorKind.forbidden:
// configuration problem — do not retry
break;
default:
print('Rovenue error: ${e.kind.name} — ${e.detail}');
}
}
```
```dart
class RovenueException implements Exception {
final RovenueErrorKind kind;
final String detail; // always non-null
final String? serverCode; // present for API-originated errors
final int? httpStatus; // present for API-originated errors
final bool retryable;
}
```
| Field | Type | Description |
| ------------ | ------------------ | ------------------------------------------------------------------------------------- |
| `kind` | `RovenueErrorKind` | Normalized error category, shared across every Rovenue SDK façade. |
| `detail` | `String` | Human-readable message. Falls back to the kind's name when the native side sent none. |
| `serverCode` | `String?` | Server-issued error code, when the error came from an API response. |
| `httpStatus` | `int?` | HTTP status code, when the error came from an API response. |
| `retryable` | `bool` | Whether the operation is safe to retry. |
***
## Feature parity with React Native [#feature-parity-with-react-native]
Flutter tracks the React Native SDK as its parity bar. As of this release:
| Area | Status |
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| Configure, identity, entitlements, virtual currencies | Full parity |
| Offerings, purchase, restore, subscription options | Full parity |
| Remote config, experiments | Full parity |
| `setFallbackPlacements` | Full parity |
| Funnel token extraction (`extractFunnelToken`), `claimFromUrl`/`claimInstall`/`claimViaEmail`/`claimFromClipboard` | Full parity |
| `resolveFunnelClaim` retry/fallback chain | **Not ported** — see [Funnel attribution](#funnel-attribution) above |
***
## Funnels [#funnels-1]
The widget hosts the native renderer as a platform view — there is no Dart
`getFunnel` (`packages/sdk-flutter/rovenue_flutter/lib/src/funnel_view.dart:10-14`) — full path: [Show a funnel](/docs/funnels/native/show-a-funnel).
```dart compile=body
RovenueFunnelView buildOnboardingFunnel() {
return const RovenueFunnelView(placementIdentifier: 'onboarding');
}
```
***
## What's next [#whats-next]
* [Identifying Users](/docs/guides/identifying-users) — link your backend user to the Rovenue subscriber.
* [Entitlements](/docs/guides/entitlements) — read and gate on active subscriptions.
* [Virtual Currencies](/docs/guides/credits) — read virtual-currency balances and grant/spend via server-side APIs.
* [Processing Purchases](/docs/guides/processing-purchases) — full purchase API and error handling.
* [Placements](/docs/guides/placements) — native funnels and how placements resolve them.
* [App Lifecycle](/docs/guides/app-lifecycle) — foreground/background wiring in depth.
* [Reference: Methods](/docs/reference/methods) — full method reference.
* [Reference: Types](/docs/reference/types) — all SDK types in one place.
* [SDK API Reference](/docs/reference/sdk-reference) — the generated dartdoc reference for every public Dart type and method, at `/api/sdk-flutter/`.
# iOS (Swift) (/docs/platforms/ios-swift)
### Install the SDK [#install-the-sdk]
**Swift Package Manager** — in Xcode choose **File → Add Package Dependencies** and enter the repository URL, or add it to your `Package.swift`:
```swift
dependencies: [
.package(url: "https://github.com/broverse/rovenue-ios", from: "0.16.0"),
],
targets: [
.target(name: "YourApp", dependencies: ["Rovenue"]),
]
```
> Supported platforms: iOS 16.0+ (device and simulator) and macOS 12.0+.
**CocoaPods** — add to your `Podfile` and run `pod install`:
```ruby
pod 'Rovenue'
```
### Configure the SDK [#configure-the-sdk]
Call `Rovenue.configure` once, before any SDK method is used. `AppDelegate.application(_:didFinishLaunchingWithOptions:)` is the correct place:
```swift
import UIKit
import Rovenue
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
do {
try Rovenue.configure(
apiKey: "rov_pub_YOUR_API_KEY",
logLevel: .info
)
} catch {
// Only throws for a blank/malformed apiKey — a configuration
// problem to fix before shipping, not a runtime condition to
// recover from.
print("Rovenue.configure failed: \(error)")
}
return true
}
}
```
**Self-hosting?** `baseUrl` is optional and defaults to the hosted endpoint `https://api.rovenue.io`. Override it to point at your own deployment, e.g. `Rovenue.configure(apiKey: "rov_pub_YOUR_API_KEY", baseUrl: "https://api.acme.com")`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
The SDK reads `CFBundleShortVersionString` from the main bundle automatically — you do not need to pass `appVersion` manually.
### Types on this page [#types-on-this-page]
```swift
// Swift mirror of Rovenue.configure's parameters
func configure(
apiKey: String,
baseUrl: String? = nil, // optional — defaults to https://api.rovenue.io
logLevel: LogLevel = .warn, // .off | .error | .warn | .info | .debug | .trace
appVersion: String? = nil, // override auto-detected version
environment: String? = nil
) throws
```
| Field | Type | Description |
| ------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `String` | Public SDK key from your Rovenue dashboard. Prefix `rov_pub_`. |
| `baseUrl` | `String?` | Optional. Defaults to `https://api.rovenue.io`. Override only when self-hosting (`https://`; `http://` allowed for `localhost` only). |
| `logLevel` | `LogLevel` | Verbosity of SDK logs. Defaults to `.warn` — turn up to `.info`/`.debug` locally, never ship `.debug`/`.trace` in production. |
| `appVersion` | `String?` | Overrides the version auto-read from the bundle. |
| `environment` | `String?` | Optional environment label forwarded with requests. |
Call `configure` exactly once, before any other SDK call — the ideal location is `application(_:didFinishLaunchingWithOptions:)`. `configure` itself only throws for a blank/malformed `apiKey`; there is no `RovenueError.notConfigured` case. Calling any *other* SDK method (`Rovenue.shared.…`) before `configure` has ever run is a hard, uncatchable crash (`fatalError`) — not a throwable error on any platform. This is an ordering bug to fix at the call site, not a condition to catch. See [Calling the SDK before configure()](/docs/reference/errors#calling-the-sdk-before-configure).
### Wire foreground signals [#wire-foreground-signals]
The SDK uses `setForeground` to drive its internal polling cadence. Wire it from your `SceneDelegate`:
```swift
import UIKit
import Rovenue
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneWillEnterForeground(_ scene: UIScene) {
Rovenue.shared.setForeground(true)
}
func sceneDidEnterBackground(_ scene: UIScene) {
Rovenue.shared.setForeground(false)
}
}
```
If your app uses the SwiftUI `@main` lifecycle rather than a `SceneDelegate`, observe `scenePhase` from the environment and call `setForeground` from `.onChange(of: scenePhase)`.
***
## Identifying users [#identifying-users]
Anonymous subscribers get a permanent `rovenueId` automatically. Call `identify` once your own auth flow completes, to link that install to your backend user:
```swift
import Rovenue
// After your auth flow completes:
try await Rovenue.shared.identify("user_123")
// When your user signs out:
try await Rovenue.shared.logOut()
let user = await Rovenue.shared.currentUser()
// user.rovenueId (always present), user.appUserId (nil until identify())
```
See [Identifying Users](/docs/guides/identifying-users) for the full identity model, including what happens when `identify` and `logOut` are called.
***
## Purchases [#purchases]
Use `getOfferings()` to fetch products configured in your Rovenue dashboard, then `purchase(_:)` to drive the full StoreKit 2 purchase flow. The SDK validates the receipt server-side and resolves with updated entitlements.
```swift
let offerings = try await Rovenue.shared.getOfferings()
if let pkg = offerings.current?.packages.first {
do {
let result = try await Rovenue.shared.purchase(pkg)
if result.isDeferred {
// Ask-to-Buy / parental-control hold — no entitlements yet. The SDK
// reconciles it in the background; watch `Rovenue.shared.changes` for
// `.entitlementsChanged` to know when it resolves.
} else {
// result.entitlements and result.virtualCurrencies are already up to date
}
} catch let e as RovenueError {
switch e.kind {
case .purchaseCanceled:
break // User dismissed the sheet — no action needed
default:
throw e
}
}
}
// Re-validate all prior transactions (e.g. after reinstall):
try await Rovenue.shared.restorePurchases()
```
```swift
// Swift mirror of PurchaseResult
struct PurchaseResult {
var entitlements: [Entitlement]
var virtualCurrencies: [String: Int64]
var productId: String
var storeTransactionId: String
var isDeferred: Bool // true for an Ask-to-Buy / parental-control hold
}
struct Entitlement {
var id: String
var isActive: Bool
var productIdentifier: String
var store: String
var expiresIso: String? // ISO 8601 — present for time-limited entitlements
}
```
| Field | Type | Description |
| -------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
| `entitlements` | `[Entitlement]` | Updated entitlement list after the purchase. |
| `virtualCurrencies` | `[String: Int64]` | Updated virtual-currency balances after the purchase (currency code → amount). |
| `productId` | `String` | The product identifier that was purchased. |
| `storeTransactionId` | `String` | The StoreKit transaction ID. |
| `isDeferred` | `Bool` | `true` when the purchase is pending external approval — no entitlements are granted yet. |
There is no `RovenueError.purchasePending` case — as of 0.16.0, a deferred purchase (Ask-to-Buy) does not throw at all; it returns normally with `isDeferred = true`. See [Reference: Errors](/docs/reference/errors) for the full 24-value `ErrorKind` taxonomy and [Processing Purchases](/docs/guides/processing-purchases) for the complete purchase API and the `StoreProduct` shape.
***
## Funnels [#funnels]
`getFunnel(placementId:locale:)` resolves a placement into the funnel the
current subscriber should see (see [Placements &
Funnels](/docs/guides/placements)). The session is already open
when it returns — hand it to a `FunnelDriver` and host `RovenueFunnelView`
(see [Show a Funnel](/docs/funnels/native/show-a-funnel)):
```swift compile=body
import Rovenue
if let funnel = try await Rovenue.shared.getFunnel(placementId: "onboarding", locale: "en") {
// nil when the placement has no assignment — render nothing.
// Hand `funnel` to a FunnelDriver and host RovenueFunnelView (Show a Funnel).
_ = funnel
}
```
Purchase attribution is automatic: the funnel's `presentedContext` is
attached to the next purchase for you.
A native funnel draws in the device's language only if your app declares
that language. iOS hands an app `Locale.current` from the app's own declared
localizations — `CFBundleLocalizations` in `Info.plist`, or the
localizations your Xcode project has. An app that declares only English
reaches the SDK as `en_XX` (`en_TR` on a Turkish device), so the funnel
shows its English copy and English drawer strings (field placeholders,
VoiceOver labels) even when the funnel carries a Turkish translation. List
every language your funnels are translated into.
See [Placements](/docs/guides/placements) for how placements resolve to a funnel or an A/B experiment variant.
***
## Observing changes [#observing-changes]
`Rovenue.shared.changes` is an `AsyncStream` of change events. Consume it inside a long-lived `Task`:
```swift
import Rovenue
Task {
for await event in Rovenue.shared.changes {
// event: ChangeEvent (.entitlementsChanged | .identityChanged | .virtualCurrenciesChanged)
print("Rovenue change:", event)
// Update your UI or local state here
}
}
```
Cancel the `Task` (or call `Rovenue.shared.shutdown()`) when the owning object is deallocated.
See [Reacting to Changes](/docs/guides/reacting-to-changes) for a complete guide on handling live subscription events.
***
## Debugging [#debugging]
Register a log handler to receive structured log entries from the SDK. Set it up before `Rovenue.configure` to capture initialisation logs:
```swift
Rovenue.shared.setLogHandler { entry in
print("[\(entry.level)] \(entry.message)")
}
```
***
## Funnels [#funnels-1]
A placement can serve a native funnel: `getFunnel`, then a `FunnelDriver`
driving `RovenueFunnelView` (`packages/sdk-swift/Sources/Rovenue/Rovenue.swift:717`) — full path: [Show a funnel](/docs/funnels/native/show-a-funnel).
```swift compile=body
if let funnel = try await Rovenue.shared.getFunnel(placementId: "onboarding") {
_ = FunnelDriver(session: funnel.session)
}
```
***
## What's next [#whats-next]
* [Identifying Users](/docs/guides/identifying-users) — link your backend user to the Rovenue subscriber.
* [Entitlements](/docs/guides/entitlements) — read and gate on active subscriptions.
* [Virtual Currencies](/docs/guides/credits) — read virtual-currency balances and grant/spend via server-side APIs.
* [Processing Purchases](/docs/guides/processing-purchases) — full purchase API and error handling.
* [App Lifecycle](/docs/guides/app-lifecycle) — foreground/background wiring in depth.
* [Reference: Methods](/docs/reference/methods) — full method reference.
* [Reference: Types](/docs/reference/types) — all SDK types in one place.
* [SDK API Reference](/docs/reference/sdk-reference) — the generated DocC reference for every public Swift type and method, at `/api/sdk-swift/`.
# React Native (/docs/platforms/react-native)
**In an Expo app, use the React Native version your Expo SDK pins** — run
`npx expo install --check` (or `--fix`) after upgrading either. Expo ships
`ExpoModulesCore` prebuilt against its pinned React Native's JSI ABI (Expo 56
pins React Native 0.85.3); a newer React Native changes that ABI, and the app
dies at launch in dyld with `Symbol not found: …jsi…` before any JavaScript or
Rovenue code runs. `examples/sample-rn-expo` (Expo 56, React Native 0.85.3)
runs on the iOS simulator in this monorepo.
### Install the package [#install-the-package]
Install with your package manager (your choice is remembered across the whole site):
npm
pnpm
yarn
bun
```bash
npm install @rovenue/react-native-sdk
```
```bash
pnpm add @rovenue/react-native-sdk
```
```bash
yarn add @rovenue/react-native-sdk
```
```bash
bun add @rovenue/react-native-sdk
```
**Expo managed workflow** — add the config plugin to `app.json` (or `app.config.js`). It auto-wires the iOS CocoaPods dependency and Android Gradle on prebuild:
```json
{
"expo": {
"plugins": ["@rovenue/react-native-sdk"]
}
}
```
Then run:
```bash
npx expo prebuild
```
**Bare React Native** — after installing, run pod install for iOS:
```bash
cd ios && pod install
```
> Supported platforms: iOS 16.0+ (device and simulator).
Peer dependencies: `expo >=52`, `expo-modules-core >=2`, `react >=18.3`, `react-native >=0.76` — and, within those, the React Native version your Expo SDK pins.
### Configure the SDK [#configure-the-sdk]
Call `Rovenue.configure` once at the root of your app — before any hooks are used. `App.tsx` is the right place:
```tsx
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.configure({
apiKey: 'rov_pub_YOUR_API_KEY',
logLevel: 'info',
});
export default function App() {
// Foreground wiring added in the next step
return ;
}
```
**Self-hosting?** `baseUrl` is optional and defaults to the hosted endpoint `https://api.rovenue.io`. Override it to point at your own deployment, e.g. `Rovenue.configure({ apiKey: 'rov_pub_YOUR_API_KEY', baseUrl: 'https://api.acme.com' })`. The host must use `https://` — plain `http://` is accepted only for `localhost` during local development.
### Types on this page [#types-on-this-page]
```ts
type RovenueConfig = {
apiKey: string; // public SDK key — prefix rov_pub_
baseUrl?: string; // optional — defaults to https://api.rovenue.io
logLevel?: 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; // default 'warn'
appVersion?: string; // override auto-detected version
environment?: 'prod' | 'staging' | 'development';
};
```
| Field | Type | Description |
| ------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `string` | Public SDK key from your Rovenue dashboard. Prefix `rov_pub_`. |
| `baseUrl` | `string?` | Optional. Defaults to `https://api.rovenue.io`. Override only when self-hosting (`https://`; `http://` allowed for `localhost` only). |
| `logLevel` | `string?` | Verbosity of SDK logs. Defaults to `'warn'` — turn up to `'info'`/`'debug'` locally, never ship `'debug'`/`'trace'` in production. |
| `appVersion` | `string` | Overrides the version auto-read from the bundle. |
| `environment` | `string?` | Optional environment label forwarded with requests. |
Call `configure` exactly once, before rendering any component that calls a Rovenue hook. Calling it more than once after initialisation is a no-op but logs a warning.
### Wire foreground signals [#wire-foreground-signals]
The SDK uses `setForeground` to manage its internal polling cadence. Wire it to React Native's `AppState` inside your root component:
```tsx
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { Rovenue } from '@rovenue/react-native-sdk';
export default function App() {
useEffect(() => {
// Signal foreground on mount
Rovenue.setForeground(true);
const sub = AppState.addEventListener('change', (state) => {
Rovenue.setForeground(state === 'active');
});
return () => {
sub.remove();
};
}, []);
return ;
}
```
`Rovenue.shutdown()` stops all background work (polling, in-flight refreshes). Reserve it for full app teardown — do **not** call it on every background transition. `setForeground(false)` already pauses polling when the app backgrounds.
***
## Identifying users [#identifying-users]
Anonymous subscribers get a permanent `rovenueId` automatically. Call `identify` once your own auth flow completes, to link that install to your backend user:
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// After your auth flow completes:
await Rovenue.identify('user_123');
// When your user signs out:
await Rovenue.logOut();
```
`useCurrentUser()` (below) reflects the result reactively — no manual refetch needed.
See [Identifying Users](/docs/guides/identifying-users) for the full identity model, including what happens when `identify` and `logOut` are called.
***
## React hooks [#react-hooks]
All hooks must be called from components rendered after `configure` has been called.
| Hook | Returns |
| -------------------------- | ----------------------------------------------------------- |
| `useCurrentUser()` | `User \| null` — the identified subscriber, or `null` |
| `useEntitlement(id)` | `Entitlement \| null` — entitlement object for the given ID |
| `useEntitlements()` | `Entitlement[]` — all active entitlements |
| `useVirtualCurrencies()` | `Record` — all virtual-currency balances |
| `useVirtualCurrency(code)` | `number` — balance for a single currency code (0 if absent) |
```ts
type User = {
rovenueId: string; // permanent per-install id (prefix rov_), always present
appUserId: string | null; // your backend user id, null until identify()
};
type Entitlement = {
id: string; // e.g. "pro", "credits_500"
isActive: boolean;
productIdentifier: string;
store: string; // "app_store" | "play_store" | "stripe"
expiresIso?: string; // ISO 8601 — present for time-limited entitlements
};
```
| Field | Type | Description |
| ------------ | ---------------- | -------------------------------------------------------- |
| `rovenueId` | `string` | Permanent per-install id (prefix `rov_`); never changes. |
| `appUserId` | `string \| null` | Your backend user id; `null` until `identify()`. |
| `id` | `string` | Entitlement identifier configured in your dashboard. |
| `isActive` | `boolean` | `true` if the entitlement is currently active. |
| `expiresIso` | `string` | ISO 8601 expiry; present for time-limited entitlements. |
### Example — gating on an entitlement [#example--gating-on-an-entitlement]
```tsx
import { useEntitlement } from '@rovenue/react-native-sdk';
export function ProBadge() {
const pro = useEntitlement('pro');
if (!pro?.isActive) {
return null;
}
return Pro member;
}
```
### Example — displaying virtual-currency balances [#example--displaying-virtual-currency-balances]
```tsx
import { useVirtualCurrency, useVirtualCurrencies } from '@rovenue/react-native-sdk';
// Show one currency by code
export function CoinsLabel() {
const coins = useVirtualCurrency('COINS');
return {coins} coins;
}
// Show all currencies
export function WalletSummary() {
const balances = useVirtualCurrencies(); // Record
return (
<>
{Object.entries(balances).map(([code, amount]) => (
{code}: {amount}
))}
>
);
}
```
See [Entitlements](/docs/guides/entitlements) and [Virtual Currencies](/docs/guides/credits) for the full guide on reading and reacting to subscription state.
***
## Purchases [#purchases]
Use `getOfferings()` to fetch products configured in your Rovenue dashboard, then `purchase(pkg)` to drive the full purchase flow. The SDK triggers the native StoreKit 2 / Play Billing sheet, validates the receipt server-side, and resolves with updated entitlements and virtual-currency balances. On Android the SDK acknowledges or consumes the Play purchase on the device after the server verified it, and `purchase` resolves even when Play cannot take that acknowledgement yet — see [Who acknowledges a Play purchase](/docs/platforms/android-kotlin#who-acknowledges-a-play-purchase).
```tsx
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
if (pkg) {
try {
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) {
// Ask-to-Buy / parental-control hold — no entitlements yet. The SDK
// reconciles it in the background; watch addChangeListener for
// 'ENTITLEMENTS_CHANGED' to know when it resolves.
} else {
// result.entitlements and result.virtualCurrencies are already up to date
}
} catch (e) {
if (e instanceof RovenueError && e.kind === 'PurchaseCanceled') {
// User dismissed the store sheet — no action needed
} else {
throw e;
}
}
}
```
To re-validate all prior transactions (e.g. after a reinstall):
```ts
await Rovenue.restorePurchases();
```
```ts
type PurchaseResult = {
entitlements: Entitlement[];
virtualCurrencies: Record;
productId: string;
storeTransactionId: string;
isDeferred: boolean; // true for an Ask-to-Buy / parental-control hold
};
```
| Field | Type | Description |
| -------------------- | ------------------------ | ---------------------------------------------------------------------------------------- |
| `entitlements` | `Entitlement[]` | Updated entitlement list after the purchase. |
| `virtualCurrencies` | `Record` | Updated virtual-currency balances after the purchase (currency code → amount). |
| `productId` | `string` | The product identifier that was purchased. |
| `storeTransactionId` | `string` | The store-side transaction ID. |
| `isDeferred` | `boolean` | `true` when the purchase is pending external approval — no entitlements are granted yet. |
There is no `PurchaseCancelledError`/`PurchasePendingError` — every failure is a single `RovenueError` class discriminated by `.kind`, and there is no `'PurchasePending'` kind at all: as of 0.16.0, a deferred purchase does not throw; it returns normally with `isDeferred = true`. See [Reference: Errors](/docs/reference/errors) for the full 24-value `ErrorKind` taxonomy and [Processing Purchases](/docs/guides/processing-purchases) for the complete purchase API and the `StoreProduct` shape.
***
## Funnels [#funnels]
Placements serve native funnels, drawn by `RovenueFunnelView` (see
[Placements](/docs/guides/placements) and
[Show a Funnel](/docs/funnels/native/show-a-funnel)). There is deliberately
no JS `getFunnel` — the native side resolves the placement itself:
```tsx compile=body sdk=rn
import { RovenueFunnelView } from '@rovenue/react-native-sdk';
const onboarding = {}} />;
```
Do not wrap `RovenueFunnelView` in a `SafeAreaView`: it keeps its content
clear of the system bars itself, and a wrapper stops the funnel's background
from reaching under them.
On iOS the funnel draws in the device's language only if your app declares
that language: iOS hands an app `Locale.current` from its declared
localizations, so an app that declares only English reaches the SDK as
`en_XX` (`en_TR` on a Turkish device) and shows the funnel's English copy.
With Expo, list them in `app.json` — `"ios": { "infoPlist": {
"CFBundleLocalizations": ["en", "tr"] } }` — and prebuild; in a bare app,
add `CFBundleLocalizations` to `ios//Info.plist`. Android needs nothing.
Purchase attribution is automatic: the funnel's `presentedContext` is
attached to the next purchase for you.
***
## Change listener [#change-listener]
Use `Rovenue.addChangeListener` to react to server-pushed entitlement or virtual-currency updates. It returns an unsubscribe function — call it on cleanup.
```tsx
import { useEffect } from 'react';
import { Rovenue } from '@rovenue/react-native-sdk';
useEffect(() => {
const unsub = Rovenue.addChangeListener((event) => {
// event: 'ENTITLEMENTS_CHANGED' | 'IDENTITY_CHANGED' | 'VIRTUAL_CURRENCIES_CHANGED' | 'REMOTE_CONFIG_CHANGED'
console.log('Rovenue change:', event);
});
return () => {
unsub();
};
}, []);
```
See [Reacting to Changes](/docs/guides/reacting-to-changes) for a complete guide on handling live subscription events.
***
## Funnels [#funnels-1]
The funnel widget hosts the platform's native renderer — there is no JS
`getFunnel` (`packages/sdk-rn/src/index.ts:150-152`) — full path: [Show a funnel](/docs/funnels/native/show-a-funnel).
```tsx compile=body sdk=rn
import { RovenueFunnelView } from "@rovenue/react-native-sdk";
function OnboardingFunnel() {
return ;
}
```
***
## What's next [#whats-next]
* [Identifying Users](/docs/guides/identifying-users) — link your backend user to the Rovenue subscriber.
* [Entitlements](/docs/guides/entitlements) — read and gate on active subscriptions.
* [Virtual Currencies](/docs/guides/credits) — read virtual-currency balances and grant/spend via server-side APIs.
* [Processing Purchases](/docs/guides/processing-purchases) — full purchase API and error handling.
* [App Lifecycle](/docs/guides/app-lifecycle) — foreground/background wiring in depth.
* [Reference: Methods](/docs/reference/methods) — full method reference.
* [Reference: Types](/docs/reference/types) — all SDK types in one place.
* [SDK API Reference](/docs/reference/sdk-reference) — the generated TypeDoc reference for every exported type and method, at `/api/sdk-rn/`.
# Web (/docs/platforms/web)
## Before you install: allow your origin [#before-you-install-allow-your-origin]
A Rovenue API key is not usable from a browser until you list the origins your
app is served from. This is the first thing to do: every request from an origin
that is not on the key's list is refused with `403`
[`ORIGIN_NOT_ALLOWED`](/docs/reference/api-errors#origin_not_allowed) before it
does anything. The SDK throws it as a `RovenueApiError` with that `code`, so the
cause is readable in your app rather than a bare CORS failure.
In the dashboard, open **SDK & API → API keys**, and add each origin to the key
you will ship — for example `https://app.example.com` and, for local work,
`http://localhost:3000`. Origins are matched exactly: scheme, host and port.
There are no wildcards, because a `https://*.example.com` entry would turn a
subdomain takeover into a working API key.
## Install [#install]
```bash
npm install @rovenue/web-sdk
```
## Configure [#configure]
```ts
import { configure, createStorage } from "@rovenue/web-sdk";
export const rovenue = configure({
apiKey: "rov_pub_...", // your PUBLIC key — visible in the browser by design
apiUrl: "https://api.rovenue.io",
storage: createStorage(), // localStorage where available, memory where not
});
```
Create the client **once** and share it. Constructing it per render rebuilds the
identity, the entitlement cache and the event queue each time.
### Choosing an app user id [#choosing-an-app-user-id]
The SDK generates its own identifier and uses it on every request. If you call
`identify()` with an id of your own, that id becomes guessable to anyone holding
your public key — which is visible in your page source.
So: never use an email address, a sequential number, or anything else a stranger
could construct. Use an opaque id your backend maps to the user. The SDK warns
in the console when it sees an email-shaped id.
```ts
rovenue.identify("u_9f3a1c7e"); // opaque
// rovenue.identify("ada@example.com"); // warns — guessable
```
`identify()` is client-local. Merging two subscribers is a server-side operation
with your **secret** key, never something a browser can do.
## Entitlements [#entitlements]
```ts
const entitlements = await rovenue.getEntitlements();
```
The SDK caches the last answer, so a returning subscriber does not see a flash of
the paywall they already paid to remove:
```ts
const cached = rovenue.getCachedEntitlements(); // synchronous, no network
```
`null` and `{}` mean different things and the SDK keeps them apart: `{}` is a
real answer meaning this person has no entitlements, `null` means we have not
found out. When the network is unreachable or the API returns a 5xx,
`getEntitlements()` answers from cache. When it returns a 4xx — a revoked key, a
rejected origin — it throws instead, so a misconfiguration surfaces rather than
hiding behind stale data.
## React [#react]
```tsx
import { RovenueProvider, useEntitlements } from "@rovenue/web-sdk/react";
function App() {
return (
);
}
function Content() {
const { entitlements, isLoading, error } = useEntitlements();
if (entitlements?.pro) return ;
return ;
}
```
## Checkout [#checkout]
The web SDK has no `purchase()` — there is no native store sheet in a
browser. `checkout()` is where a web purchase flow ends: it starts a Stripe
Checkout session and hands you the URL to redirect to. This is the one
place the web quickstart genuinely diverges from the native SDKs' flow
(`getOfferings()` → `purchase()`).
```ts
const { offerings } = await rovenue.getOfferings();
const offering = offerings.find((o) => o.isDefault) ?? offerings[0];
const session = await rovenue.checkout({
offeringIdentifier: offering.identifier,
packageIdentifier: offering.packages[0].packageIdentifier,
successUrl: "https://app.example.com/welcome",
cancelUrl: "https://app.example.com/pricing",
});
window.location.assign(session.url);
```
`packageIdentifier` is the package slot (`packages[i].packageIdentifier`), not
the product `identifier`.
`successUrl` and `cancelUrl` must be on one of the API key's **allowed
origins**, an exact match of scheme, host and port. That includes
`http://localhost:` while developing, if it is listed. Otherwise they
must be on a **verified custom domain** over https.
You name a **package**, never a price. The amount is resolved on the server from
the offering that package belongs to, so a tampered page cannot change what is
charged. Pass an `idempotencyKey` if a double-submit is possible. It is sent as
the `Idempotency-Key` header, and the server hashes it together with your
project and the subscriber before using the digest as Stripe's idempotency key,
so the same key from two different buyers never returns the same session.
Repeating the call with the same key for the same buyer returns the session
already created. Without a key, the server derives one from the buyer, the
package, the price and a 5-minute window, so rapid re-posts still converge on
one session.
## Events [#events]
```ts
rovenue.track({ eventType: "pricing_viewed" });
```
Queued and delivered at least once: an event is dropped only after the server
acknowledges it, unsent events survive a page reload, and the queue flushes when
the tab is hidden or closing. Each event carries a stable id so a replay is
deduplicated rather than double-counted.
Under React the provider starts and stops the queue for you. Elsewhere, call
`rovenue.startEventQueue()` once after configuring.
## Server-side rendering [#server-side-rendering]
The SDK is safe to import and construct on a server — Next.js, Remix and similar
frameworks run this code where there is no `window` and no `localStorage`.
Nothing touches the DOM at import time, and storage falls back to memory when it
is unavailable or blocked.
Network calls still belong on the client. Construct on the server if it suits
your app's shape; fetch when you have a viewer.
## Supported browsers [#supported-browsers]
Any browser with `fetch`, `localStorage` and WebCrypto. WebCrypto is only exposed
on secure origins, so experiment assignment needs `https` (or
`http://localhost`); the SDK says so explicitly rather than silently bucketing
everyone into one variant.
***
## Funnels [#funnels]
Web funnels are hosted at a URL and embedded — the web SDK's surface is
client, storage, cache and events (`packages/sdk-web/src/index.ts:7-11`), so it draws no funnel. Embed the project id and slug: [Hosting](/docs/funnels/web/hosting).
```ts compile=body
const frame = document.createElement("iframe");
frame.src = "https://funnels.example.com/f/prj_demo1/summer-quiz";
document.body.appendChild(frame);
```
***
## What's next [#whats-next]
* [Entitlements](/docs/guides/entitlements) — read and gate on active subscriptions.
* [Placements](/docs/guides/placements) — how a placement resolves to a funnel or an A/B experiment variant.
* [SDK API Reference](/docs/reference/sdk-reference) — the generated TypeDoc reference for every exported type and method, at `/api/sdk-web/`. [Reference: Methods](/docs/reference/methods) and [Reference: Types](/docs/reference/types) cover the React Native/Swift/Kotlin SDKs only — the web SDK's surface differs too much (no `purchase()`, no StoreKit/Play Billing bridge) to share those tables, so `/api/sdk-web/` is the accurate reference here.
# API Errors (/docs/reference/api-errors)
{/*
GENERATED FILE — DO NOT EDIT BY HAND.
Source: packages/shared/src/error-catalog.ts
Regenerate: pnpm --filter @rovenue/docs generate:errors
(also runs automatically as part of this app's `build` script)
*/}
Every Rovenue API response is either `{ data: T }` or `{ error: { code, message } }`. This page documents every value `error.code` can take — the exact string returned on the wire, alongside the HTTP status it ships with, what it means, and what to do about it.
Looking for **SDK** errors instead — the `RovenueError`/`RovenueException` `kind` thrown by the React Native, Swift, and Kotlin SDKs? See [SDK Errors](/docs/reference/errors); this page is the *API's* `{ error: { code, message } }` envelope, a different (and larger) set aimed at anyone calling the HTTP API directly rather than through an SDK.
Five codes are lowercase on the wire even though their name in the API's source is SCREAMING\_CASE: `asset_in_use`, `asset_missing`, `purchase_not_paid`, `apple_offer_signing_unavailable`, `apple_offer_signing_failed`. Match on the exact string in the table below — not an uppercased guess.
***
## Quick reference [#quick-reference]
| Code | HTTP status |
| --------------------------------------------------------------------------------- | ----------- |
| [`HTTP_ERROR`](#http_error) | `500` |
| [`VALIDATION_ERROR`](#validation_error) | `400` |
| [`INTERNAL_ERROR`](#internal_error) | `500` |
| [`UNAUTHORIZED`](#unauthorized) | `401` |
| [`FORBIDDEN`](#forbidden) | `403` |
| [`NOT_FOUND`](#not_found) | `404` |
| [`CONFLICT`](#conflict) | `409` |
| [`NOT_IMPLEMENTED`](#not_implemented) | `501` |
| [`RATE_LIMITED`](#rate_limited) | `429` |
| [`ROVI_NOT_CONFIGURED`](#rovi_not_configured) | `412` |
| [`ROVI_QUOTA_EXCEEDED`](#rovi_quota_exceeded) | `429` |
| [`BEARER_REQUIRED`](#bearer_required) | `401` |
| [`INVALID_API_KEY`](#invalid_api_key) | `401` |
| [`INVALID_API_KEY_FORMAT`](#invalid_api_key_format) | `401` |
| [`API_KEY_KIND_MISMATCH`](#api_key_kind_mismatch) | `403` |
| [`ORIGIN_NOT_ALLOWED`](#origin_not_allowed) | `403` |
| [`STORE_NOT_CONFIGURED`](#store_not_configured) | `400` |
| [`RECEIPT_INVALID`](#receipt_invalid) | `400` |
| [`RECEIPT_PRODUCT_UNKNOWN`](#receipt_product_unknown) | `404` |
| [`RECEIPT_IN_USE`](#receipt_in_use) | `409` |
| [`STORE_API_ERROR`](#store_api_error) | `502` |
| [`STORE_UNAVAILABLE`](#store_unavailable) | `503` |
| [`STORE_CREDENTIALS_INVALID`](#store_credentials_invalid) | `400` |
| [`STORE_KEY_MISSING`](#store_key_missing) | `400` |
| [`STORE_KEY_WRONG_TYPE`](#store_key_wrong_type) | `400` |
| [`STORE_NOTIFICATION_URL_IN_USE`](#store_notification_url_in_use) | `409` |
| [`STORE_KEY_INSUFFICIENT_ROLE`](#store_key_insufficient_role) | `403` |
| [`STORE_NOTIFICATION_URL_NOT_PUBLIC`](#store_notification_url_not_public) | `400` |
| [`STORE_NOTIFICATION_URL_REJECTED`](#store_notification_url_rejected) | `400` |
| [`APPLE_APP_ID_MISSING`](#apple_app_id_missing) | `503` |
| [`APPLE_APP_ID_MISMATCH`](#apple_app_id_mismatch) | `401` |
| [`NOTIFICATION_FORWARD_LIMIT`](#notification_forward_limit) | `409` |
| [`PUBSUB_API_DISABLED`](#pubsub_api_disabled) | `400` |
| [`PUBSUB_PERMISSION_MISSING`](#pubsub_permission_missing) | `400` |
| [`PUBSUB_ORG_POLICY`](#pubsub_org_policy) | `400` |
| [`PUBSUB_TOPIC_NOT_FOUND`](#pubsub_topic_not_found) | `400` |
| [`PUBSUB_ENDPOINT_NOT_HTTPS`](#pubsub_endpoint_not_https) | `400` |
| [`PUBSUB_CONNECT_IN_PROGRESS`](#pubsub_connect_in_progress) | `409` |
| [`WEBHOOK_IN_PROGRESS`](#webhook_in_progress) | `409` |
| [`STORE_TEST_NOTIFICATION_TOKEN_UNKNOWN`](#store_test_notification_token_unknown) | `400` |
| [`STORE_ID_TAKEN`](#store_id_taken) | `409` |
| [`STRIPE_PRICE_NOT_FOUND`](#stripe_price_not_found) | `400` |
| [`STORE_CANCEL_UNSUPPORTED`](#store_cancel_unsupported) | `409` |
| [`REVOKE_IMMEDIATELY_UNSUPPORTED`](#revoke_immediately_unsupported) | `400` |
| [`apple_offer_signing_unavailable`](#apple_offer_signing_unavailable) | `400` |
| [`apple_offer_signing_failed`](#apple_offer_signing_failed) | `400` |
| [`FONT_FORMAT_UNSUPPORTED`](#font_format_unsupported) | `400` |
| [`FONT_FILE_TOO_LARGE`](#font_file_too_large) | `400` |
| [`FONT_QUOTA_EXCEEDED`](#font_quota_exceeded) | `400` |
| [`FONT_FAMILY_NOT_FOUND`](#font_family_not_found) | `404` |
| [`ASSET_FORMAT_UNSUPPORTED`](#asset_format_unsupported) | `400` |
| [`ASSET_FILE_TOO_LARGE`](#asset_file_too_large) | `413` |
| [`ASSET_QUOTA_EXCEEDED`](#asset_quota_exceeded) | `402` |
| [`ASSET_STORAGE_UNAVAILABLE`](#asset_storage_unavailable) | `503` |
| [`ANALYTICS_UNAVAILABLE`](#analytics_unavailable) | `503` |
| [`ASSET_INVALID_NAME`](#asset_invalid_name) | `400` |
| [`ASSET_PROCESSING_FAILED`](#asset_processing_failed) | `400` |
| [`IMPORT_FILE_TOO_LARGE`](#import_file_too_large) | `413` |
| [`IMPORT_STORAGE_UNAVAILABLE`](#import_storage_unavailable) | `503` |
| [`FEATURE_NOT_CONFIGURED`](#feature_not_configured) | `503` |
| [`DATABASE_UNAVAILABLE`](#database_unavailable) | `503` |
| [`CACHE_UNAVAILABLE`](#cache_unavailable) | `503` |
| [`QUEUE_UNAVAILABLE`](#queue_unavailable) | `503` |
| [`ANALYTICS_UNREACHABLE`](#analytics_unreachable) | `503` |
| [`purchase_not_paid`](#purchase_not_paid) | `400` |
| [`SUBSCRIBER_UNRESOLVABLE`](#subscriber_unresolvable) | `409` |
| [`PAYLOAD_TOO_LARGE`](#payload_too_large) | `413` |
| [`STRIPE_NOT_CONNECTED`](#stripe_not_connected) | `503` |
| [`STRIPE_CUSTOMER_NOT_FOUND`](#stripe_customer_not_found) | `404` |
| [`STORE_LOOKUP_UNAVAILABLE`](#store_lookup_unavailable) | `503` |
| [`RETURN_URL_NOT_ALLOWED`](#return_url_not_allowed) | `400` |
| [`WRONG_PAGE`](#wrong_page) | `409` |
| [`STEP_STALE`](#step_stale) | `409` |
| [`SESSION_CLOSED`](#session_closed) | `409` |
| [`OFFERING_IN_USE`](#offering_in_use) | `409` |
| [`PRODUCT_IN_USE`](#product_in_use) | `409` |
| [`PRODUCT_HAS_HISTORY`](#product_has_history) | `409` |
| [`ACCESS_IN_USE`](#access_in_use) | `409` |
| [`CUSTOM_HOST_PATH_NOT_FOUND`](#custom_host_path_not_found) | `404` |
| [`FUNNEL_VALIDATION`](#funnel_validation) | `400` |
| [`FUNNEL_SETTINGS_INVALID`](#funnel_settings_invalid) | `400` |
| [`FUNNEL_LOCALE_MISMATCH`](#funnel_locale_mismatch) | `400` |
| [`FIRST_API_KEY_EXISTS`](#first_api_key_exists) | `409` |
| [`FUNNEL_SLUG_TAKEN`](#funnel_slug_taken) | `409` |
| [`FUNNEL_TARGET_CHANGED`](#funnel_target_changed) | `409` |
| [`FUNNEL_ALREADY_ON_TARGET`](#funnel_already_on_target) | `400` |
| [`FUNNEL_CONVERSION_BLOCKED`](#funnel_conversion_blocked) | `400` |
| [`FUNNEL_CONVERSION_UNPUBLISHES`](#funnel_conversion_unpublishes) | `409` |
| [`FUNNEL_IN_USE`](#funnel_in_use) | `409` |
| [`FUNNEL_ARCHIVED`](#funnel_archived) | `409` |
| [`FUNNEL_DRAFT_SUPERSEDED`](#funnel_draft_superseded) | `409` |
| [`FUNNEL_SLUG_RESERVED`](#funnel_slug_reserved) | `400` |
| [`FUNNEL_NOT_NATIVE`](#funnel_not_native) | `400` |
| [`NO_PUBLISHED_VERSION`](#no_published_version) | `404` |
| [`INVALID_ROW_REF`](#invalid_row_ref) | `400` |
| [`audience_in_use`](#audience_in_use) | `409` |
| [`CUSTOM_DOMAIN_LIMIT_REACHED`](#custom_domain_limit_reached) | `409` |
| [`HOSTNAME_TAKEN`](#hostname_taken) | `409` |
| [`ROOT_FUNNEL_INVALID`](#root_funnel_invalid) | `400` |
| [`PAYMENT_ALREADY_RECORDED`](#payment_already_recorded) | `409` |
| [`PAYMENT_TEMPORARILY_UNAVAILABLE`](#payment_temporarily_unavailable) | `503` |
| [`PAYMENT_IN_FLIGHT`](#payment_in_flight) | `409` |
| [`PAYMENT_NOT_SETTLED_YET`](#payment_not_settled_yet) | `409` |
| [`PAYMENT_PROVIDER_REJECTED`](#payment_provider_rejected) | `422` |
| [`PAYMENT_UNVERIFIABLE`](#payment_unverifiable) | `409` |
| [`CONFIRM_EMAIL_MISMATCH`](#confirm_email_mismatch) | `400` |
| [`OWNS_PROJECTS`](#owns_projects) | `409` |
| [`exposure_out_of_range`](#exposure_out_of_range) | `400` |
| [`EXPERIMENT_NOT_RUNNING`](#experiment_not_running) | `409` |
| [`promote_requires_flag_experiment`](#promote_requires_flag_experiment) | `400` |
| [`window_too_large`](#window_too_large) | `400` |
| [`USAGE_LIMIT_EXCEEDED`](#usage_limit_exceeded) | `403` |
| [`BILLING_CARD_IN_USE`](#billing_card_in_use) | `409` |
| [`BILLING_CARD_OPEN_INVOICE`](#billing_card_open_invoice) | `409` |
| [`WEBHOOK_EVENT_EXPIRED`](#webhook_event_expired) | `410` |
| [`WEBHOOK_EVENT_UNMAPPABLE`](#webhook_event_unmappable) | `422` |
| [`STALE_BATCH`](#stale_batch) | `422` |
| [`TELEMETRY_UNAVAILABLE`](#telemetry_unavailable) | `503` |
| [`IDENTIFIER_TAKEN`](#identifier_taken) | `409` |
| [`UNKNOWN_REFERENCES`](#unknown_references) | `400` |
| [`IMPORT_JOB_STATE`](#import_job_state) | `409` |
| [`IMPORT_MAPPING_INCOMPLETE`](#import_mapping_incomplete) | `400` |
| [`EXPERIMENT_STATE`](#experiment_state) | `400` |
| [`WEBHOOK_STATE`](#webhook_state) | `400` |
| [`COPILOT_INTENT_SETTLED`](#copilot_intent_settled) | `409` |
| [`ROLE_REQUIRED`](#role_required) | `403` |
| [`CAPABILITY_REQUIRED`](#capability_required) | `403` |
| [`BILLING_NOTHING_TO_CANCEL`](#billing_nothing_to_cancel) | `409` |
| [`BILLING_ALREADY_ACTIVE`](#billing_already_active) | `409` |
| [`BILLING_UPGRADE_REQUIRED`](#billing_upgrade_required) | `409` |
| [`STRIPE_ALREADY_CONNECTED`](#stripe_already_connected) | `409` |
| [`ALREADY_MEMBER`](#already_member) | `409` |
| [`ALREADY_OWNER`](#already_owner) | `409` |
| [`INVITATION_NOT_SENDABLE`](#invitation_not_sendable) | `409` |
| [`INVITATION_ALREADY_ACCEPTED`](#invitation_already_accepted) | `409` |
| [`INVITATION_EMAIL_MISMATCH`](#invitation_email_mismatch) | `403` |
| [`WEBHOOK_ENDPOINT_LIMIT`](#webhook_endpoint_limit) | `409` |
| [`WEBHOOK_URL_REJECTED`](#webhook_url_rejected) | `400` |
| [`TEST_DELIVERY_NOT_REDELIVERABLE`](#test_delivery_not_redeliverable) | `409` |
| [`INTEGRATION_ALREADY_CONNECTED`](#integration_already_connected) | `409` |
| [`INTEGRATION_CREDENTIALS_REJECTED`](#integration_credentials_rejected) | `400` |
| [`SCHEDULED_ACTION_CONFLICT`](#scheduled_action_conflict) | `409` |
| [`HOSTNAME_INVALID`](#hostname_invalid) | `400` |
| [`ORIGIN_INVALID`](#origin_invalid) | `400` |
| [`QUERY_REJECTED`](#query_rejected) | `400` |
| [`CURRENCY_GRANT_INVALID`](#currency_grant_invalid) | `400` |
| [`REFUND_NOT_POSSIBLE`](#refund_not_possible) | `422` |
| [`LEGAL_URL_IN_USE`](#legal_url_in_use) | `409` |
| [`ADMIN_EMAIL_DOMAIN`](#admin_email_domain) | `403` |
| [`ADMIN_2FA_REQUIRED`](#admin_2fa_required) | `403` |
| [`ADMIN_SESSION_EXPIRED`](#admin_session_expired) | `401` |
| [`PROJECT_SUSPENDED`](#project_suspended) | `403` |
| [`SUPPORT_ACCESS_READ_ONLY`](#support_access_read_only) | `403` |
***
## Codes [#codes]
### `HTTP_ERROR` [#http_error]
**HTTP status:** `500`
`mapHttpStatus` in middleware/error.ts returns this for EVERY HTTPException whose status is not one of the explicitly mapped ones (400/401/403/404/409/429/501, which each get their own specific code) and whose `cause` isn't a recognized ERROR\_CODE. A repo-wide scan of such call sites finds it at status 410, 412, 413 and 422. Never at 5xx: every 5xx HTTPException carries a typed `cause` (ops-resilience D9; the stuck census counts any that does not) — `INTERNAL_ERROR`, `STORE_API_ERROR`, `FEATURE_NOT_CONFIGURED`, `STORE_UNAVAILABLE`, `DATABASE_UNAVAILABLE`, `CACHE_UNAVAILABLE`, `QUEUE_UNAVAILABLE`, `ANALYTICS_UNAVAILABLE`, `ANALYTICS_UNREACHABLE`, …. This code is therefore NOT a signal of any single failure class or status the way every other code in this catalog is — it is what's left over among 4xx. A bare 409 is no longer part of it: it arrives as CONFLICT.
**What to do:** Do not branch on this code alone — it groups together the leftover 4xx statuses (preconditions, payload-size rejections, unprocessable requests) that all happen to route through an HTTPException without a typed `cause`; it is never a 5xx. Read the actual HTTP status and `message` on the response to know what really happened; if you need to react programmatically to a specific one of these cases, ask for it to be promoted to its own typed code (give the throw site a `cause: ERROR_CODE.`) rather than parsing `message`.
***
### `VALIDATION_ERROR` [#validation_error]
**HTTP status:** `400`
The default for a rejected HTTPException(400, ...) and for any unhandled ZodError (body/query/param schema failure) — by far the most common error code in the API. (The refund routes' 'cannot refund this purchase' answers are REFUND\_NOT\_POSSIBLE, not this code.)
**What to do:** Fix the request: re-check required fields, types, and enum values against the route's schema in `message`. This is a client-side mistake, not a transient failure — retrying unchanged will fail again.
***
### `INTERNAL_ERROR` [#internal_error]
**HTTP status:** `500`
The final fallback in middleware/error.ts for any thrown value that is neither an HTTPException nor a ZodError — an unexpected bug, not a modeled failure mode. The real error is logged server-side; the client only ever sees a generic message. Every explicit `HTTPException(500, …)` (an invariant the server found broken, e.g. dashboard/invitations.ts's 'just-created invitation vanished' check) carries this code as its `cause`, so every 500 arrives as INTERNAL\_ERROR. An infra outage never does: a database, cache, queue or analytics connection failure answers 503 with its own code.
**What to do:** Retry with backoff once (some causes are transient — a dropped DB connection, an unhandled edge case under load), then stop and report it if it persists. There is nothing the caller can do to avoid triggering this; it means the server hit a case it didn't expect. To catch every server-side failure, match on the HTTP status (>= 500): the 502 and 503 codes are separate entries.
***
### `UNAUTHORIZED` [#unauthorized]
**HTTP status:** `401`
The status→code mapping's answer for any HTTPException(401, ...) whose `cause` isn't a recognized ERROR\_CODE. As of Task 5 (2026-09-06), the four `api-key-auth.ts` branches that used to fall through to this generic code now set their own `cause` (BEARER\_REQUIRED, INVALID\_API\_KEY\_FORMAT, INVALID\_API\_KEY), so this code no longer covers those. It remains the answer for any other 401 raised without a typed cause.
**What to do:** This is a generic 401 with no more specific code attached — read `message` for detail. If you expected BEARER\_REQUIRED, INVALID\_API\_KEY\_FORMAT or INVALID\_API\_KEY instead, check that you're hitting `apiKeyAuth`-guarded `/v1` routes; other 401 producers in the codebase have not been given typed causes.
***
### `FORBIDDEN` [#forbidden]
**HTTP status:** `403`
The status→code mapping's answer for any HTTPException(403, ...) without a recognized `cause` — the authenticated caller is real but not allowed to do this. As of Task 5 (2026-09-06), `apiKeyAuth`'s own wrong-kind check now sets `cause: API_KEY_KIND_MISMATCH` instead of falling through here. This code still covers the separate route-level guards `requireSecretKey`/`requirePublicApiKey` (which run AFTER `apiKeyAuth` and have not been given a typed cause) and a dashboard user's role lacking the capability the route checks (`assertProjectCapability`/`assertProjectAccess`).
**What to do:** For SDK callers: switch to a secret key for server-to-server endpoints (credit grants and similar) — never ship a secret key in a client app. For dashboard callers: the signed-in user's project role doesn't include the needed capability; ask a project owner to grant it. If you expected API\_KEY\_KIND\_MISMATCH instead, the rejection came from a route-level guard, not `apiKeyAuth`'s own kind check.
***
### `NOT_FOUND` [#not_found]
**HTTP status:** `404`
Returned both by the generic status→code mapping for any bare HTTPException(404, ...) and directly by many routes for 'no row with that id in this project' (subscriptions, transactions, assets, font faces, projects). It never distinguishes 'never existed' from 'exists in a different project' — both look identical to avoid leaking cross-project existence.
**What to do:** Double-check the id and that it belongs to the project the request is authenticated against — a valid id for someone else's project 404s exactly like a nonexistent one. Don't treat this as retryable.
***
### `CONFLICT` [#conflict]
**HTTP status:** `409`
The status→code mapping's answer for any HTTPException(409, ...) whose `cause` isn't a recognized ERROR\_CODE: the request collides with the resource's current state — an identifier already taken, a row still referenced by another, an operation already done (a refund already recorded). Every explained conflict carries its own code instead (IDENTIFIER\_TAKEN, STORE\_ID\_TAKEN, FUNNEL\_SLUG\_TAKEN, OFFERING\_IN\_USE, IMPORT\_JOB\_STATE, …); a bare CONFLICT is left only where the server has nothing more specific to say, and each such site is listed with its reason in e2e/live/explained-refusals-allowlist.json.
**What to do:** Read `message`: it names what collided. Change the request (another identifier) or the state it collides with (remove the reference first), then retry. Retrying unchanged fails the same way.
***
### `NOT_IMPLEMENTED` [#not_implemented]
**HTTP status:** `501`
Reserved slot in the status→code mapping for an HTTPException(501, ...); at the time of writing no route in apps/api/src throws status 501, so this code has never actually been observed on the wire. It exists so a future not-yet-built endpoint stub has a machine-readable code to return instead of a bare 500.
**What to do:** If you see this in practice, treat it the same as a permanent client-side dead end — the endpoint exists in the route table but its implementation isn't live yet. Don't retry; check the changelog for when it ships.
***
### `RATE_LIMITED` [#rate_limited]
**HTTP status:** `429`
Thrown by the shared `endpointRateLimit`/`apiKeyRateLimit` middleware (middleware/rate-limit.ts) whenever a caller exceeds a route's per-minute cap — asset uploads and general API-key traffic are all gated this way. `ROVI_QUOTA_EXCEEDED` below is a distinct, unrelated 429 for a different limit (monthly AI usage, not request rate). From the shared limiter, `error.details.retryAfterSeconds` repeats the Retry-After wait.
**What to do:** Back off and retry after a short delay; the response should carry standard rate-limit headers to tell you how long. If this fires under normal usage, the per-minute cap for that route is probably too low for your traffic pattern — reduce polling frequency or batch requests.
***
### `ROVI_NOT_CONFIGURED` [#rovi_not_configured]
**HTTP status:** `412`
The project has no working AI provider for Rovi copilot chat (it routes through `resolveProviderForProject`): no BYOK credentials saved for the project AND no server-wide default provider/API key configured in env either.
**What to do:** An operator must fix configuration: either save BYOK provider credentials for this project in the dashboard, or set `ROVI_DEFAULT_PROVIDER`/`ROVI_DEFAULT_API_KEY` (and friends) in the API's environment. A caller cannot work around this by retrying.
***
### `ROVI_QUOTA_EXCEEDED` [#rovi_quota_exceeded]
**HTTP status:** `429`
The project's monthly Rovi usage (message count or token count, whichever the tier caps first) has been exhausted, per `evaluateQuota`/`resolveTier` in middleware/rovi-quota-guard.ts. The response body carries `tier`, `exceeded`, and `resetAt` alongside the envelope for a client to show a real countdown.
**What to do:** Wait until `resetAt` (the first of next month, UTC) or upgrade the project's billing tier for a higher cap. Retrying before the reset always fails identically — this is not a transient rate limit.
***
### `BEARER_REQUIRED` [#bearer_required]
**HTTP status:** `401`
The Authorization header is missing or not a Bearer token. `middleware/api-key-auth.ts`'s first check throws `HTTPException(401, { message: 'Bearer token required', cause: ERROR_CODE.BEARER_REQUIRED })`, so this is a distinct wire code from UNAUTHORIZED (as of Task 5, 2026-09-06).
**What to do:** Send the header as `Authorization: Bearer ` (public `rov_pub_...` or secret `rov_sec_...`). No key was presented at all, or it wasn't in Bearer scheme — nothing to rotate or look up in the dashboard yet.
***
### `INVALID_API_KEY` [#invalid_api_key]
**HTTP status:** `401`
The presented API key doesn't resolve to a live record — unknown, revoked, or expired. `api-key-auth.ts`'s post-lookup check throws `HTTPException(401, { message: 'Invalid or expired API key', cause: ERROR_CODE.INVALID_API_KEY })` (as of Task 5, 2026-09-06) once the key has the right prefix and kind but fails the DB lookup or the revoked/expired check.
**What to do:** Rotate the key in the dashboard if it's genuinely invalid, revoked, or expired. If the key looks right, double-check it belongs to the project you think it does — a key for a different project also resolves to nothing here.
***
### `INVALID_API_KEY_FORMAT` [#invalid_api_key_format]
**HTTP status:** `401`
The Bearer token doesn't match either recognized key prefix (`rov_pub_`/`rov_sec_`, via `detectKind`). `api-key-auth.ts` throws `HTTPException(401, { message: 'Invalid API key format', cause: ERROR_CODE.INVALID_API_KEY_FORMAT })` (as of Task 5, 2026-09-06) before ever attempting a DB lookup.
**What to do:** Verify the key was copied in full and unmodified from the dashboard — a truncated or hand-edited key, or a credential from a different product, is the usual cause of a format mismatch.
***
### `API_KEY_KIND_MISMATCH` [#api_key_kind_mismatch]
**HTTP status:** `403`
A public key was presented where a secret key is required, or vice versa. `api-key-auth.ts`'s `required !== detected` branch throws `HTTPException(403, { message: ' API key required', cause: ERROR_CODE.API_KEY_KIND_MISMATCH })` (as of Task 5, 2026-09-06). NOTE: the separate route-level guards `requireSecretKey`/`requirePublicApiKey` (which run after `apiKeyAuth`, for routes that share one middleware stack across both key kinds) have NOT been given a typed cause and still fall through to the generic FORBIDDEN — see that entry. `error.details.required` names the key kind the route needs.
**What to do:** Use a secret key for server-to-server endpoints (never ship one in a client app) or a public key for SDK/client endpoints. Read `message` for which kind this specific route required.
***
### `ORIGIN_NOT_ALLOWED` [#origin_not_allowed]
**HTTP status:** `403`
A browser request on /v1/web/:publicKey came from an Origin that is not on this public key's allowed origins. Checked after authentication on every request (the preflight is answered for any origin), so the browser can read this error instead of a CORS failure. `error.details.origin` is the refused Origin.
**What to do:** Add the page's exact origin (scheme, host and port — e.g. [https://app.example.com](https://app.example.com) or [http://localhost:5173](http://localhost:5173)) to the key's allowed origins in the dashboard (Project → API keys).
***
### `STORE_NOT_CONFIGURED` [#store_not_configured]
**HTTP status:** `400`
A receipt verification (`POST /v1/receipts/apple` or `/google`) or the dashboard product-catalog lookup (`getStoreCatalog`) has no usable store credentials for the requested platform: no Apple credential row (a missing key is STORE\_KEY\_MISSING), or no Google Play service-account credentials saved for the project at all.
**What to do:** An operator must add the missing store credentials for this project in the dashboard (App Store Connect API key, or the Google Play service-account JSON) before the catalog can be fetched. Not retryable without that.
***
### `RECEIPT_INVALID` [#receipt_invalid]
**HTTP status:** `400`
`POST /v1/receipts/apple` or `/google` refused the receipt itself: the Apple JWS failed verification, the Google purchase token did not verify, the submitted `productId` does not match the verified transaction, or the Google one-time purchase was canceled.
**What to do:** Do not retry the same receipt. The purchase is not valid for this project; ask the user to retry the purchase or restore. If a legitimate purchase keeps failing, check that the app's bundle id or package name and the store credentials match the project.
***
### `RECEIPT_PRODUCT_UNKNOWN` [#receipt_product_unknown]
**HTTP status:** `404`
`POST /v1/receipts/apple` or `/google` verified (or was given) a product id that no product of this project carries, neither as its identifier nor as its Apple or Google store id. `error.details.productId` is the store product id the receipt names.
**What to do:** Add the product in the dashboard (Project → Products) with that store id, or fix the id the app purchases. Not retryable until the catalog changes.
***
### `RECEIPT_IN_USE` [#receipt_in_use]
**HTTP status:** `409`
`POST /v1/receipts/apple` or `/google`: the receipt is already bound to a different subscriber of this project, and the request does not carry the identity needed to move it.
**What to do:** Identify the user who owns the purchase (the same account that bought it) and resubmit, or transfer the subscriber through the secret-key `/v1/subscribers/transfer` route. Not retryable as is.
***
### `STORE_API_ERROR` [#store_api_error]
**HTTP status:** `502`
An upstream provider's API returned an error or an unusable answer: the store's own API (App Store Connect or Google Play Developer API) while listing products, fetching a subscription, or looking up a transaction; Stripe during the Connect authorization exchange, a funnel payment intent with no client secret, or a checkout session with no URL; or the project's AI provider rejecting the key in the copilot credential test. Credentials were valid enough to reach the provider, but the call itself failed (rate limit, transient outage, a malformed/unauthorized request the provider rejected).
**What to do:** Retry after a short delay — most causes are transient upstream issues. If it persists, check the store's own status page and verify the configured credentials still have the needed scopes/permissions (an API key that used to work can be revoked or downgraded on Apple/Google's side).
***
### `STORE_UNAVAILABLE` [#store_unavailable]
**HTTP status:** `503`
A store could not be reached: its circuit breaker is open, or the call failed with a network error, a timeout, a 429 or a 5xx. It says nothing about the receipt or the payment itself — they may be perfectly valid. The response carries `Retry-After: 60`. Who answers it:
* **Google Play:** `POST /v1/receipts/google` and `POST /v1/subscribers/:appUserId/restore` when a Play receipt cannot be checked with Play — instead of a 400 or an empty restore. A restore answers it whole when any of its receipts hit the outage, even if other receipts in it verified.
* **Stripe:** the public web-funnel payment routes, `POST /public/funnel-sessions/:sessionId/payment-intent` ("Payments are temporarily unavailable…") and `POST /public/funnel-sessions/:sessionId/confirm` ("We can't confirm this payment right now…"). Stripe refusing the request is `PAYMENT_PROVIDER_REJECTED` or `PAYMENT_UNVERIFIABLE`, not this.
* **App Store receipts never answer it:** `POST /v1/receipts/apple` and Apple receipts in a restore are signed transactions verified on the server without calling Apple. A bad one is a 400.
* Any other route whose store call is rejected by an open circuit breaker.
**What to do:** Retry after the `Retry-After` seconds (60 — the breaker's reset window) and keep the purchase pending on the device; do not tell the user the purchase failed. A restore that answers this is retried whole: receipts it did verify are recorded idempotently, so verifying them again is safe.
***
### `STORE_CREDENTIALS_INVALID` [#store_credentials_invalid]
**HTTP status:** `400`
The App Store Connect or Google Play key cannot be read: the private key is not a PKCS#8 PEM of the right type (Apple: EC P-256; Google: RSA), the Apple key id is not 10 letters/digits, the issuer id is not a UUID, or only some of Apple's key id / issuer id / private key were given. `PUT /dashboard/projects/:projectId/credentials/:store` stores nothing; the store catalog and the connection test report it for a key stored before. `error.details` names the store (`store`: apple | google), the field (`field`: keyId | issuerId | privateKey | serviceAccount) and why (`reason`): `missing` (only some of Apple's key id / issuer id / private key were given; `field` is the first one missing), `unreadable` (not a PEM, or an id in the wrong format), `wrong_key_type` (a readable key of the wrong kind: Apple needs an EC P-256 .p8, Google an RSA key) `rejected_by_store` (the store refused a well-formed key), `app_not_found` (`field: bundleId`: App Store Connect has no app with the stored bundle id or app id that this key can see — the key itself authenticated) or `insufficient_role` (App Store Connect authenticated the key but its role cannot read the app: 403, answered by the key save, Re-check and the catalog; setting the notification URL answers STORE\_KEY\_INSUFFICIENT\_ROLE instead). `POST …/credentials/google/pubsub/connect` and `…/pubsub/rotate-token` answer it with `domain_scoped_project` when the service account's `project_id` (`field: serviceAccount`) or the `existingTopic`'s project (`field: existingTopic`) is a legacy domain-scoped Google Cloud project (`example.com:my-project`): managed Pub/Sub does not support those; set the push subscription up by hand. The setup state's `app` step also carries this code with `unreadable` (the stored credential cannot be decrypted) or `stored_setup_invalid` (it decrypts, but is not a setup this server can read).
**What to do:** Re-download the key (.p8 from App Store Connect → Users and Access → Integrations; the service-account JSON from Google Cloud) and paste it whole, with the key id and issuer id shown next to it.
***
### `STORE_KEY_MISSING` [#store_key_missing]
**HTTP status:** `400`
The Apple key a feature needs is not stored for the project. `details.key` names it: `inAppPurchase` (App Store Server API calls, offer signing, test notifications) or `appStoreConnect` (product import and prices). Returned by `POST /dashboard/projects/:projectId/credentials/apple/verify`, `POST …/credentials/apple/test-notification`, `GET …/credentials/apple/notification-url`, `POST …/credentials/apple/notification-url/apply` (which needs both keys, and checks both before calling Apple) and the store catalog. A consumer never falls back to the other key.
**What to do:** Add the named key on the Stores page: an In-App Purchase key (App Store Connect → Users and Access → Integrations → In-App Purchase) or a Team App Store Connect API key (Integrations → App Store Connect API → Team Keys).
***
### `STORE_KEY_WRONG_TYPE` [#store_key_wrong_type]
**HTTP status:** `400`
`PUT /dashboard/projects/:projectId/credentials/apple` refused a key that Apple accepts on the OTHER API: a Team App Store Connect API key given as the In-App Purchase key, or the reverse. Nothing is stored. `details.expected` is the key type the field needs (`inAppPurchase` | `appStoreConnect`) and `details.field` the body field (`inAppPurchaseKey` | `appStoreConnectKey`).
**What to do:** Upload the right key: `SubscriptionKey_.p8` from Integrations → In-App Purchase for the In-App Purchase key; `AuthKey_.p8` from Integrations → App Store Connect API → Team Keys for the App Store Connect API key.
***
### `STORE_NOTIFICATION_URL_IN_USE` [#store_notification_url_in_use]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/credentials/apple/notification-url/apply` with `replaceExisting: false` found an App Store Server Notifications URL that is not exactly this project's for Production or Sandbox (`details.current`: the URL per slot). Nothing was changed at Apple. `details.otherRovenueProject` is true when that URL is another Rovenue project's webhook on this API's own host (staging and production on one bundle, or a re-created project).
**What to do:** Replace it (that service stops receiving Apple's notifications), or keep it and have that service forward Apple's request body to Rovenue's notification URL.
***
### `STORE_KEY_INSUFFICIENT_ROLE` [#store_key_insufficient_role]
**HTTP status:** `403`
App Store Connect refused to read or modify the app with this App Store Connect API key: its role is too low. Answered by `GET /dashboard/projects/:projectId/credentials/apple/notification-url` and `POST …/notification-url/apply` (`details.key`: `appStoreConnect`). The key save, Re-check and the catalog report the same 403 as STORE\_CREDENTIALS\_INVALID with `reason: insufficient_role` on the key's step instead.
**What to do:** Use a Team key with the App Manager or Admin role (Users and Access → Integrations → App Store Connect API).
***
### `STORE_NOTIFICATION_URL_NOT_PUBLIC` [#store_notification_url_not_public]
**HTTP status:** `400`
`POST /dashboard/projects/:projectId/credentials/apple/notification-url/apply`, before any call to Apple: this server's own notification URL (`details.url`, built from `PUBLIC_BASE_URL`) is not a public https URL — plain http, localhost, a `.local`/`.internal` name or a loopback/private IP. Apple can never post to it, so nothing was sent to App Store Connect.
**What to do:** Set `PUBLIC_BASE_URL` to the API's public https address (behind your reverse proxy or tunnel) and restart the API, then try again. Not retryable until then.
***
### `STORE_NOTIFICATION_URL_REJECTED` [#store_notification_url_rejected]
**HTTP status:** `400`
`POST /dashboard/projects/:projectId/credentials/apple/notification-url/apply`: App Store Connect refused to store this server's notification URL (its 409 on the app update). `details.url` is the URL sent; `details.appleMessage` is Apple's own error title and detail. The App Store Connect key is fine; nothing changed at Apple.
**What to do:** Read Apple's message: usually the URL is not one Apple accepts (it must be public https). Fix `PUBLIC_BASE_URL`, or paste the URL by hand in App Store Connect to see Apple's form validation.
***
### `APPLE_APP_ID_MISSING` [#apple_app_id_missing]
**HTTP status:** `503`
A Production-environment Apple notification (`POST /webhooks/apple/:projectId`) arrived for a project whose App Apple ID (Apple's numeric app id) is not stored, and neither the project's App Store pick, App Store Connect (the stored Team key) nor the public iTunes lookup knows it. Apple's verifier cannot check a Production notification without it, so nothing was processed; Apple redelivers the notification later. The Apple notifications step shows this reason. Sandbox notifications and StoreKit receipts (signed transactions, which carry no App Apple ID) never need it.
**What to do:** Enter the App Apple ID in the Apple app step (App Store Connect → the app → App Information → General Information → Apple ID), or store an App Store Connect API key so Rovenue can look it up.
***
### `APPLE_APP_ID_MISMATCH` [#apple_app_id_mismatch]
**HTTP status:** `401`
A Production-environment Apple notification (`POST /webhooks/apple/:projectId`) carried a valid Apple signature for this project's bundle id, but names another App Apple ID (Apple's numeric app id) than the one stored for the project (or resolved for it). Nothing was processed; Apple redelivers the notification later. The Apple notifications step shows this reason until a Production notification for the stored id arrives or the id is changed.
**What to do:** Check the App Apple ID in the Apple app step against App Store Connect → the app → App Information → General Information → Apple ID, and correct it.
***
### `NOTIFICATION_FORWARD_LIMIT` [#notification_forward_limit]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/credentials/:store/forwarding` refused a URL: the store (`details.store`) already forwards to the maximum number of URLs (`details.max`). Nothing was added.
**What to do:** Remove a forward URL first.
***
### `PUBSUB_API_DISABLED` [#pubsub_api_disabled]
**HTTP status:** `400`
`POST /dashboard/projects/:projectId/credentials/google/pubsub/connect` (also Recreate) or `…/pubsub/rotate-token`: Google answered 403 `SERVICE_DISABLED` — the Cloud Pub/Sub API is not enabled in the Google Cloud project of the stored service account (`details.gcpProject`, `details.clientEmail`). `details.enableUrl` is Google's own activation link when Google sent one. Nothing was created.
**What to do:** Open `details.enableUrl` (or Google Cloud → APIs & Services → Cloud Pub/Sub API → Enable), wait a minute, then Recreate.
***
### `PUBSUB_PERMISSION_MISSING` [#pubsub_permission_missing]
**HTTP status:** `400`
`POST …/credentials/google/pubsub/connect` or `…/pubsub/rotate-token`: Google answered 403 to a Pub/Sub admin call (create the topic, read or set its IAM policy, create or update the push subscription). The stored service account (`details.clientEmail`) lacks a Pub/Sub permission in `details.gcpProject`. Whatever was created before the refusal stays; Recreate reuses it.
**What to do:** Grant the service account the Pub/Sub Admin role (`roles/pubsub.admin`) in Google Cloud → IAM, then Recreate.
***
### `PUBSUB_ORG_POLICY` [#pubsub_org_policy]
**HTTP status:** `400`
`POST …/credentials/google/pubsub/connect`: granting Google Play's publisher (`google-play-developer-notifications@system.gserviceaccount.com`) on the topic was refused with `FAILED_PRECONDITION` — an organization policy such as Domain Restricted Sharing (`constraints/iam.allowedPolicyMemberDomains`) does not allow members outside the organization in `details.gcpProject`. The topic exists; Play cannot publish to it.
**What to do:** An organization admin must allow `system.gserviceaccount.com` (or Google Play's service account) for this project, then Recreate. Or use a Google Cloud project outside that policy.
***
### `PUBSUB_TOPIC_NOT_FOUND` [#pubsub_topic_not_found]
**HTTP status:** `400`
`POST …/credentials/google/pubsub/connect` with `existingTopic`: Google has no such topic in `details.gcpProject` (a bare id is looked up in the service account's own project). Nothing was created.
**What to do:** Copy the topic name from Play Console → Monetize → Monetization setup (or Google Cloud → Pub/Sub → Topics) and try again, or leave the field empty to let Rovenue create its own topic.
***
### `PUBSUB_ENDPOINT_NOT_HTTPS` [#pubsub_endpoint_not_https]
**HTTP status:** `400`
`POST …/credentials/google/pubsub/connect` or `…/pubsub/rotate-token`, before any call to Google: this server's Google notification URL (`details.url`, built from `PUBLIC_BASE_URL`) is not a public https URL. `details.reason` says why: `not_https` (plain http), `private_address` (a loopback/private IP, IPv6-mapped forms included), `single_label_host`, `local_name` (localhost, `.local`, `.internal`) or `invalid_url`; `details.endpointScheme` is its scheme. Pub/Sub pushes only to public https endpoints.
**What to do:** Set `PUBLIC_BASE_URL` to the API's public https address and restart the API, or set the subscription up by hand (Set it up by hand). Not retryable until then.
***
### `PUBSUB_CONNECT_IN_PROGRESS` [#pubsub_connect_in_progress]
**HTTP status:** `409`
`POST …/credentials/google/pubsub/connect` or `…/pubsub/rotate-token`: another connect, Recreate or token rotation for this project holds the project's credential lock right now. This call changed nothing; the running one decides the push token Google and Rovenue share. Also answered by `PUT /dashboard/projects/:projectId/credentials/:store`, by `DELETE …/credentials/:store` (`details.action: "disconnect"`) and, as a non-2xx Pub/Sub redelivers, by a cloud first-use push, when that lock is held longer than they wait.
**What to do:** Wait a few seconds and read the store status again; retry only if the running call failed.
***
### `WEBHOOK_IN_PROGRESS` [#webhook_in_progress]
**HTTP status:** `409`
A store webhook (Apple, Google Pub/Sub, Stripe Connect) delivered an event that another request is processing right now. Nothing was done; the answer carries `Retry-After` (seconds). Stores retry any non-2xx, so the event is delivered once the running request settles — or, if it crashed, once its processing lease lapses.
**What to do:** None needed: the store retries automatically.
***
### `STORE_TEST_NOTIFICATION_TOKEN_UNKNOWN` [#store_test_notification_token_unknown]
**HTTP status:** `400`
`GET /dashboard/projects/:projectId/credentials/apple/test-notification/:token` was given a token this API did not issue, or one Apple no longer knows (Apple answered 404 — test notification tokens expire). Says nothing about the stored key; no check row is written.
**What to do:** Send a new test notification (`POST …/credentials/apple/test-notification`) and poll the token it returns.
***
### `STORE_ID_TAKEN` [#store_id_taken]
**HTTP status:** `409`
`POST`/`PATCH /dashboard/projects/:projectId/products` (and Rovi's product create) refuse a store id another product of the project already maps; a purchase of that SKU would resolve to either product. The message names the holder; `error.details` carries the store (`store`: apple | google | stripe), the id (`storeId`) and the product holding it (`product`: \{ id, identifier }).
**What to do:** Edit the named product (or remove its store id) instead, or use a different store product.
***
### `STRIPE_PRICE_NOT_FOUND` [#stripe_price_not_found]
**HTTP status:** `400`
The `stripe` store id is not a price on the project's connected Stripe account (checked only when the id changes and Stripe is connected).
**What to do:** Copy the price id (`price_…`) from the connected account's product page, or import it through Import from store → Web.
***
### `STORE_CANCEL_UNSUPPORTED` [#store_cancel_unsupported]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/subscriptions/:purchaseId/schedule` refuses an App Store purchase: Apple offers no server API to cancel a customer's subscription, so there is nothing to schedule — the customer's own cancel arrives as a `DID_CHANGE_RENEWAL_STATUS` notification.
**What to do:** Do not schedule the cancel; tell the customer to cancel in their App Store subscriptions. The dashboard hides the action for App Store purchases.
***
### `REVOKE_IMMEDIATELY_UNSUPPORTED` [#revoke_immediately_unsupported]
**HTTP status:** `400`
`POST …/subscriptions/:purchaseId/schedule` refuses `revokeImmediately` on a non-MANUAL purchase: only an operator comp (MANUAL) can be cut short locally; a store subscription runs to its term end.
**What to do:** Schedule without `revokeImmediately`, or revoke access through the store itself.
***
### `apple_offer_signing_unavailable` [#apple_offer_signing_unavailable]
**HTTP status:** `400`
`POST /v1/apple-offer-signature` couldn't load complete Apple credentials for the project (missing private key, keyId, or bundleId) — promotional-offer signing needs the same in-app-purchase signing key used for StoreKit offer signatures, and it isn't fully configured.
**What to do:** An operator must upload the project's Apple in-app-purchase signing key (and confirm keyId/bundleId are set) in the dashboard before this endpoint can sign offers. Not something the SDK caller can fix.
***
### `apple_offer_signing_failed` [#apple_offer_signing_failed]
**HTTP status:** `400`
Credentials were present, but `signOfferPayload` threw while actually producing the cryptographic signature for the promotional offer — most likely a malformed or corrupted stored private key, since the payload itself is built from validated request fields.
**What to do:** Not retryable by the SDK caller as-is; if this is new, an operator should re-upload the project's Apple in-app-purchase signing key in the dashboard, since the stored key material is likely invalid. If it was working and suddenly isn't, check for a recent credential rotation.
***
### `FONT_FORMAT_UNSUPPORTED` [#font_format_unsupported]
**HTTP status:** `400`
The uploaded font file's bytes didn't match any allowed font format under `detectFontFormat`'s magic-byte check. This is a shape check on the actual bytes, never the filename — a `.ttf`-named file carrying non-font bytes (or bytes for an unsupported format) triggers this regardless of its extension.
**What to do:** Upload an actual TTF/OTF/WOFF/WOFF2 font file (whichever formats `detectFontFormat` currently allows) — renaming a file's extension does not change how it's detected. Verify the file wasn't corrupted in transfer.
***
### `FONT_FILE_TOO_LARGE` [#font_file_too_large]
**HTTP status:** `400`
The uploaded font face exceeds `FONT_FACE_MAX_BYTES`. Returned by two gates that produce the identical code/message on purpose: `hono/body-limit`'s transport-level rejection for a body large enough to exceed even the framing allowance, and the in-handler `file.size` check for the ordinary case where the file itself is just too big.
**What to do:** Compress or subset the font file (e.g. drop unused glyphs/hinting) to fit under the documented per-face size limit before uploading again.
***
### `FONT_QUOTA_EXCEEDED` [#font_quota_exceeded]
**HTTP status:** `400`
The project already has `FONT_FACES_MAX_PER_PROJECT` font faces and this upload would add a new one (not replace an existing `(familyId, weight, style)` combination, which is exempt from this check since it replaces a row rather than growing the count).
**What to do:** Delete an unused font face first, or upload this as a replacement weight/style for an existing family instead of a new face. This is a hard per-project cap, not a billing-tier limit that can be upgraded away.
***
### `FONT_FAMILY_NOT_FOUND` [#font_family_not_found]
**HTTP status:** `404`
A client-supplied `familyId` (on face upload, or on the family-level routes) doesn't resolve to a live font family owned by this project — missing entirely, belonging to a different project, or soft-deleted.
**What to do:** Re-fetch the project's current font families and use a live `familyId` from that list. A previously valid id that now 404s here was most likely deleted since you last fetched it.
***
### `ASSET_FORMAT_UNSUPPORTED` [#asset_format_unsupported]
**HTTP status:** `400`
The uploaded bytes don't match the asset kind the upload route claims (image/video/lottie), per `detectAssetKind`'s magic-byte check — for example, non-video bytes posted to the `video` upload endpoint. Like the font check, this is a byte-shape check, never a filename or declared-Content-Type check. `error.details.kind` names the kind the bytes were expected to be.
**What to do:** Upload to the endpoint matching the file's actual kind (`/image`, `/video`, or `/lottie`), and confirm the file isn't corrupted or a placeholder — SVG is deliberately blocked here regardless of endpoint (see the asset-CDN libvips allowlist).
***
### `ASSET_FILE_TOO_LARGE` [#asset_file_too_large]
**HTTP status:** `413`
The uploaded asset exceeds that asset kind's own size cap (`ASSET_MAX_BYTES[kind]`), enforced entirely at the transport level by a per-kind `hono/body-limit` registration — each of `/image`, `/video`, and `/lottie` has its own cap, so this always means the specific kind's own limit, not a shared one.
**What to do:** Compress the asset (re-encode the image/video, or strip unused data from the Lottie JSON) to fit the size limit for its kind, or use a smaller source file.
***
### `ASSET_QUOTA_EXCEEDED` [#asset_quota_exceeded]
**HTTP status:** `402`
The project's total asset storage usage has reached its billing-tier limit (`getStorageUsage` against the tier's cap) — checked before any normalization or upload work is done, so this is returned cheaply without ever touching the file's bytes.
**What to do:** Delete unused assets to free up quota, or upgrade the project's billing tier for a higher storage cap — this is a paid-plan limit, not a per-request or per-file cap.
***
### `ASSET_STORAGE_UNAVAILABLE` [#asset_storage_unavailable]
**HTTP status:** `503`
Either the asset storage backend (S3-compatible object store) isn't configured for this deployment at all (`store.isStorageConfigured()` false), or it was configured but the actual write to it failed — both return the same code so a caller doesn't need to distinguish 'not set up' from 'temporarily down'.
**What to do:** Retry shortly for the transient-outage case. If it never succeeds, an operator needs to check the deployment's object-storage configuration (endpoint, credentials, bucket) — this is not something an SDK/dashboard caller can fix by retrying indefinitely.
***
### `ANALYTICS_UNAVAILABLE` [#analytics_unavailable]
**HTTP status:** `503`
An analytics read (charts, overview, transactions, cohorts, the queries playground and the other ClickHouse-backed dashboard routes) on a deployment with no ClickHouse — `CLICKHOUSE_URL` is blank, so `ClickHouseUnavailableError` was thrown and middleware/error.ts mapped it here. Postgres-backed reads on the same page still work.
**What to do:** Not transient — do not retry. An operator sets `CLICKHOUSE_URL` (plus its credentials) and runs the ClickHouse migrations to turn analytics on; until then a client shows an 'analytics not configured' state rather than an error.
***
### `ASSET_INVALID_NAME` [#asset_invalid_name]
**HTTP status:** `400`
The `name` query parameter on an asset upload failed `isValidAssetName` — missing, empty, or containing characters the asset naming scheme doesn't allow.
**What to do:** Pass a non-empty `name` using only the allowed character set (see `isValidAssetName`) — this is checked before authentication-adjacent work runs, so it's the cheapest possible rejection and always means the request itself is malformed.
***
### `ASSET_PROCESSING_FAILED` [#asset_processing_failed]
**HTTP status:** `400`
Image normalization (`normalizeImage` — resizing, EXIF/GPS stripping, format conversion via libvips/sharp) threw while processing an otherwise format-valid image. The bytes passed the magic-byte format check but libvips couldn't actually decode/process them — a truncated, corrupted, or unusually malformed file. The message is the image pipeline's own sentence.
**What to do:** Re-export the image from its source application and re-upload — this usually means the file is subtly corrupted or uses an edge-case encoding libvips can't handle, not that the format itself is wrong (that's `ASSET_FORMAT_UNSUPPORTED`).
***
### `IMPORT_FILE_TOO_LARGE` [#import_file_too_large]
**HTTP status:** `413`
The uploaded subscriber-import file (CSV) exceeds `IMPORT_MAX_UPLOAD_BYTES`, enforced by `hono/body-limit` at the transport level on `POST /dashboard/projects/:projectId/imports` before the body is ever fully buffered.
**What to do:** Split the import into smaller batches under the documented size limit, or remove unnecessary columns/rows from the CSV before re-uploading.
***
### `IMPORT_STORAGE_UNAVAILABLE` [#import_storage_unavailable]
**HTTP status:** `503`
The dedicated private import-file bucket (never the public asset bucket — see `lib/import-store.ts`) is either unconfigured for this deployment or the write to it failed. Both cases return the same code, mirroring the `ASSET_STORAGE_UNAVAILABLE` pattern.
**What to do:** Retry shortly for a possible transient outage. If it never succeeds, an operator needs to check the deployment's import-storage configuration — this bucket is configured separately from the public asset CDN bucket and both must be set up independently.
***
### `FEATURE_NOT_CONFIGURED` [#feature_not_configured]
**HTTP status:** `503`
This deployment lacks the configuration the request needs, so it was refused before anything was written. `error.details.feature` names what is missing: `billing` (a cloud deployment whose platform Stripe billing keys are missing, or refused — `details.reason: "live_keys_refused"` for live keys outside production; a self-hosted deployment has no billing routes at all and answers 404), `apple_webhook_verifier` (the Apple notification verifier could not be built), `apple_receipt_verifier` (an Apple receipt cannot be chain-verified: no Apple trust anchors are configured), `upload_staging` (MCP asset/font uploads without staging storage), `unsubscribe_signing` (the unsubscribe link without `UNSUB_SIGNING_KEY`), `credential_encryption` (saving or reading stored credentials without `ENCRYPTION_KEY`), `dsar_erasure` (`POST /v1/dsar/erasure` or the dashboard's Anonymize action without the ClickHouse eraser credentials), `stripe_connect` (`POST /dashboard/projects/:projectId/stripe/connect` for a Connect mode — `details.mode` — with no client id configured). The response carries no `Retry-After`.
**What to do:** Do not retry: the same request is refused until an operator configures the named feature (for `dsar_erasure`, `CLICKHOUSE_ERASER_PASSWORD` and the ClickHouse eraser user; for `stripe_connect`, that mode's Stripe Connect client id and platform keys). A client shows a 'not available on this deployment' state rather than an error.
***
### `DATABASE_UNAVAILABLE` [#database_unavailable]
**HTTP status:** `503`
Postgres could not be reached: a refused or reset connection, a pool connect timeout, a terminated connection, or a connection-class SQLSTATE (08xxx, 57P01 admin shutdown, 57P03 cannot connect now, 53300 too many connections). Only connection-level failures map here — never a constraint, syntax or data error. The response carries `Retry-After`.
**What to do:** Retry after the `Retry-After` seconds with backoff. Nothing about the request was wrong; if it persists, an operator checks the database.
***
### `CACHE_UNAVAILABLE` [#cache_unavailable]
**HTTP status:** `503`
Redis could not be reached: the connection is closed, refused, or out of retries for the command. The response carries `Retry-After`.
**What to do:** Retry after the `Retry-After` seconds with backoff. If it persists, an operator checks Redis.
***
### `QUEUE_UNAVAILABLE` [#queue_unavailable]
**HTTP status:** `503`
The request needed to enqueue background work (an invitation email, an import run or dry-run, a DSAR export or erasure, an integration redelivery or backfill, a store webhook) and the job queue (Redis) rejected the job or did not accept it within the enqueue timeout. `error.details.queue` names the queue. The response carries `Retry-After`.
**What to do:** Retry after the `Retry-After` seconds. Every enqueue is idempotent by row id or claim, so a retry never duplicates the work even if the refused job landed late.
***
### `ANALYTICS_UNREACHABLE` [#analytics_unreachable]
**HTTP status:** `503`
ClickHouse is configured but did not answer an analytics read: the network failed, the request timed out, or ClickHouse refused the credentials (AUTHENTICATION\_FAILED, IP\_ADDRESS\_NOT\_ALLOWED). Distinct from `ANALYTICS_UNAVAILABLE`, which means ClickHouse is not configured at all. The response carries `Retry-After`.
**What to do:** Retry after the `Retry-After` seconds; show a temporary 'analytics unreachable' state, never the 'analytics not configured' one. If it persists, an operator checks ClickHouse's reachability and credentials.
***
### `purchase_not_paid` [#purchase_not_paid]
**HTTP status:** `400`
A Google receipt was submitted for verification while the underlying purchase is still `PENDING` (subscription `subscriptionState` or one-time `purchaseState`) — the user has started but not finished paying (e.g. a pending cash/carrier-billing payment method). This is machine-readable specifically because the purchase may still complete.
**What to do:** Retry receipt verification after the payment actually completes on-device — don't treat this as a hard failure or show a generic purchase-failed error. Poll or wait for the client's own purchase-update callback before re-verifying.
***
### `SUBSCRIBER_UNRESOLVABLE` [#subscriber_unresolvable]
**HTTP status:** `409`
This device's subscriber record cannot be resolved.
**What to do:** The subscriber's merge history is inconsistent. Do not retry; contact the project owner, who can see the logged chain head.
***
### `PAYLOAD_TOO_LARGE` [#payload_too_large]
**HTTP status:** `413`
The API-wide global body-size ceiling (`app.ts`'s root `bodyLimit`) was exceeded on a route that doesn't own a larger, more specific limit of its own (asset/font/import uploads are carved out with their own bigger caps via `ROUTE_OWNED_BODY_LIMIT_PATH`). This code exists specifically because Hono's default body-limit rejection surfaced as `HTTP_ERROR` with an EMPTY message before it was added — a real incident, not a hypothetical.
**What to do:** This means the request body is far larger than any ordinary JSON payload this API expects on a non-upload route — check you're posting to the right endpoint, and use one of the dedicated upload routes (assets/fonts/imports) for large file bodies instead.
***
### `STRIPE_NOT_CONNECTED` [#stripe_not_connected]
**HTTP status:** `503`
The project has no active Stripe Connect account to charge or open a portal session on. Produced at three sites, each keeping its own pre-existing HTTP status: `POST /v1/billing-portal` (503, `lib/stripe-platform.ts`'s `StripeNotConnectedError` caught and re-thrown with `cause`), the dashboard funnel-publish checkout gate (`dashboard/funnels.ts`, 400), and the public funnel-payment `payment-intent` route (`public/funnel-payment.ts`, 409). As of Task 5 (2026-09-06) all three set `cause: ERROR_CODE.STRIPE_NOT_CONNECTED`, so `error.code` is this value from every one of them — read `error.code`; the message is a plain sentence.
**What to do:** An operator must connect Stripe for this project (dashboard's Stripe Connect flow) — and, for the funnel routes specifically, complete Stripe's own verification so `charges_enabled` is true — before the billing portal, a funnel with a checkout can be published, or a funnel-payment checkout can proceed.
***
### `STRIPE_CUSTOMER_NOT_FOUND` [#stripe_customer_not_found]
**HTTP status:** `404`
The authenticated subscriber (resolved from `X-Rovenue-App-User-Id`) has no Stripe customer record on file. This is the normal, expected case for a subscriber whose entitlements come entirely from Apple or Google — not an error state in the usual sense.
**What to do:** Don't show this as a generic error to an Apple/Google-only subscriber — hide or disable the 'manage billing' entry point for them instead of surfacing this code as a failure, since it's expected rather than exceptional.
***
### `STORE_LOOKUP_UNAVAILABLE` [#store_lookup_unavailable]
**HTTP status:** `503`
`GET /dashboard/store-lookup/apple` when the iTunes Search API did not answer (timeout, non-2xx, unreachable — e.g. a self-hosted install without egress).
**What to do:** Enter the app manually; search works again once Apple answers.
***
### `RETURN_URL_NOT_ALLOWED` [#return_url_not_allowed]
**HTTP status:** `400`
The `returnUrl` posted to `/v1/billing-portal` is neither on one of the authenticating API key's allowed origins (an exact scheme + host + port match) nor on one of the project's verified custom domains served over https — this request's URL is never echoed back to Stripe unchecked, precisely because the returned session URL grants access to payment data.
**What to do:** Either add the web app's origin (e.g. `https://app.example.com`, or `http://localhost:5173` while developing) to the API key's allowed origins, or verify the domain in the dashboard's project settings and pass an https URL on that exact verified domain. This is a security check, not something to work around by retrying with the same value.
***
### `WRONG_PAGE` [#wrong_page]
**HTTP status:** `409`
`runMachineStep` (apps/api/src/routes/public/funnels.ts, the shared body behind `/current`, `/back`, `/submit` and `/tap`) rejects the request's `from_page_id` before doing any work — the pre-transaction cheap check at line 567 — because the session's stored `currentPageId` no longer matches it. This means the browser tried to act from a page the session isn't actually on anymore: another tab already moved it, or a duplicate/replayed request is targeting a page the visitor already left. The authoritative compare-and-set inside the transaction (line 648) also checks `from_page_id`, but a race lost there collapses into STEP\_STALE or SESSION\_CLOSED instead — this code is only ever raised by the early, pre-transaction check.
**What to do:** Not a client bug to fix by retrying the same call. The dashboard runner (`funnel-runner.tsx`'s `handleStepFailure`/`resync`) treats this as recoverable: it reads `GET /funnel-sessions/:id/state` — the one read with no compare-and-set of its own — and replays `/current` with the session's real `current_page_id`/`machine_step`, dropping the tap that triggered the 409 rather than retrying it. A second consecutive 409 (including one from that resync's own `/current` call) is terminal by construction, not resynced again.
***
### `STEP_STALE` [#step_stale]
**HTTP status:** `409`
`runMachineStep` rejects the request's `step` counter, either at the pre-transaction cheap check (line 573, `session.machineStep !== envelope.step`) or from losing the authoritative compare-and-set inside the transaction (line 648) when the session's state is still RUNNING — the CAS failure classifies as STEP\_STALE whenever it is not also closed, so a same-step race that actually moved the page can still surface as this code rather than WRONG\_PAGE. Either way it means the visitor's cursor is behind the session's real step count: a duplicated request (the same step submitted twice) or another tab that already advanced it.
**What to do:** Same recovery as WRONG\_PAGE: the dashboard runner's `resync()` reads `GET /funnel-sessions/:id/state` for the session's real `current_page_id`/`machine_step` and replays `/current` with those values, dropping the request that got refused. Do not blindly retry the original call with the same `step` — it will refuse again. A second consecutive 409 (including from the resync's own call) is treated as terminal, not looped.
***
### `SESSION_CLOSED` [#session_closed]
**HTTP status:** `409`
`runMachineStep` refuses because the session itself has stopped taking steps, not because the visitor's cursor is behind. Two throw sites: the entry check (line 536, before any page/step is even looked at) when `session.state !== RUNNING` already; and the compare-and-set failure inside the transaction (line 648) when the CAS lost AND the session's freshly re-read state is no longer RUNNING. In practice this means the session was already paid, completed, or closed by `funnel-abandoner.ts` while this tab sat idle or mid-flight.
**What to do:** Not recoverable by resyncing — there is no later page to catch up to. The dashboard runner (`funnel-runner.tsx`) does not attempt `resync()` for this code; `handleStepFailure` routes it straight to a `{ kind: 'closed' }` status, rendering its own 'This session has ended' screen instead of the generic error screen or a retry loop.
***
### `OFFERING_IN_USE` [#offering_in_use]
**HTTP status:** `409`
`DELETE /dashboard/projects/:projectId/offerings/:id` refuses because a LIVE funnel — `published`, with a current version, web or native — sells this offering: its current version's package lists name it, read through the same `funnelOfferingIdentifier` the funnel serve, the placement resolver and the payment route use. Deleting it would leave that funnel's package lists empty and its checkout failing. The message names the funnels; `error.details` carries the offering's identifier (`offering`) and the live funnels (`funnels`: \{ id, name }\[]).
**What to do:** Unpublish or archive the named funnels (or publish a version that sells a different offering) first, then delete the offering. Draft and archived funnels never block a delete: their next publish refuses the missing offering as `OFFERING_NOT_FOUND`.
***
### `PRODUCT_IN_USE` [#product_in_use]
**HTTP status:** `409`
`DELETE /dashboard/projects/:projectId/products/:id` refuses a product that is a package in an offering; the package would dangle and any live funnel selling that offering would silently lose it. The message names the offerings and the live funnels; `error.details.offerings` (\{ id, identifier }\[]) and `error.details.funnels` (\{ id, name }\[], empty when no live funnel sells them) carry them. A product with purchase history is `PRODUCT_HAS_HISTORY` instead.
**What to do:** Remove the product from the named offerings first (Offerings → the offering → remove), then delete it.
***
### `PRODUCT_HAS_HISTORY` [#product_has_history]
**HTTP status:** `409`
`DELETE /dashboard/projects/:projectId/products/:id` refuses a product that purchases or revenue events reference (RESTRICT foreign keys): history can never be un-referenced. `error.details.purchases` lists up to five of the referencing purchases, newest first (\{ id, store, originalTransactionId }); `error.details.truncated` is true when more exist. A product referenced only by revenue events has `purchases: []`.
**What to do:** Archive the product instead (`PATCH { isActive: false }`): it stops being offered and keeps its history.
***
### `ACCESS_IN_USE` [#access_in_use]
**HTTP status:** `409`
`DELETE /dashboard/projects/:projectId/access/:id` refuses an access level that live `subscriber_access` rows still hold (the `subscriber_access_accessId_access_id_fk` RESTRICT reference). The delete unlinks the access from products itself, so only subscribers block; `error.details.subscriberCount` names how many hold it.
**What to do:** Wait for the grants to lapse (or revoke them), then delete the access level.
***
### `CUSTOM_HOST_PATH_NOT_FOUND` [#custom_host_path_not_found]
**HTTP status:** `404`
`GET /public/host/lookup` (`apps/api/src/routes/public/funnels.ts`) returns this when the request's `Host` header IS a verified, serving project custom domain, but nothing is published at the requested path — no root funnel, a reserved slug, or no published funnel matching the first path segment. Distinct from the plain `NOT_FOUND` an unrecognized host gets: a customer's own domain must never show the Rovenue dashboard, so the SPA has to answer these two cases differently.
**What to do:** Treat this as 'render the funnel-side 404 page', not the dashboard's generic not-found — the host is legitimately theirs, only the path is wrong. If the caller expected a specific funnel slug to resolve, verify it is published with a current version and isn't a reserved slug.
***
### `FUNNEL_VALIDATION` [#funnel_validation]
**HTTP status:** `400`
`POST /dashboard/projects/:projectId/funnels/:funnelId/publish` refuses because the draft does not publish: it fails the page schema, the Rust validator reports errors, a gate (checkout/legacy rules) fails, or the offering it sells does not exist in the project. `error.details.issues` lists every problem in the validator's vocabulary. Also returned with 422 by `GET /dashboard/projects/:projectId/funnels/:funnelId/preview-document?version=draft` when the draft cannot be built into a document at all (its pages fail the page schema); `error.details.issues` then carries `{ path, code, message }` entries. A draft that builds but has validator errors is NOT refused there: it is served with those errors in `data.issues`.
**What to do:** Fix each issue in `error.details.issues` in the builder (its publish drawer lists them) and publish again. Warnings never appear here; only errors block.
***
### `FUNNEL_SETTINGS_INVALID` [#funnel_settings_invalid]
**HTTP status:** `400`
`PATCH /dashboard/projects/:projectId/funnels/:funnelId` refuses `draft_settings_json` that fails the persisted funnel-settings schema. `error.details.issues` carries the schema's issues.
**What to do:** Correct the settings the issues point at (a malformed scheme, a bad app-value name) and save again.
***
### `FUNNEL_LOCALE_MISMATCH` [#funnel_locale_mismatch]
**HTTP status:** `400`
`PATCH /dashboard/projects/:projectId/funnels/:funnelId` refuses a write after which the funnel's `default_locale` is not one of its `locales` (sent one without the other).
**What to do:** Send a `default_locale` that is in `locales`, or add it to `locales` in the same request.
***
### `FIRST_API_KEY_EXISTS` [#first_api_key_exists]
**HTTP status:** `409`
`POST /dashboard/projects/:id/api-keys/first` (the onboarding keys step) found the project already holding an active API key, so it minted nothing: the first key is created once, even when two tabs ask at the same moment. `error.details.publicKey` is the existing key's publishable value.
**What to do:** Use the key the project already has, or create more keys on the SDK & API page (`POST /dashboard/projects/:id/api-keys`). Retrying unchanged fails the same way.
***
### `FUNNEL_SLUG_TAKEN` [#funnel_slug_taken]
**HTTP status:** `409`
A web funnel's slug write (PATCH or convert to web) collides with another funnel's slug in the same project. `error.details.slug` names the slug.
**What to do:** Choose a different slug; slugs are unique per project.
***
### `FUNNEL_TARGET_CHANGED` [#funnel_target_changed]
**HTTP status:** `409`
The funnel's target changed (a conversion in another tab) since the caller judged or edited it — a PATCH with `expected_target`, or a publish that raced a conversion. `error.details.target` is the funnel's current target.
**What to do:** Reload the funnel; the builder stops autosaving and tells the author the funnel was converted.
***
### `FUNNEL_ALREADY_ON_TARGET` [#funnel_already_on_target]
**HTTP status:** `400`
`POST /dashboard/projects/:projectId/funnels/:funnelId/convert` asked for the target the funnel already has. `error.details.target` names it.
**What to do:** Nothing to do; only convert to the other target.
***
### `FUNNEL_CONVERSION_BLOCKED` [#funnel_conversion_blocked]
**HTTP status:** `400`
`POST …/funnels/:funnelId/convert` refuses because the draft carries something the new target cannot (a checkout on native, a store purchase on web, …). `error.details.issues` lists the blockers.
**What to do:** Remove or replace each blocker in the builder, then convert again. `GET …/convert?target=` previews the same list without converting.
***
### `FUNNEL_CONVERSION_UNPUBLISHES` [#funnel_conversion_unpublishes]
**HTTP status:** `409`
`POST …/funnels/:funnelId/convert` refuses because the funnel is live and converting would take it off the air. `error.details.stopsServing` names what stops serving.
**What to do:** Resend with `unpublish: true` to acknowledge the funnel stops serving, then publish it again on its new target.
***
### `FUNNEL_IN_USE` [#funnel_in_use]
**HTTP status:** `409`
A funnel archive/delete refuses because placements or a running experiment still serve it. `error.details` lists `placements` (\{id, identifier, name}) and `experiments` (\{id, name, status}).
**What to do:** Point the listed placements at another target and stop the listed experiments, then archive again.
***
### `FUNNEL_ARCHIVED` [#funnel_archived]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/funnels/:funnelId/publish` refuses an archived funnel: publishing would put it back on the air without the restore that re-checks it. `POST /dashboard/experiments/:id/stop` refuses the same way when the winning variant's funnel is archived, since the stop would point placements at it.
**What to do:** Restore the funnel (`POST .../funnels/:funnelId/restore`), then publish it (or stop the experiment with it as the winner).
***
### `FUNNEL_DRAFT_SUPERSEDED` [#funnel_draft_superseded]
**HTTP status:** `409`
`PATCH /dashboard/projects/:projectId/funnels/:funnelId` refuses a save carrying a `writer_id` whose `save_seq` is not above the one the funnel last stored from that writer — an older save from the same editor tab (an aborted request, or a retry of one that already landed) arriving after a newer one. Saves from different writers (other tabs) are never refused.
**What to do:** Nothing to do: the newer save from the same editor is already stored. The builder drops the refused save silently.
***
### `FUNNEL_SLUG_RESERVED` [#funnel_slug_reserved]
**HTTP status:** `400`
A web funnel create, PATCH or copilot intent names a slug no funnel may take (`assets` — custom domains serve the dashboard bundle's static files there).
**What to do:** Choose a different slug.
***
### `FUNNEL_NOT_NATIVE` [#funnel_not_native]
**HTTP status:** `400`
`GET /dashboard/projects/:projectId/funnels/:funnelId/preview-document` refuses a web-target funnel: the device preview renders a native funnel through the SDK's native views, and a web funnel is previewed at its own URL instead.
**What to do:** Preview a web funnel in a browser at its funnel URL, or convert it to a native funnel first.
***
### `NO_PUBLISHED_VERSION` [#no_published_version]
**HTTP status:** `404`
`GET /dashboard/projects/:projectId/funnels/:funnelId/preview-document?version=published` finds nothing on the air for the funnel: it was never published, or it was archived or restored to draft, so no placement would serve it.
**What to do:** Preview `version=draft` instead, or publish the funnel first.
***
### `INVALID_ROW_REF` [#invalid_row_ref]
**HTTP status:** `400`
A placement create/update names, in one of its rows, an audience, funnel or experiment that does not belong to the project, or an experiment that is not a FUNNEL experiment. The message names the row reference; `error.details` carries what kind it is (`kind`: audience | funnel | experiment | experimentType) and the ids (`ids`).
**What to do:** Pick targets from this project only (the placement editor's pickers list exactly those) and save again.
***
### `audience_in_use` [#audience_in_use]
**HTTP status:** `409`
`DELETE /dashboard/audiences/:id` refuses an audience that an experiment, a feature flag rule or a placement row still references; `error.details.references` lists each as `{ type, id, name }`.
**What to do:** Point those experiments, flags and placements at another audience (or remove the rules), then delete the audience.
***
### `CUSTOM_DOMAIN_LIMIT_REACHED` [#custom_domain_limit_reached]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/custom-domains` refuses on a cloud deployment (`HOST_MODE=cloud`) because the project already has its one custom domain. Self-hosted deployments have no limit.
**What to do:** Detach the existing custom domain first; one domain serves every web funnel of the project.
***
### `HOSTNAME_TAKEN` [#hostname_taken]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/custom-domains` refuses a hostname another custom domain (in any project) already holds. Hostnames are unique across the deployment.
**What to do:** Use a different hostname, or detach it from the project that holds it first.
***
### `ROOT_FUNNEL_INVALID` [#root_funnel_invalid]
**HTTP status:** `400`
`PATCH /dashboard/projects/:projectId/custom-domains/:id` refuses a `rootFunnelId` that is not a published web funnel of this project.
**What to do:** Choose a published web funnel of this project as the root, or clear it (null) so `/` is a 404.
***
### `PAYMENT_ALREADY_RECORDED` [#payment_already_recorded]
**HTTP status:** `409`
The public funnel-payment routes refuse because this session's payment is already recorded (or the purchase is already complete). Returned by `payment-intent`.
**What to do:** Stop trying to pay: the runner moves on to claiming the purchase.
***
### `PAYMENT_TEMPORARILY_UNAVAILABLE` [#payment_temporarily_unavailable]
**HTTP status:** `503`
The public funnel-payment routes could not take the per-session payment lock (Redis unavailable), or the project's Stripe connection is gone while finalizing a payment that may already have been charged.
**What to do:** Retry after a moment. A buyer who was charged can restore the purchase from the app using the email they paid with.
***
### `PAYMENT_IN_FLIGHT` [#payment_in_flight]
**HTTP status:** `409`
Another payment attempt for this funnel session holds the payment lock, or won the race to write it.
**What to do:** Retry shortly; the other attempt will either finish the payment or release the lock.
***
### `PAYMENT_NOT_SETTLED_YET` [#payment_not_settled_yet]
**HTTP status:** `409`
`claim` on the public funnel-payment routes: Stripe has not reported the payment as settled yet. Transient, unlike a terminal 409.
**What to do:** Retry with backoff; the runner does this automatically.
***
### `PAYMENT_PROVIDER_REJECTED` [#payment_provider_rejected]
**HTTP status:** `422`
`payment-intent` on the public funnel-payment routes: Stripe answered and refused to create the payment — a declined card (the message is Stripe's own), or a request the project's connected Stripe account cannot serve (for example an account type that cannot take direct charges). Nothing was charged. `details` carries `stripeRequestId`, `stripeErrorType` and `stripeErrorCode`. An unreachable Stripe is `STORE_UNAVAILABLE` instead.
**What to do:** A declined card: try another card. An account refusal (`stripeErrorType` other than `StripeCardError`): the project owner looks the request id up in their Stripe dashboard (Developers → Logs) and fixes the account — until then a retry is refused the same way.
***
### `PAYMENT_UNVERIFIABLE` [#payment_unverifiable]
**HTTP status:** `409`
`confirm` on the public funnel-payment routes: Stripe answered but will not show this session's payment — typically the project reconnected a different Stripe account, so the recorded subscription or PaymentIntent is unknown there, or the account revoked Rovenue's access. Says nothing about whether the buyer paid. `details` carries `stripeRequestId`, `stripeErrorType` and `stripeErrorCode`.
**What to do:** The buyer must not pay again: if they were charged, they restore the purchase from the app with the email they paid with (the Connect webhook also completes it once it arrives). The project owner checks the request id in their Stripe dashboard.
***
### `CONFIRM_EMAIL_MISMATCH` [#confirm_email_mismatch]
**HTTP status:** `400`
`DELETE /dashboard/me` (self-service account deletion): the `confirmEmail` in the body is not the signed-in account's email (compared case-insensitively). Nothing was deleted.
**What to do:** Type the account's own email address exactly as shown on the profile page to confirm the deletion.
***
### `OWNS_PROJECTS` [#owns_projects]
**HTTP status:** `409`
`DELETE /dashboard/me` refuses because the caller is still an OWNER of one or more projects; deleting the account would leave them ownerless. The message names the projects; `error.details.projects` carries them (\{ id, name }\[]). Nothing was deleted.
**What to do:** Transfer ownership of each named project to another member (Members page), or delete the project, then delete the account again.
***
### `exposure_out_of_range` [#exposure_out_of_range]
**HTTP status:** `400`
`POST /v1/experiments/:id/expose`: the `exposedAt` is before the experiment's `startedAt` or more than five minutes in the future. An honest client cannot have been served the experiment before it started. No exposure was recorded.
**What to do:** Send the time the variant was actually shown, or omit `exposedAt` to have the server use the receive time. Check the device clock if this happens for recent exposures.
***
### `EXPERIMENT_NOT_RUNNING` [#experiment_not_running]
**HTTP status:** `409`
`POST /v1/experiments/:id/expose` on an experiment that is not RUNNING (draft, paused or completed). It has no live assignment surface, so the exposure is not recorded.
**What to do:** Nothing to retry: the same exposure is refused until the experiment runs again. The SDKs record it as sent and stop re-posting it.
***
### `promote_requires_flag_experiment` [#promote_requires_flag_experiment]
**HTTP status:** `400`
`POST /dashboard/experiments/:id/stop` with `promoteToFlag: true` on a FUNNEL or OFFERING experiment. Only a FLAG experiment's winning value is a feature-flag value. The experiment was not stopped. `error.details.type` is the experiment's type.
**What to do:** Stop without `promoteToFlag`. A FUNNEL experiment's winner repoints its placements; an OFFERING experiment just completes with the winner recorded.
***
### `window_too_large` [#window_too_large]
**HTTP status:** `400`
`GET /metrics/mrr-movement` refuses a window longer than 1 096 days, or longer than 400 days at grain=day; `error.details` carries `{ days, maxDays }`.
**What to do:** Ask for a shorter window or a coarser grain (week/month/total).
***
### `USAGE_LIMIT_EXCEEDED` [#usage_limit_exceeded]
**HTTP status:** `403`
`middleware/usage-lock.ts` answers the dashboard's project feature routes with this once the project exceeded its plan's hard caps for two consecutive billing periods (`projects.usage_locked_at`). Project administration stays reachable (the project itself, billing, members, invitations, API keys, webhook secret), and SDK `/v1` and store webhooks are untouched: data collection never stops. Only where billing is enabled.
**What to do:** Upgrade the project's plan from its billing page. The feature routes answer this until the lock is lifted.
***
### `BILLING_CARD_IN_USE` [#billing_card_in_use]
**HTTP status:** `409`
`DELETE /dashboard/projects/:projectId/billing/payment-methods/:pmId` refuses the project's last card while the project is on a paid plan: metered billing collects off-session from it.
**What to do:** Cancel metered billing first; remove the card once the project is back on Free.
***
### `BILLING_CARD_OPEN_INVOICE` [#billing_card_open_invoice]
**HTTP status:** `409`
`DELETE /dashboard/projects/:projectId/billing/payment-methods/:pmId` refuses the project's last card while an invoice of the project is still open (e.g. the final period's, billed after the project went back to Free): it is collected from the card on file.
**What to do:** Pay the open invoice from Invoices first, then remove the card.
***
### `WEBHOOK_EVENT_EXPIRED` [#webhook_event_expired]
**HTTP status:** `410`
`POST /dashboard/projects/:projectId/integrations/:id/deliveries/:deliveryId/redeliver` when the delivery's originating outbox event is no longer stored (pruned by retention, or a delivery recorded before deliveries kept their source row). The delivery row itself is kept.
**What to do:** Nothing to retry: the event cannot be rebuilt. Replay the underlying change at its source if the receiver still needs it.
***
### `WEBHOOK_EVENT_UNMAPPABLE` [#webhook_event_unmappable]
**HTTP status:** `422`
`POST .../deliveries/:deliveryId/redeliver` when the originating outbox event still exists but no longer maps onto a deliverable webhook event (its event type was retired).
**What to do:** Nothing to retry: the same delivery is refused every time.
***
### `STALE_BATCH` [#stale_batch]
**HTTP status:** `422`
`POST /v1/sdk/sessions` when every event of the batch lies outside the 48-hour freshness window. Nothing was recorded. A batch with only some stale events is accepted with the stale ones dropped.
**What to do:** Drop the batch; resending it is refused the same way. The SDKs do this on their own.
***
### `TELEMETRY_UNAVAILABLE` [#telemetry_unavailable]
**HTTP status:** `503`
`POST /v1/sdk/sessions` when the session pipeline (Kafka) is not configured or the produce failed. Nothing was recorded.
**What to do:** Keep the batch and retry with backoff. The SDKs buffer and retry on their own.
***
### `IDENTIFIER_TAKEN` [#identifier_taken]
**HTTP status:** `409`
A dashboard create or rename collides with an identifier another row of the same kind already holds in the project: an access level, offering, product, placement or leaderboard identifier, a cohort name or a virtual-currency code. `error.details.resource` names the kind (access | offering | product | placement | leaderboard | cohort | virtualCurrency) and `error.details.identifier` the taken value.
**What to do:** Choose another identifier, or edit the existing row instead.
***
### `UNKNOWN_REFERENCES` [#unknown_references]
**HTTP status:** `400`
A dashboard write names rows that do not exist in the project (or cannot be used there): product ids in an offering's packages, access ids or virtual currencies on a product (404 for a currency), a variant id on an experiment update, funnels that are not native and live in this project as experiment variants, or a `startAfterExperimentId` from another project. `error.details.resource` names the kind (product | access | virtualCurrency | variant | funnel | experiment) and `error.details.ids` the refused ids.
**What to do:** Pick the rows from this project (the dashboard's pickers list exactly those) and save again.
***
### `IMPORT_JOB_STATE` [#import_job_state]
**HTTP status:** `409`
An import-job action the job's current state does not allow: editing the mapping or starting a dry run while the job runs, committing anything but a finished dry run, resuming a job that is not VERIFICATION\_INCOMPLETE or VERIFYING, cancelling a finished job. `error.details.action` names the action (editMapping | dryRun | commit | resume | cancel) and `error.details.state` the job's `ImportJobStatus`.
**What to do:** Reload the job: act on it once it reaches a state that allows the action, or start a new import.
***
### `IMPORT_MAPPING_INCOMPLETE` [#import_mapping_incomplete]
**HTTP status:** `400`
`PATCH …/imports/:id/mapping` or a dry run on a mapping that leaves required canonical fields unmapped. `error.details.missingFields` lists them.
**What to do:** Map a column to each listed field, then save the mapping again.
***
### `EXPERIMENT_STATE` [#experiment_state]
**HTTP status:** `400`
An experiment action its status does not allow: editing a PAUSED or COMPLETED experiment, deleting or starting anything but a DRAFT, pausing anything but a RUNNING experiment, resuming anything but a PAUSED one, stopping one that is neither. `error.details.action` names the action (edit | start | pause | resume | stop | delete) and `error.details.status` the experiment's `ExperimentStatus`.
**What to do:** Reload the experiment; its status changed or the action does not apply to it.
***
### `WEBHOOK_STATE` [#webhook_state]
**HTTP status:** `400`
`POST /dashboard/webhooks/:id/retry` or `/dismiss` on a delivery that is not DEAD. `error.details.action` names the action (retry | dismiss) and `error.details.status` the delivery's `OutgoingWebhookStatus`.
**What to do:** Only a dead delivery can be retried or dismissed; reload the list.
***
### `COPILOT_INTENT_SETTLED` [#copilot_intent_settled]
**HTTP status:** `409`
`POST …/copilot/intents/:id/reject` or `/execute` on an intent that is no longer pending. `error.details.status` is its status (rejected | expired | executed | failed).
**What to do:** Nothing to do: the intent was already settled. Ask Rovi again for a fresh one.
***
### `ROLE_REQUIRED` [#role_required]
**HTTP status:** `403`
A dashboard project route the caller's role ranks below (`assertProjectAccess`). `error.details.role` is the caller's `MemberRole`, `error.details.requiredRole` the lowest role the route accepts.
**What to do:** Ask a project owner for a role at or above the required one.
***
### `CAPABILITY_REQUIRED` [#capability_required]
**HTTP status:** `403`
A dashboard action whose capability the caller's role lacks (`assertProjectCapability`, e.g. `products:write`, `subscribers:import`). `error.details.role` is the caller's `MemberRole`, `error.details.capability` the capability the action needs.
**What to do:** Ask a project owner for a role that includes the capability.
***
### `BILLING_NOTHING_TO_CANCEL` [#billing_nothing_to_cancel]
**HTTP status:** `409`
`POST …/billing/cancel` (or its undo) when the project has no paid metered-billing subscription to schedule a cancellation on.
**What to do:** Nothing to cancel: the project is already on Free.
***
### `BILLING_ALREADY_ACTIVE` [#billing_already_active]
**HTTP status:** `409`
`POST …/billing/upgrade` when the project already has active metered billing.
**What to do:** Nothing to do: the project is already upgraded.
***
### `BILLING_UPGRADE_REQUIRED` [#billing_upgrade_required]
**HTTP status:** `409`
`POST …/billing/payment-methods` when the project has no billing customer yet: cards are added during the upgrade.
**What to do:** Upgrade the project first; the upgrade collects the card.
***
### `STRIPE_ALREADY_CONNECTED` [#stripe_already_connected]
**HTTP status:** `409`
`POST /dashboard/projects/:projectId/stripe/connect` when the project already has an active Stripe connection (connected from another tab meanwhile).
**What to do:** Reload the Stores page; disconnect the current account first to connect another.
***
### `ALREADY_MEMBER` [#already_member]
**HTTP status:** `409`
`POST …/invitations` for an email that already belongs to a project member. `error.details.email` is that email.
**What to do:** Nothing to send: change the member's role on the Members page instead.
***
### `ALREADY_OWNER` [#already_owner]
**HTTP status:** `409`
An ownership transfer to a member who is already an OWNER.
**What to do:** Nothing to do: pick a member who is not an owner yet.
***
### `INVITATION_NOT_SENDABLE` [#invitation_not_sendable]
**HTTP status:** `409`
`POST …/invitations/:id/resend` on an invitation that cannot be sent again. `error.details.reason` is `closed` (accepted, revoked or expired) or `suppressed` (the address bounced or complained, so the mailer refuses it).
**What to do:** Closed: send a new invitation. Suppressed: invite another address, or have the person fix their mailbox and ask support to lift the suppression.
***
### `INVITATION_ALREADY_ACCEPTED` [#invitation_already_accepted]
**HTTP status:** `409`
`POST /public/invitations/:token/accept` on an invitation that was already accepted.
**What to do:** Nothing to do: open the project from the dashboard.
***
### `INVITATION_EMAIL_MISMATCH` [#invitation_email_mismatch]
**HTTP status:** `403`
`POST /public/invitations/:token/accept` by a signed-in user whose email is not the one the invitation was sent to. `error.details.email` is the invited address.
**What to do:** Sign in with the invited email, or ask for an invitation to your own.
***
### `WEBHOOK_ENDPOINT_LIMIT` [#webhook_endpoint_limit]
**HTTP status:** `409`
Creating a custom-webhook integration when the project already has the maximum number of webhook endpoints. `error.details.limit` is the maximum.
**What to do:** Remove an endpoint you no longer use, then add the new one.
***
### `WEBHOOK_URL_REJECTED` [#webhook_url_rejected]
**HTTP status:** `400`
A custom-webhook URL the delivery guard refuses (create, or a PATCH of `url`). `error.details.reason` says why: `malformed`, `scheme` (not http/https), `insecure` (http in production), `userinfo` (credentials in the URL), `host_not_allowed` (localhost or an internal name), `not_routable` (an IP literal that is not public), `unresolvable` (DNS returned nothing), `private_address` (DNS resolves to a private address), or `credentials_shape` (credentials other than one `url`).
**What to do:** Use a public https URL your receiver serves, without credentials in it.
***
### `TEST_DELIVERY_NOT_REDELIVERABLE` [#test_delivery_not_redeliverable]
**HTTP status:** `409`
`POST …/integrations/:id/deliveries/:deliveryId/redeliver` on a test delivery: a test event has no source row to rebuild.
**What to do:** Send a new test event instead.
***
### `INTEGRATION_ALREADY_CONNECTED` [#integration_already_connected]
**HTTP status:** `409`
Creating a second connection for a provider that allows one per project (the partial unique index of migration 0104).
**What to do:** Edit the existing connection instead.
***
### `INTEGRATION_CREDENTIALS_REJECTED` [#integration_credentials_rejected]
**HTTP status:** `400`
Creating or updating an integration whose credentials the provider check refused (a probe call the provider answered with an error, or a value the provider's own format rules reject). `error.details.reason` is the provider check's own words. A credentials object that fails the provider's schema is a VALIDATION\_ERROR with `details.fields` under `credentials.` instead.
**What to do:** Correct the credentials in the provider's console (a fresh key, the right workspace) and save again.
***
### `SCHEDULED_ACTION_CONFLICT` [#scheduled_action_conflict]
**HTTP status:** `409`
Scheduling or cancelling a subscription action from the dashboard against a state that does not allow it. `error.details.reason`: `terminal` (the purchase already ended), `pending` (an action is already scheduled for it), `not_pending` (the scheduled action already fired or was cancelled).
**What to do:** Reload the subscription; cancel the pending action first to schedule another.
***
### `HOSTNAME_INVALID` [#hostname_invalid]
**HTTP status:** `400`
`POST …/custom-domains` with a hostname that is not a valid DNS name (`reason`: hostname\_invalid) or one Rovenue keeps for itself (`reason`: hostname\_reserved). `error.details.hostname` is the normalised hostname.
**What to do:** Use a subdomain you own, such as `pay.example.com`.
***
### `ORIGIN_INVALID` [#origin_invalid]
**HTTP status:** `400`
`PATCH /dashboard/projects/:projectId` with a browser origin the CORS allow-list cannot use (a path, a wildcard, a bad scheme). `error.details.origin` is the first refused value.
**What to do:** Give scheme, host and optional port only, such as `https://app.example.com`.
***
### `QUERY_REJECTED` [#query_rejected]
**HTTP status:** `400`
The SQL editor's query was refused: by the query validator (not a single read-only SELECT over the allowed tables) or by ClickHouse itself (a parse or type error). `error.details.reason` is the validator's or ClickHouse's own first line, so the editor can show it next to the query.
**What to do:** Fix the query where the reason points and run it again.
***
### `CURRENCY_GRANT_INVALID` [#currency_grant_invalid]
**HTTP status:** `400`
A product's virtual-currency grants cannot work as written. `error.details.reason`: `renewal_needs_subscription` (a RENEWAL or BOTH grant on a product that never renews) or `granted_twice` (one currency granted twice on one term; `error.details.currency` is its code).
**What to do:** Renewal grants: use On purchase, or make the product a subscription. Twice: keep one row per trigger, or a single Both row.
***
### `REFUND_NOT_POSSIBLE` [#refund_not_possible]
**HTTP status:** `422`
`POST …/subscriptions/:purchaseId/refund` or `…/transactions/:id/refund` cannot refund this purchase. `error.details.reason`: `apple_unsupported` (Apple offers no refund API; 422), `missing_store_ref` (the purchase carries no store transaction to refund against; 422) or `already_refunded` (it is already refunded or revoked; 409). Nothing was sent to the store.
**What to do:** Apple: the customer requests the refund from Apple. Missing reference: refund it in the store's console. Already refunded: nothing to do.
***
### `LEGAL_URL_IN_USE` [#legal_url_in_use]
**HTTP status:** `409`
`PATCH /dashboard/projects/:projectId` clearing `termsUrl` or `privacyUrl` (sending `null`) while a LIVE funnel — `published`, web or native — references it as `{{project.terms_url}}` / `{{project.privacy_url}}` in its current version. Funnels receive the links beside the document at serve time, so clearing one would leave those funnels with a broken legal link. Replacing a URL with another https URL is always allowed. `error.details.field` is the refused body field (`termsUrl` or `privacyUrl`); `error.details.funnels` lists the live funnels (`{ id, name }[]`).
**What to do:** Set a different https URL instead of clearing it, or publish versions of the named funnels that no longer reference the link (or unpublish them), then clear it.
***
### `ADMIN_EMAIL_DOMAIN` [#admin_email_domain]
**HTTP status:** `403`
A `/admin/*` request whose session user is not on a platform-admin email domain (`PLATFORM_ADMIN_EMAIL_DOMAINS`: exact, case-insensitive match after the last `@` — look-alikes and subdomains do not match) or whose address is unverified. Re-checked on every request: an admin whose email later changes off-domain loses access immediately.
**What to do:** Sign in with a verified `@rovenue.io` or `@broverse.ai` address that holds a platform-admin grant.
***
### `ADMIN_2FA_REQUIRED` [#admin_2fa_required]
**HTTP status:** `403`
A `/admin/*` request whose session user has no 2FA enabled. Platform admins must pass TOTP (the second-factor gate challenges OAuth and magic-link sign-ins, so every admin session has passed it).
**What to do:** Enable 2FA on the account and sign in again.
***
### `ADMIN_SESSION_EXPIRED` [#admin_session_expired]
**HTTP status:** `401`
A `/admin/*` request on a session older than `ADMIN_SESSION_MAX_AGE_SECONDS` (12 h) since its creation. Admin sessions are short-lived even when the underlying Better Auth session is still valid.
**What to do:** Sign in again on the admin host.
***
### `PROJECT_SUSPENDED` [#project_suspended]
**HTTP status:** `403`
A `/dashboard/projects/:projectId/*` request for a platform-suspended project (Terms §15). Suspension is dashboard-only: SDK, server-to-server, webhook and public-funnel traffic keeps serving, and billing continues. Only a platform admin can lift it.
**What to do:** Contact [support@rovenue.io](mailto:support@rovenue.io). There is no self-service unsuspend.
***
### `SUPPORT_ACCESS_READ_ONLY` [#support_access_read_only]
**HTTP status:** `403`
A mutating request through a read-only support grant. Break-glass access reads at CUSTOMER\_SUPPORT level and writes nothing: every non-GET/HEAD/OPTIONS dashboard route, and every non-read capability, refuses with this code.
**What to do:** Read the data; make the change through a real membership.
# API Explorer (/docs/reference/api-explorer)
This page renders [`apps/api/openapi/openapi.json`](/openapi.json) — the OpenAPI 3.1 document covering the 36
canonical `/v1` endpoints — as a browsable reference with a "try it" panel. It runs entirely in your browser: this
docs site ships as static files with no server behind it, so nothing here proxies your requests or sees your API key.
Rovenue is self-hosted, so there's no shared host to point this at. Enter your own deployment's base URL below, plus
a public or secret API key, and expand any endpoint to see its parameters, request body, and responses — or send a
real request and inspect what comes back.
## If the interactive parts don't load [#if-the-interactive-parts-dont-load]
Every endpoint's panel also has a copyable `curl` command built from the same base URL and key you entered — that
works from a terminal regardless of CORS, JavaScript, or browser extensions. For error codes without the explorer,
see [API Errors](/docs/reference/api-errors); for the raw document, see [`/openapi.json`](/openapi.json) or the
committed [`apps/api/openapi/openapi.json`](https://github.com/broverse/rovenue/blob/main/apps/api/openapi/openapi.json)
in the repository.
# Errors (/docs/reference/errors)
Every Rovenue SDK error originates from the same Rust-core `RovenueError` type and is mapped to an idiomatic type on each platform:
* **React Native** — thrown as `RovenueError` (a class extending `Error`) with a `kind` discriminant. Use `e.kind === "..."` to branch.
* **Swift** — thrown as `RovenueError` (a struct conforming to `LocalizedError`) with a `kind: ErrorKind` discriminant. Catch with `catch let e as RovenueError`.
* **Kotlin** — thrown as `RovenueException` (a plain class, not sealed) with a `kind: ErrorKind` discriminant. There is no per-kind subclass and no exhaustive `when` over it — catch `RovenueException` and branch on `e.kind`.
Calling the HTTP API directly instead of through an SDK? See [API Errors](/docs/reference/api-errors) for the `{ error: { code, message } }` envelope's `code` values — a different, larger set than the `kind`s below.
***
## Migrating from 0.15.x → 0.16.0 [#migrating-from-015x--0160]
**Breaking change in 0.16.0** — the per-type error subclasses and Swift `Rovenue.Error` enum cases are replaced by a single `RovenueError`/`RovenueException` with a `kind` string discriminant, and a pending (Ask-to-Buy) purchase is now a non-throwing `isDeferred` outcome rather than a thrown error.
### React Native: `instanceof` → `e.kind` [#react-native-instanceof--ekind]
```ts
// Before (0.15.x)
import { InsufficientCreditsError, NetworkUnavailableError } from '@rovenue/react-native-sdk';
try {
await Rovenue.purchase(pkg);
} catch (e) {
if (e instanceof InsufficientCreditsError) { /* … */ }
if (e instanceof NetworkUnavailableError) { /* … */ }
}
// After (0.16.0)
import { RovenueError } from '@rovenue/react-native-sdk';
try {
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) {
// Ask-to-Buy / parental controls — not an error. Wait for
// Rovenue.addChangeListener to report 'ENTITLEMENTS_CHANGED' before
// granting entitlements (see "Handling the Deferred purchase state" below).
showToast('Your purchase is awaiting approval.');
return;
}
} catch (e) {
if (e instanceof RovenueError) {
if (e.kind === 'InsufficientCredits') { /* … */ }
if (e.kind === 'NetworkUnavailable') { /* … */ }
}
}
```
### Swift: `Rovenue.Error` enum → `RovenueError` struct [#swift-rovenueerror-enum--rovenueerror-struct]
```swift
// Before (0.15.x)
do {
try await Rovenue.shared.purchase(pkg)
} catch Rovenue.Error.purchasePending { showToast("Awaiting approval.") }
catch Rovenue.Error.insufficientCredits { /* … */ }
// After (0.16.0)
do {
let result = try await Rovenue.shared.purchase(pkg)
if result.isDeferred { showToast("Awaiting approval."); return }
} catch let e as RovenueError {
switch e.kind {
case .insufficientCredits: /* … */
default: throw e
}
}
```
### Kotlin: standalone exceptions → `RovenueException.kind` [#kotlin-standalone-exceptions--rovenueexceptionkind]
```kotlin
// Before (0.15.x)
try {
Rovenue.shared.purchase(activity, pkg)
} catch (e: PurchasePendingException) { showToast("Awaiting approval.") }
catch (e: PurchaseCancelledException) { /* user cancelled */ }
// After (0.16.0)
val outcome = Rovenue.shared.purchase(activity, pkg)
if (outcome.isDeferred) { showToast("Awaiting approval."); return }
// Errors:
try {
Rovenue.shared.purchase(activity, pkg)
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.PURCHASE_CANCELED -> { /* user cancelled — no action */ }
else -> throw e
}
}
```
### Handling the Deferred purchase state [#handling-the-deferred-purchase-state]
`purchase()` no longer throws for Ask-to-Buy / parental-control holds. Instead, it returns a `PurchaseResult` (the same return type as a normal successful purchase) with `isDeferred = true`. You should:
1. Show a friendly message (e.g. "Your purchase is awaiting approval from your family organizer.").
2. Do **not** grant entitlements — entitlements are only granted after the purchase completes asynchronously.
3. There is no purchase-specific notification — the SDK reconciles the pending
transaction in the background and reports the result the same way as any
other entitlement change. Listen for `ChangeEvent`'s `ENTITLEMENTS_CHANGED`
value on each platform's change stream to know when the deferred purchase
resolves: RN `Rovenue.addChangeListener((event) => { if (event ===
'ENTITLEMENTS_CHANGED') { /* re-check entitlements */ } })`; Swift/Kotlin —
collect `Rovenue.shared.changes` (`AsyncStream` / `SharedFlow`)
and match `.entitlementsChanged` / `ENTITLEMENTS_CHANGED`.
***
***
## Calling the SDK before `configure()` [#calling-the-sdk-before-configure]
This is **not** an entry in the table below — it isn't part of the `ErrorKind` taxonomy
at all, and it is not something to write a `catch` for. Calling any other SDK method
before `configure()` has run is a programming error to fix at the call site (always
`configure()` first, at app startup — see [App lifecycle](/docs/guides/app-lifecycle)),
not a runtime condition your code should branch on. What actually happens if you get
the ordering wrong, per platform:
* **iOS** (native Swift, and React Native / Flutter on iOS, which call into the same
Swift singleton) — a **hard crash**. Accessing `Rovenue.shared` before `configure()`
triggers Swift's `fatalError`, which aborts the process immediately. This is not a
throwable Swift error: no `do`/`catch` in Swift, no `try`/`catch` in JS, and no
`try`/`catch` in Dart can intercept it — the crash log will show `"Rovenue: must
call Rovenue.configure(apiKey:baseUrl:) before accessing shared"` just before the
process terminates.
* **Android** (native Kotlin) — throws a plain `IllegalStateException`, **not** a
`RovenueException`. A `catch (e: RovenueException)` will not observe it at all; you'd
need a broader `catch (e: Exception)` to see it, and even then it carries no
`ErrorKind` — it's outside the taxonomy this page documents.
* **React Native on Android** and **Flutter on Android** — the native bridge on this
platform catches everything and re-surfaces it as a normal, catchable error, but
because the underlying `IllegalStateException` isn't a `RovenueException`, it
normalizes to the generic `kind: "Internal"` (RN) / `RovenueErrorKind.internal`
(Flutter) — indistinguishable from any other unexpected internal failure, not a
signal you can branch on to detect "not configured" specifically.
The only reliable fix is ordering: call `configure()` before anything else, every time.
## Error table [#error-table]
Every row names the actual value to check — there is no per-kind subclass or nested
type on any platform (see the platform notes at the top of this page). React Native
and Swift check `RovenueError.kind`; Kotlin checks `RovenueException.kind`.
| When it fires | React Native (`kind`) | Swift (`kind`) | Kotlin (`kind`) |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------- | ----------------------------------- |
| API key is missing, malformed, or rejected by the server | `'InvalidApiKey'` | `.invalidApiKey` | `ErrorKind.INVALID_API_KEY` |
| An argument was invalid — e.g. an empty string passed to `identify()` | `'InvalidArgument'` | `.invalidArgument` | `ErrorKind.INVALID_ARGUMENT` |
| Server returned a non-recoverable error response | `'ServerError'` | `.serverError` | `ErrorKind.SERVER_ERROR` |
| Device has no network connectivity | `'NetworkUnavailable'` | `.networkUnavailable` | `ErrorKind.NETWORK_UNAVAILABLE` |
| Request exceeded the allowed time limit | `'Timeout'` | `.timeout` | `ErrorKind.TIMEOUT` |
| Too many requests — SDK is being rate-limited; back off and retry | `'RateLimited'` | `.rateLimited` | `ErrorKind.RATE_LIMITED` |
| Local persistent storage (MMKV) read/write failure | `'Storage'` | `.storage` | `ErrorKind.STORAGE` |
| Returned by the server when a virtual-currency spend transaction has insufficient balance (server-side API only — no client SDK method throws this) | `'InsufficientCredits'` | `.insufficientCredits` | `ErrorKind.INSUFFICIENT_CREDITS` |
| Receipt could not be validated by the store or server | `'ReceiptInvalid'` | `.receiptInvalid` | `ErrorKind.RECEIPT_INVALID` |
| The server cannot resolve this device's subscriber because its merge history is corrupt (`SUBSCRIBER_UNRESOLVABLE`). Thrown by `identify()`; not retryable — the project owner must repair the data. Queued events and attributes are kept and delivered after the repair | `'SubscriberUnresolvable'` | `.subscriberUnresolvable` | `ErrorKind.SUBSCRIBER_UNRESOLVABLE` |
| Unexpected internal SDK error | `'Internal'` | `.internal` | `ErrorKind.INTERNAL` |
| User cancelled the store purchase sheet | `'PurchaseCanceled'` | `.purchaseCanceled` | `ErrorKind.PURCHASE_CANCELED` |
| The selected product is not available in the user's storefront | `'ProductNotAvailable'` | `.productNotAvailable` | `ErrorKind.PRODUCT_NOT_AVAILABLE` |
| The store returned a non-specific billing or connectivity error | `'StoreProblem'` | `.storeProblem` | `ErrorKind.STORE_PROBLEM` |
There is no `PurchasePending` (or equivalent) row: as covered above,
Ask-to-Buy / parental-control holds are not an error on any platform as of
0.16.0 — `purchase()` returns a `PurchaseResult` with `isDeferred = true`
instead of throwing. See [Handling the Deferred purchase
state](#handling-the-deferred-purchase-state).
**`InvalidApiKey` vs `InvalidArgument`**
`InvalidApiKey` fires when the SDK detects that your API key is malformed (wrong prefix) at `configure()` time, or when the server rejects it on the first network request. It indicates a **configuration problem** — check your dashboard key.
`InvalidArgument` fires when your code passes an invalid value to a method at call time — for example, an empty string to `identify()`. It indicates a **programming error** in your call site.
***
## Handling errors [#handling-errors]
All throwing SDK methods should be wrapped in try/catch. The recommended pattern is to handle the errors you expect explicitly, then re-throw anything unexpected so it surfaces in your crash reporting.
```ts
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
// --- Purchase flow ---
try {
const result = await Rovenue.purchase(pkg);
// success — check result.isDeferred for Ask-to-Buy (see below;
// it does not throw)
} catch (e) {
if (!(e instanceof RovenueError)) throw e; // not an SDK error — re-throw
switch (e.kind) {
case 'PurchaseCanceled':
// User dismissed the store sheet — no action needed
break;
case 'ProductNotAvailable':
showToast('This product is not available in your region.');
break;
case 'ReceiptInvalid':
showRetryPrompt();
break;
case 'NetworkUnavailable':
showToast('No internet connection. Please try again.');
break;
default:
throw e; // re-throw unexpected SDK errors
}
}
```
```swift
import Rovenue
// --- Purchase flow ---
do {
let result = try await Rovenue.shared.purchase(pkg)
// success — check result.isDeferred for Ask-to-Buy (see below;
// it does not throw)
} catch let e as RovenueError {
switch e.kind {
case .purchaseCanceled:
break // User dismissed the store sheet — no action needed
case .productNotAvailable:
showToast("This product is not available in your region.")
case .receiptInvalid:
showRetryPrompt()
case .networkUnavailable:
showToast("No internet connection. Please try again.")
default:
throw e // re-throw unexpected SDK errors
}
} catch {
throw error // not a RovenueError — re-throw
}
```
```kotlin
import dev.rovenue.sdk.Rovenue
import dev.rovenue.sdk.RovenueException
import dev.rovenue.sdk.generated.ErrorKind
// --- Purchase flow ---
try {
// `activity` is your current Activity — Play Billing requires it.
val result = Rovenue.shared.purchase(activity, pkg)
// success — check result.isDeferred for Ask-to-Buy (see below;
// it does not throw)
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.PURCHASE_CANCELED -> {
// User dismissed the store sheet — no action needed
}
ErrorKind.PRODUCT_NOT_AVAILABLE -> {
showToast("This product is not available in your region.")
}
ErrorKind.RECEIPT_INVALID -> {
showRetryPrompt()
}
ErrorKind.NETWORK_UNAVAILABLE -> {
showToast("No internet connection. Please try again.")
}
else -> throw e // re-throw unexpected SDK errors
}
}
```
A `PurchaseCanceled` `kind` is **not an error** from the user's perspective. It fires whenever the store sheet is dismissed without a completed payment — including when the user deliberately taps Cancel. Do not show an error dialog for this case.
***
## Which methods throw which errors [#which-methods-throw-which-errors]
Values below are `kind`s (see the [error table](#error-table) above for the exact
per-platform spelling of each).
| Method | Possible `kind`s |
| --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`configure()`](/docs/reference/methods#configuration) | [`InvalidApiKey`](#error-table) |
| [`identify(appUserId)`](/docs/reference/methods#identify) | [`InvalidArgument`](#error-table) |
| [`refreshEntitlements()`](/docs/reference/methods#refreshentitlements) | [`NetworkUnavailable`](#error-table), [`ServerError`](#error-table) |
| [`refreshVirtualCurrencies()`](/docs/reference/methods#refreshvirtualcurrencies) | [`NetworkUnavailable`](#error-table), [`ServerError`](#error-table) |
| [`getOfferings()`](/docs/reference/methods#getofferings) | [`NetworkUnavailable`](#error-table), [`ServerError`](#error-table) |
| [`purchase()`](/docs/reference/methods#purchase) | [`PurchaseCanceled`](#error-table), [`ProductNotAvailable`](#error-table), [`StoreProblem`](#error-table), [`ReceiptInvalid`](#error-table), [`NetworkUnavailable`](#error-table) — plus a non-throwing `isDeferred` outcome, see [Handling the Deferred purchase state](#handling-the-deferred-purchase-state) |
| [`restorePurchases()`](/docs/reference/methods#restorepurchases) | [`NetworkUnavailable`](#error-table), [`ServerError`](#error-table) |
| [`setFallbackPlacements()`](/docs/reference/methods#setfallbackplacements) | [`InvalidArgument`](#error-table) |
| [`refreshRemoteConfig()`](/docs/reference/methods#refreshremoteconfig) | [`NetworkUnavailable`](#error-table), [`Timeout`](#error-table), [`RateLimited`](#error-table), [`InvalidApiKey`](#error-table), [`ServerError`](#error-table) |
| [`setAttributes()`](/docs/reference/methods#setattributes) — and `setEmail()`, `setDisplayName()`, `setPhoneNumber()`, `setPushToken()` | [`Storage`](#error-table) |
| [`flushAttributes()`](/docs/reference/methods#flushattributes) | [`NetworkUnavailable`](#error-table), [`Timeout`](#error-table), [`RateLimited`](#error-table), [`InvalidApiKey`](#error-table), [`Forbidden`](#error-table), [`ServerError`](#error-table) |
| [`getAppAccountToken()`](/docs/reference/methods#account-token) | [`Storage`](#error-table) |
| All methods | [`Internal`](#error-table) (unexpected failures) — see [Calling the SDK before `configure()`](#calling-the-sdk-before-configure) for what happens if `configure()` was skipped; it is not one of these `kind`s |
# Methods (/docs/reference/methods)
Quick navigation: [Configuration](#configuration) · [Identity](#identity) · [Entitlements](#entitlements) · [Virtual Currencies](#virtual-currencies) · [Events](#events) · [Purchases](#purchases) · [Funnels](#funnels) · [Remote Config](#remote-config) · [Subscriber Attributes](#subscriber-attributes) · [Account Token](#account-token) · [Lifecycle](#lifecycle) · [Logging](#logging) · [Change Stream](#change-stream) · [Funnel Attribution](#funnel-attribution)
***
## Configuration [#configuration]
### `configure` [#configure]
Initialise the SDK. Call this **once at app startup**, before any other Rovenue call. On React Native, pass a single config object. On Swift and Kotlin, use labelled parameters.
**Params**
| Parameter | Type | Required | Description |
| ------------ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `string` | Yes | Your project's public API key. Prefix `rov_pub_`. |
| `baseUrl` | `string` | No | API endpoint. Defaults to the hosted endpoint `https://api.rovenue.io`; override only when self-hosting (`https://`; `http://` allowed for `localhost` only). |
| `logLevel` | `string` | No | Verbosity of SDK logs: `off` / `error` / `warn` / `info` / `debug` / `trace`. Defaults to `warn`. |
| `appVersion` | `string` | No | App version string forwarded to the server for analytics. Defaults to the value reported by the OS. |
**Returns** — `void`
**Throws** — [`InvalidApiKey`](/docs/reference/errors#error-table) if the key format is invalid at call time (a malformed prefix). The key is not validated against the server until the first network request.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.configure({
apiKey: 'rov_pub_…',
logLevel: 'info',
appVersion: '3.1.0',
// baseUrl: 'https://api.acme.com', // optional — only when self-hosting
});
```
```swift
import Rovenue
Rovenue.configure(
apiKey: "rov_pub_…",
logLevel: .info,
appVersion: "3.1.0"
// baseUrl: "https://api.acme.com" // optional — only when self-hosting
)
```
```kotlin
import dev.rovenue.sdk.Rovenue
Rovenue.configure(
apiKey = "rov_pub_…",
logLevel = LogLevel.INFO,
appVersion = BuildConfig.VERSION_NAME
// baseUrl = "https://api.acme.com" // optional — only when self-hosting
)
```
`configure` must be called before any other Rovenue method. This is a programming error to prevent by call-site ordering, not a runtime error to catch: on **iOS**, accessing the SDK before `configure` is a hard crash (`fatalError`, not a throwable error — no `catch` anywhere can intercept it); on **Android**, it throws a plain `IllegalStateException` that is not a `RovenueException`. See [Calling the SDK before `configure()`](/docs/reference/errors#calling-the-sdk-before-configure) for the full per-platform breakdown, including React Native and Flutter.
**See also:** [Configuring the SDK guide](/docs/guides/configuring)
***
## Identity [#identity]
### Method summary [#method-summary]
| Method | Signature (RN) | Returns | Throws |
| ------------- | --------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `currentUser` | `currentUser()` | [`User`](/docs/reference/types#user) | — |
| `identify` | `identify(appUserId)` | `void` | [`InvalidArgument`](/docs/reference/errors#error-table), [`SubscriberUnresolvable`](/docs/reference/errors#error-table) |
| `logOut` | `logOut()` | `void` | — |
***
### `currentUser` [#currentuser]
Synchronous cache read — no network call. Returns the [`User`](/docs/reference/types#user) for the current install.
**Params** — none
**Returns**
```ts
type User = {
rovenueId: string; // always present, prefix "rov_"
appUserId: string | null; // null until identify() has been called
};
```
→ Full type reference: [`User`](/docs/reference/types#user)
**Throws** — nothing
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const user = await Rovenue.currentUser();
console.log(user.rovenueId); // e.g. "rov_01abc..."
console.log(user.appUserId); // null until identify() called
```
```swift
let user = await Rovenue.shared.currentUser()
print(user.rovenueId) // "rov_01abc..."
print(user.appUserId ?? "anonymous")
```
```kotlin
val user = Rovenue.shared.currentUser()
Log.d("App", user.rovenueId) // "rov_01abc..."
Log.d("App", user.appUserId ?: "anonymous")
```
***
### `identify` [#identify]
Bind a backend user ID to the current install. Rovenue attaches the `appUserId` label to the `rovenueId` and, if that `appUserId` already exists on another install, transfers that install's purchases and credits to this device. The call is **optimistic** — local state updates immediately; the server sync completes in the background.
**Params**
| Parameter | Type | Required | Description |
| ----------- | -------- | -------- | --------------------------------------------------------- |
| `appUserId` | `string` | Yes | Your backend's stable user identifier. Must be non-empty. |
**Returns** — `void`
**Throws** — [`InvalidArgument`](/docs/reference/errors#error-table) if `appUserId` is empty or blank; [`SubscriberUnresolvable`](/docs/reference/errors#error-table) if the server cannot resolve this install's subscriber because its merge history is corrupt. In that case the local link is still written and stays unsynced, and it is sent once the project owner repairs the data. Every other server failure (offline, 5xx) is retried in the background and not thrown.
```ts
// After your auth flow resolves:
await Rovenue.identify('user_123');
```
```swift
try await Rovenue.shared.identify("user_123")
```
```kotlin
Rovenue.shared.identify("user_123") // suspend fun
```
Use **opaque, unguessable** IDs — never an email address or username. Because `identify()` can transfer purchases between installs, a guessable ID is an account-takeover surface. See the [Identifying users guide](/docs/guides/identifying-users) for the recommended token-based flow.
**Errors:** [`InvalidArgument`](/docs/reference/errors#error-table)
***
### `logOut` [#logout]
Mint a fresh anonymous `rovenueId`, clear `appUserId`, and drop all scope-bound caches. Call when your user signs out so the next user on a shared device starts clean.
**Params** — none
**Returns** — `void`
**Throws** — nothing
```ts
await Rovenue.logOut();
```
```swift
try await Rovenue.shared.logOut()
```
```kotlin
Rovenue.shared.logOut() // suspend fun
```
**See also:** [Identifying users guide](/docs/guides/identifying-users), [Identity consent guide](/docs/guides/identity-consent)
***
## Entitlements [#entitlements]
### Method summary [#method-summary-1]
| Method | Signature (RN) | Returns | Throws |
| --------------------- | ----------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `entitlement` | `entitlement(id)` | [`Entitlement`](/docs/reference/types#entitlement) `\| null` | — |
| `entitlementsAll` | `entitlementsAll()` | [`Entitlement[]`](/docs/reference/types#entitlement) | — |
| `refreshEntitlements` | `refreshEntitlements()` | `void` | [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table) |
`entitlement()` and `entitlementsAll()` are **synchronous cache reads** — instant, no network. The cache is populated on configure and kept current by the [change stream](#change-stream). Call `refreshEntitlements()` to force a server round-trip (e.g. after a server-side grant).
***
### `entitlement` [#entitlement]
Return a single cached entitlement by its dashboard identifier, or `null` if the user holds no such entitlement.
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | -------------------------------------------------------------------------------------------- |
| `id` | `string` | Yes | The entitlement key as configured in the Rovenue dashboard (e.g. `"pro"`, `"credits_pack"`). |
**Returns**
```ts
// Entitlement | null
```
→ Full type reference: [`Entitlement`](/docs/reference/types#entitlement)
**Throws** — nothing
```ts
const ent = await Rovenue.entitlement('pro');
if (ent?.isActive) {
// unlock Pro features
}
```
```swift
let ent = await Rovenue.shared.entitlement(id: "pro")
if ent?.isActive == true {
// unlock Pro features
}
```
```kotlin
val ent = Rovenue.shared.entitlement(id = "pro")
if (ent?.isActive == true) {
// unlock Pro features
}
```
***
### `entitlementsAll` [#entitlementsall]
Return every cached entitlement for the current user.
**Params** — none
**Returns**
```ts
// Entitlement[]
```
→ Full type reference: [`Entitlement`](/docs/reference/types#entitlement)
**Throws** — nothing
```ts
const all = await Rovenue.entitlementsAll();
const active = all.filter(e => e.isActive);
```
```swift
let all = await Rovenue.shared.entitlementsAll()
let active = all.filter { $0.isActive }
```
```kotlin
val all = Rovenue.shared.entitlementsAll()
val active = all.filter { it.isActive }
```
***
### `refreshEntitlements` [#refreshentitlements]
Force a server round-trip to refresh the entitlement cache. Fires an entitlements-changed [`ChangeEvent`](/docs/reference/types#changeevent) (RN: `'ENTITLEMENTS_CHANGED'`; Swift: `.entitlementsChanged`; Kotlin: `ChangeEvent.ENTITLEMENTS_CHANGED`) when the cache is updated.
**Params** — none
**Returns** — `void`
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```ts
// After a server-side entitlement grant, force-refresh:
await Rovenue.refreshEntitlements();
```
```swift
try await Rovenue.shared.refreshEntitlements()
```
```kotlin
Rovenue.shared.refreshEntitlements() // suspend fun
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
**See also:** [Entitlements guide](/docs/guides/entitlements)
***
## Virtual Currencies [#virtual-currencies]
### Method summary [#method-summary-2]
| Method | Signature (RN) | Returns | Throws |
| -------------------------- | ---------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `virtualCurrencies` | `virtualCurrencies()` | `Promise>` | — |
| `virtualCurrency` | `virtualCurrency(code)` | `Promise` | — |
| `refreshVirtualCurrencies` | `refreshVirtualCurrencies()` | `void` | [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table) |
React Native also exposes `useVirtualCurrencies()` and `useVirtualCurrency(code)` hooks. Swift uses `virtualCurrencyBalances() -> [String: Int64]` and `virtualCurrency(_ code:) -> Int64`. Kotlin uses `virtualCurrencyBalances(): Map` and `virtualCurrency(code): Long`.
`virtualCurrencies()` and `virtualCurrency(code)` resolve immediately from the local cache (no network round-trip), but are async — `await` them. `refreshVirtualCurrencies()` forces a server round-trip. **Spending virtual currency is server-side only** — call `POST /v1/virtual-currencies/:appUserId/:code/transactions` from your backend using your secret API key.
***
### `virtualCurrencies` [#virtualcurrencies]
Return the cached virtual-currency balances for the current user as a map of currency code to amount.
**Params** — none
**Returns** — `Promise>` (resolves to empty object if not yet loaded)
**Throws** — nothing
```ts
const balances = await Rovenue.virtualCurrencies();
console.log('COINS:', balances['COINS'] ?? 0);
```
```swift
let balances = await Rovenue.shared.virtualCurrencyBalances()
print("COINS:", balances["COINS"] ?? 0)
```
```kotlin
val balances = Rovenue.shared.virtualCurrencyBalances()
Log.d("App", "COINS: ${balances["COINS"] ?: 0}")
```
***
### `virtualCurrency` [#virtualcurrency]
Return the cached balance for a single currency code. Returns `0` if the currency is not present.
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------------------------------------------------------------ |
| `code` | `string` | Yes | The currency code as configured in the Rovenue dashboard (e.g. `"COINS"`, `"GEMS"`). |
**Returns** — `Promise` (resolves to `0` if absent or not yet loaded)
**Throws** — nothing
```ts
const coins = await Rovenue.virtualCurrency('COINS');
console.log(`You have ${coins} coins`);
```
```swift
let coins = await Rovenue.shared.virtualCurrency("COINS")
print("You have \(coins) coins")
```
```kotlin
val coins = Rovenue.shared.virtualCurrency("COINS")
Log.d("App", "You have $coins coins")
```
***
### `refreshVirtualCurrencies` [#refreshvirtualcurrencies]
Force a server round-trip to refresh the virtual-currency balance cache. Fires a virtual-currencies-changed [`ChangeEvent`](/docs/reference/types#changeevent) (RN: `'VIRTUAL_CURRENCIES_CHANGED'`; Swift: `.virtualCurrenciesChanged`; Kotlin: `ChangeEvent.VIRTUAL_CURRENCIES_CHANGED`) when the cache is updated.
**Params** — none
**Returns** — `void`
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```ts
await Rovenue.refreshVirtualCurrencies();
```
```swift
try await Rovenue.shared.refreshVirtualCurrencies()
```
```kotlin
Rovenue.shared.refreshVirtualCurrencies() // suspend fun
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
**See also:** [Virtual Currencies guide](/docs/guides/credits)
***
## Events [#events]
### `track` [#track]
Emit a generic event to the backend. The server forwards it to any configured integrations (e.g. Meta Conversions API, TikTok Events API). The call performs the `POST /v1/events` request (retrying transient failures) and **rejects if delivery ultimately fails** — callers that don't need delivery confirmation can ignore the returned promise.
**Params**
| Parameter | Type | Required | Description |
| ----------- | ----------------------------- | -------- | --------------------------------------------------------------- |
| `eventType` | `string` | Yes | The event name (e.g. `"purchase"`, `"lead"`, `"registration"`). |
| `params` | [`TrackParams`](#trackparams) | No | Optional event metadata. |
#### `TrackParams` [#trackparams]
| Field | Type | Description |
| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `occurredAt` | `string` | ISO-8601 timestamp of when the event occurred. Defaults to now. |
| `subscriberId` | `string` | Override the subscriber scope. Defaults to the current user / anonymous install. |
| `productId` | `string` | Associated product identifier. |
| `amount` | `string` | Decimal string (e.g. `"9.99"`). |
| `currency` | `string` | ISO-4217 currency code (e.g. `"USD"`). |
| `eventSourceUrl` | `string` | URL of the page or screen where the event occurred. |
| `identityContext` | `object` | Identity signals for ad-platform matching. Sent as **plaintext**; the server hashes them before forwarding to integrations. Fields: `email`, `externalId`, `phone`, `ip`, `userAgent`, `firstName`, `lastName`, `city`, `countryCode`. |
**Returns** — `void`
**Throws** — rejects with a `RovenueError` if the event can't be delivered after retries.
The typed `track(eventType, params)` signature is the React Native API. On **Swift and Kotlin**, `track(envelopeJson:)` takes a pre-built camelCase JSON envelope string using the same field names (`eventType` and `occurredAt` required). The SDK stamps the wire `version` and a stable `eventId` for you.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Minimal — event type only:
await Rovenue.track('lead');
// With params:
await Rovenue.track('purchase', {
amount: '9.99',
currency: 'USD',
productId: 'com.example.pro_monthly',
});
// With identity context for ad-platform matching:
await Rovenue.track('registration', {
identityContext: {
email: 'user@example.com',
phone: '+15551234567',
},
});
```
```swift
import Foundation
// Swift takes a pre-built camelCase JSON envelope string.
func envelope(_ fields: [String: String]) -> String {
let data = try! JSONSerialization.data(withJSONObject: fields)
return String(data: data, encoding: .utf8)!
}
let now = ISO8601DateFormatter().string(from: Date())
// Minimal:
try await Rovenue.shared.track(envelopeJson: envelope([
"eventType": "lead", "occurredAt": now,
]))
// With params:
try await Rovenue.shared.track(envelopeJson: envelope([
"eventType": "purchase",
"occurredAt": now,
"amount": "9.99",
"currency": "USD",
"productId": "com.example.pro_monthly",
]))
```
```kotlin
import org.json.JSONObject
import java.time.Instant
// Kotlin takes a pre-built camelCase JSON envelope string.
// track() is a suspend fun — call it from a coroutine.
val now = Instant.now().toString()
// Minimal:
Rovenue.shared.track(
JSONObject(mapOf("eventType" to "lead", "occurredAt" to now)).toString()
)
// With params:
Rovenue.shared.track(
JSONObject(mapOf(
"eventType" to "purchase",
"occurredAt" to now,
"amount" to "9.99",
"currency" to "USD",
"productId" to "com.example.pro_monthly",
)).toString()
)
```
`track()` is a client-side convenience wrapper around `POST /v1/events`. For server-side event ingestion (e.g. from your backend after a payment processor webhook), call the HTTP endpoint directly with your secret API key.
***
## Purchases [#purchases]
### Method summary [#method-summary-3]
| Method | Signature (RN) | Returns | Throws |
| ------------------ | -------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getOfferings` | `getOfferings()` | [`Offerings`](/docs/reference/types#offerings) | [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table) |
| `purchase` | `purchase(pkg)` | [`PurchaseResult`](/docs/reference/types#purchaseresult) | [`PurchaseCanceled`](/docs/reference/errors#error-table), [`ProductNotAvailable`](/docs/reference/errors#error-table), [`StoreProblem`](/docs/reference/errors#error-table), [`ReceiptInvalid`](/docs/reference/errors#error-table) |
| `restorePurchases` | `restorePurchases()` | `void` | [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table) |
***
### `getOfferings` [#getofferings]
Fetch the offerings configured in the Rovenue dashboard. Each offering contains one or more packages, each wrapping a store product with localised pricing. Call this when your paywall opens.
**Params** — none
**Returns**
```ts
type Offerings = {
current: Offering | null; // the default offering, or null if none is set
all: Record; // all offerings keyed by identifier
};
```
→ Full type reference: [`Offerings`](/docs/reference/types#offerings), [`Offering`](/docs/reference/types#offering), [`Package`](/docs/reference/types#package), [`StoreProduct`](/docs/reference/types#storeproduct)
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
// offerings.current — default Offering | null
// offerings.all — Record
const packages = offerings.current?.packages ?? [];
packages.forEach(pkg => {
console.log(pkg.product.displayName, pkg.product.priceString);
});
```
```swift
let offerings = try await Rovenue.shared.getOfferings()
// offerings.current — Offering?
// offerings.all — [String: Offering]
let packages = offerings.current?.packages ?? []
for pkg in packages {
print(pkg.product.displayName, pkg.product.priceString)
}
```
```kotlin
val offerings = Rovenue.shared.getOfferings()
// offerings.current — Offering?
// offerings.all — Map
val packages = offerings.current?.packages ?: emptyList()
packages.forEach { pkg ->
Log.d("App", "${pkg.product.displayName} ${pkg.product.priceString}")
}
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
***
### `purchase` [#purchase]
Trigger a native store sheet for the given package. The SDK handles the full purchase lifecycle: StoreKit 2 on iOS, Play Billing on Android, server-side receipt validation. Resolves with updated entitlements and credit balance — no additional polling needed.
**Params**
| Parameter | Type | Required | Description |
| --------- | ------------------------------------------ | -------- | ------------------------------------------------- |
| `pkg` | [`Package`](/docs/reference/types#package) | Yes | A package from [`getOfferings()`](#getofferings). |
> **Android:** Kotlin requires the current `Activity` as the first argument — `purchase(activity, pkg)`. iOS (Swift) and React Native take only the package.
**Returns**
```ts
type PurchaseResult = {
entitlements: Entitlement[]; // full set of active entitlements after the purchase
virtualCurrencies: Record; // virtual-currency balances after the purchase
productId: string; // the store product ID purchased
storeTransactionId: string; // StoreKit 2 transaction ID or Play Billing order ID
isDeferred: boolean; // true for Ask-to-Buy / parental-control holds — see below
};
```
→ Full type reference: [`PurchaseResult`](/docs/reference/types#purchaseresult)
**Ask-to-Buy / parental-control holds are not an error.** `purchase()` does not throw
for a pending approval — it resolves normally with `isDeferred: true` (and empty
`entitlements` / `storeTransactionId`, since nothing has been granted yet). See
[Handling a deferred purchase](#handling-a-deferred-purchase) below. Do not write a
catch branch for this condition; there is no `kind` for it.
**Throws** — see table below
| Error | When it fires |
| ----------------------------------------------------------- | ------------------------------------------------------------ |
| [`PurchaseCanceled`](/docs/reference/errors#error-table) | User dismissed the store sheet without completing payment |
| [`ProductNotAvailable`](/docs/reference/errors#error-table) | The product is not available in the user's storefront |
| [`StoreProblem`](/docs/reference/errors#error-table) | The store returned a non-specific error (e.g. billing issue) |
| [`ReceiptInvalid`](/docs/reference/errors#error-table) | Server-side receipt validation failed |
```ts
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
if (pkg) {
try {
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) {
// Ask-to-Buy — tell the user to wait for approval (see below)
} else {
console.log('Purchase succeeded:', result.productId);
// result.entitlements and result.virtualCurrencies are already up to date
}
} catch (e) {
if (!(e instanceof RovenueError)) throw e;
switch (e.kind) {
case 'PurchaseCanceled':
// User dismissed — no action needed
break;
case 'ReceiptInvalid':
// Show a retry prompt
break;
default:
throw e;
}
}
}
```
```swift
let offerings = try await Rovenue.shared.getOfferings()
if let pkg = offerings.current?.packages.first {
do {
let result = try await Rovenue.shared.purchase(pkg)
if result.isDeferred {
// Ask-to-Buy — tell the user to wait for approval (see below)
} else {
print("Purchase succeeded: \(result.productId)")
}
} catch let e as RovenueError {
switch e.kind {
case .purchaseCanceled:
break // User dismissed — no action needed
case .receiptInvalid:
break // Show a retry prompt
default:
throw e
}
}
}
```
```kotlin
val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.firstOrNull()
if (pkg != null) {
try {
// `activity` is your current Activity — Play Billing requires it.
val result = Rovenue.shared.purchase(activity, pkg)
if (result.isDeferred) {
// Ask-to-Buy — tell the user to wait for approval (see below)
} else {
Log.d("App", "Purchase succeeded: ${result.productId}")
}
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.PURCHASE_CANCELED -> { /* User dismissed — no action needed */ }
ErrorKind.RECEIPT_INVALID -> { /* Show a retry prompt */ }
else -> throw e
}
}
}
```
**Errors:** [`PurchaseCanceled`](/docs/reference/errors#error-table) · [`ProductNotAvailable`](/docs/reference/errors#error-table) · [`StoreProblem`](/docs/reference/errors#error-table) · [`ReceiptInvalid`](/docs/reference/errors#error-table)
#### Handling a deferred purchase [#handling-a-deferred-purchase]
There is no purchase-specific notification for when a deferred (Ask-to-Buy) purchase
resolves — the SDK reconciles it in the background and reports the result the same way
as any other entitlement change. Listen for `ChangeEvent`'s entitlements-changed value
on each platform's [change stream](#change-stream): RN
`Rovenue.addChangeListener((event) => { if (event === 'ENTITLEMENTS_CHANGED') { /* re-check entitlements */ } })`;
Swift/Kotlin — collect `Rovenue.shared.changes` and match `.entitlementsChanged` /
`ChangeEvent.ENTITLEMENTS_CHANGED`.
***
### `restorePurchases` [#restorepurchases]
Re-validate the user's existing store receipts against the Rovenue server and update the entitlement cache. Required by App Store guidelines to offer a "Restore purchases" button.
**Params** — none on iOS / React Native. **Android:** Kotlin requires the current `Activity` — `restorePurchases(activity)`.
**Returns** — `void`
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```ts
await Rovenue.restorePurchases();
// Entitlements are refreshed; check entitlement() afterwards
```
```swift
try await Rovenue.shared.restorePurchases()
```
```kotlin
Rovenue.shared.restorePurchases(activity) // suspend fun; needs the current Activity
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
**See also:** [Processing purchases guide](/docs/guides/processing-purchases)
***
## Funnels [#funnels]
Resolve a placement into the funnel to show. On React Native and Flutter
there is deliberately no `getFunnel` — the native side resolves the
placement itself and the widget only hosts the native view.
### Method summary [#method-summary-4]
| Method | Signature (RN) | Returns | Throws |
| ----------------------- | ----------------------------- | ----------------- | ------------------------------------------------------- |
| `setFallbackPlacements` | `setFallbackPlacements(json)` | `Promise` | [`InvalidArgument`](/docs/reference/errors#error-table) |
On React Native `setFallbackPlacements` lives on the `Rovenue` object — it is **not** a named export of `@rovenue/react-native-sdk`. Swift uses `setFallbackPlacements(json:)` / `setFallbackPlacements(fileURL:)`. Kotlin uses `setFallbackPlacements(json)` / `setFallbackPlacements(context, assetPath)`.
Rendering is native: host the resolved funnel in `RovenueFunnelView` — see [iOS](/docs/platforms/ios-swift), [Android](/docs/platforms/android-kotlin), [React Native](/docs/platforms/react-native) and [Flutter](/docs/platforms/flutter).
***
### `getFunnel` [#getfunnel]
Resolve a placement identifier into the funnel the current subscriber should
see — either a direct assignment or the winning variant of a client-drawn
experiment over funnels. On Swift and Kotlin the session is already open
when it returns; hand it to a `FunnelDriver` and host `RovenueFunnelView`.
**Params**
| Parameter | Type | Required | Description |
| ------------- | -------- | -------- | --------------------------------------------------------------------- |
| `placementId` | `string` | Yes | The placement identifier configured in the dashboard. |
| `locale` | `string` | No | BCP-47 tag naming which localization of the funnel document to serve. |
**Returns** — the resolved funnel, or `null` when the placement resolved to
nothing (retired, `target: none`, or an unknown identifier) — that is **not**
an error, and an unknown placement never produces a 404.
**Resolution order** — network first; on a connectivity failure only (`NetworkUnavailable` / `Timeout`) the last cached response for that placement is re-resolved, and if there is no cached response either, the bundled fallback file (see [`setFallbackPlacements`](#setfallbackplacements)) is consulted. Auth and server errors are *not* absorbed this way: they propagate. Cache always beats fallback.
**Side effects** — two, both worth knowing:
1. When the placement resolves through an experiment, the variant draw happens **client-side** and deterministically off the subscriber's `rovenueId`. There is no exposure beacon: merely resolving a funnel records nothing server-side.
2. The resolved funnel's attribution snapshot is stamped into SDK state, so the next receipt POST carries it as `presentedContext`. Purchase attribution follows the most recent `getFunnel` call.
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`Timeout`](/docs/reference/errors#error-table) (both only when neither cache nor fallback could answer), [`RateLimited`](/docs/reference/errors#error-table), [`InvalidApiKey`](/docs/reference/errors#error-table), [`Forbidden`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```swift
if let funnel = try await Rovenue.shared.getFunnel(placementId: "onboarding_end", locale: "en") {
showMyFunnel(funnel)
}
```
```kotlin
val funnel = Rovenue.shared.getFunnel(placementId = "onboarding_end", locale = "en")
if (funnel != null) {
showMyFunnel(funnel)
}
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`Timeout`](/docs/reference/errors#error-table) · [`InvalidApiKey`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
**See also:** [Placements guide](/docs/guides/placements)
***
### `setFallbackPlacements` [#setfallbackplacements]
Load a bundled fallback-placements export so placements keep resolving when
the network **and** the disk cache both miss. Download the file from **Dashboard → SDK → Offline fallback** and ship it inside your app bundle.
The export covers **every active placement**. One file, one call. This matters most for funnels: a first-run onboarding that cannot open on a plane is the whole first impression.
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ---------------------------------------------------- |
| `json` | `string` | Yes | The contents of the fallback-placements export file. |
**Returns** — `Promise` — the count of placement entries actually loaded. An individual entry that fails to decode is skipped, not fatal, so this can legitimately be lower than the number of entries in the file.
**Ordering** — call it once, at startup, before your first funnel resolution. Each call **replaces** the previously loaded set rather than merging into it.
**Throws** — [`InvalidArgument`](/docs/reference/errors#error-table) when the file does not parse at all, or its `formatVersion` is not literally `1`. This is deliberate and loud: bundling a stale or mismatched export must fail at call time rather than degrade silently at the next outage.
```ts
const count = await Rovenue.setFallbackPlacements(jsonString);
```
```swift
// Synchronous and throwing on Swift, not async.
let count = try Rovenue.shared.setFallbackPlacements(fileURL: url)
```
```kotlin
// Not a suspend fun on Kotlin.
val count = Rovenue.shared.setFallbackPlacements(context, "rovenue-fallback.json")
```
**Errors:** [`InvalidArgument`](/docs/reference/errors#error-table)
**See also:** [Placements guide](/docs/guides/placements)
***
## Remote Config [#remote-config]
Feature flags and experiment assignments, evaluated server-side for the current subscriber and cached locally.
### Method summary [#method-summary-5]
| Method | Signature (RN) | Returns | Throws |
| --------------------- | ------------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `refreshRemoteConfig` | `refreshRemoteConfig()` | `Promise` | [`NetworkUnavailable`](/docs/reference/errors#error-table), [`Timeout`](/docs/reference/errors#error-table), [`RateLimited`](/docs/reference/errors#error-table), [`InvalidApiKey`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table) |
| `getRemoteConfig` | `getRemoteConfig()` | `Promise` | — |
| `getFlag` | `getFlag(key, fallback)` | `Promise` | — |
| `getExperiment` | `getExperiment(key)` | `Promise` | — |
| `getExperiments` | `getExperiments()` | `Promise` | — |
**Cache vs. network:** only `refreshRemoteConfig()` performs a network round-trip. The four read methods resolve from the local cache — they are `async` (the value crosses the native bridge) but they never fetch, so they work offline and never throw on a malformed payload; each degrades to its documented fallback instead.
React Native also exposes reactive hooks: `useRemoteConfig()`, `useFlag(key, fallback)` and `useExperiment(key)`. They read the same local cache synchronously, prime it on first mount, and re-render on `REMOTE_CONFIG_CHANGED`.
Swift and Kotlin do not have a single `getFlag` — they expose typed getters instead: `remoteConfigBool`, `remoteConfigString`, `remoteConfigInt`, `remoteConfigDouble` and `remoteConfigJson` (Swift: `remoteConfigJSON`), plus `remoteConfigKeys()`. Nor do they have a struct-returning `getRemoteConfig`: `remoteConfigAllJson()` (Swift: `remoteConfigAllJSON()`) returns the whole bundle as a raw JSON string.
On React Native, structured error detail travels inside the native error `message` as a JSON envelope (the Expo JSI bridge forwards only `code` and `message`); the SDK unpacks it for you into `RovenueError.serverCode`, `.httpStatus`, `.isRetryable` and `.data.retryAfter`, so a `RateLimited` refresh still tells you how long to wait.
***
### `refreshRemoteConfig` [#refreshremoteconfig]
Force an immediate fetch of the latest Remote Config from the server.
**Params** — none
**Returns** — `Promise`
**Side effect** — fires a [`ChangeEvent`](/docs/reference/types#changeevent) (RN: `'REMOTE_CONFIG_CHANGED'`) on every successful refresh. Never call `refreshRemoteConfig()` from inside that listener: the refresh re-emits the event, which re-invokes the listener, forever.
On a connectivity failure the SDK hydrates the on-disk payload into memory *before* rethrowing, so reads keep serving the last-known config even though this call rejected.
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`Timeout`](/docs/reference/errors#error-table), [`RateLimited`](/docs/reference/errors#error-table), [`InvalidApiKey`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```ts
await Rovenue.refreshRemoteConfig();
```
```swift
try await Rovenue.shared.refreshRemoteConfig()
```
```kotlin
Rovenue.shared.refreshRemoteConfig() // suspend fun
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`Timeout`](/docs/reference/errors#error-table) · [`RateLimited`](/docs/reference/errors#error-table) · [`InvalidApiKey`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
**See also:** [Experiments guide](/docs/guides/experiments)
***
### `getRemoteConfig` [#getremoteconfig]
Read the whole cached bundle — flags and experiment assignments together.
**Params** — none
**Returns** — `Promise`, shaped `{ flags, experiments }`:
| Field | Type | Description |
| ------------- | -------------------------------------- | ---------------------------------------------------------------------- |
| `flags` | `Record` | Flag key → evaluated value (boolean, number, string, object or array). |
| `experiments` | `Record` | Experiment key → this subscriber's assignment. |
A malformed or empty cached payload resolves to the empty bundle (`flags: {}`, `experiments: {}`) rather than throwing.
**Throws** — nothing
```ts
const config = await Rovenue.getRemoteConfig();
console.log(Object.keys(config.flags));
```
```swift
// Swift returns the bundle as a raw JSON string — decode it yourself.
let json = await Rovenue.shared.remoteConfigAllJSON()
```
```kotlin
// Kotlin returns the bundle as a raw JSON string — decode it yourself.
val json = Rovenue.shared.remoteConfigAllJson()
```
**See also:** [Experiments guide](/docs/guides/experiments)
***
### `getFlag` [#getflag]
Read one flag with a typed fallback.
**Params**
| Parameter | Type | Required | Description |
| ---------- | -------- | -------- | --------------------------------------------------------------------------------------- |
| `key` | `string` | Yes | The flag key as configured in the dashboard. |
| `fallback` | `T` | Yes | Value returned when the flag is absent, or when its stored value cannot be read as `T`. |
**Returns** — `Promise`
**How the type is chosen:** the **runtime type of `fallback`** selects the read — a `boolean` fallback reads the flag as a boolean, a `number` fallback as a double, a `string` fallback as a string. Any other fallback (object, array, `null`) falls back to a JSON read, so object and array flags resolve too; if that JSON does not parse, you get your fallback back. There is no separate integer path on React Native — numbers always go through the double getter.
**Throws** — nothing
```ts
const showBanner = await Rovenue.getFlag('show_banner', false);
const maxItems = await Rovenue.getFlag('max_items', 10);
const theme = await Rovenue.getFlag<{ accent: string }>('theme', { accent: '#000' });
```
```swift
// Typed getters, one per type — there is no generic getFlag on Swift.
let showBanner = await Rovenue.shared.remoteConfigBool("show_banner", default: false)
let maxItems = await Rovenue.shared.remoteConfigInt("max_items", default: 10)
```
```kotlin
// Typed getters, one per type — there is no generic getFlag on Kotlin.
val showBanner = Rovenue.shared.remoteConfigBool("show_banner", fallback = false)
val maxItems = Rovenue.shared.remoteConfigInt("max_items", fallback = 10L)
```
**See also:** [Experiments guide](/docs/guides/experiments)
***
### `getExperiment` [#getexperiment]
Read the current subscriber's assignment for a single experiment.
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | -------------------------------------------------- |
| `key` | `string` | Yes | The experiment key as configured in the dashboard. |
**Returns** — `Promise`. `null` when this subscriber has no assignment for that key.
| Field | Type | Description |
| -------------- | --------- | ----------------------------------------------------------------------------------- |
| `experimentId` | `string` | The experiment's ID. |
| `key` | `string` | The experiment key you looked up. |
| `variantId` | `string` | The assigned variant's ID. |
| `variantName` | `string` | The assigned variant's display name. |
| `value` | `unknown` | The variant payload, already JSON-parsed. `null` when the payload cannot be parsed. |
Reading an assignment does **not** record an exposure — resolving a funnel records nothing server-side either.
**Throws** — nothing
```ts
const assignment = await Rovenue.getExperiment('checkout_copy');
if (assignment) console.log(assignment.variantName);
```
```swift
if let assignment = await Rovenue.shared.experiment("checkout_copy") {
print(assignment.variantName)
}
```
```kotlin
val assignment = Rovenue.shared.experiment("checkout_copy")
if (assignment != null) Log.d("App", assignment.variantName)
```
**See also:** [Experiments guide](/docs/guides/experiments)
***
### `getExperiments` [#getexperiments]
All of the current subscriber's experiment assignments.
**Params** — none
**Returns** — `Promise` (empty array when there are none, or when the cache has not been primed yet)
**Throws** — nothing
```ts
const assignments = await Rovenue.getExperiments();
```
```swift
let assignments = await Rovenue.shared.experimentsAll()
```
```kotlin
val assignments = Rovenue.shared.experimentsAll()
```
**See also:** [Experiments guide](/docs/guides/experiments)
***
## Subscriber Attributes [#subscriber-attributes]
Attach arbitrary key/value metadata to the current subscriber — plus a set of reserved, `$`-prefixed keys that map to first-class fields server-side and drive the integrations.
### Method summary [#method-summary-6]
| Method | Signature (RN) | Returns | Throws |
| ----------------- | --------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setAttributes` | `setAttributes(attributes)` | `Promise` | [`Storage`](/docs/reference/errors#error-table) |
| `setEmail` | `setEmail(email)` | `Promise` | [`Storage`](/docs/reference/errors#error-table) |
| `setDisplayName` | `setDisplayName(name)` | `Promise` | [`Storage`](/docs/reference/errors#error-table) |
| `setPhoneNumber` | `setPhoneNumber(phone)` | `Promise` | [`Storage`](/docs/reference/errors#error-table) |
| `setPushToken` | `setPushToken(token)` | `Promise` | [`Storage`](/docs/reference/errors#error-table) |
| `flushAttributes` | `flushAttributes()` | `Promise` | [`NetworkUnavailable`](/docs/reference/errors#error-table), [`Timeout`](/docs/reference/errors#error-table), [`RateLimited`](/docs/reference/errors#error-table), [`InvalidApiKey`](/docs/reference/errors#error-table), [`Forbidden`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table) |
**Write model — read this before using any of them.** Every setter is a **buffered local write**. The returned promise resolves once the mutation has been written to the on-device buffer, *not* once the server has it. A background dispatcher syncs the buffer every 30 seconds, and again when the app comes to the foreground; [`flushAttributes`](#flushattributes) forces it early. So a resolved `setEmail()` means "durably queued", never "the server knows".
`shutdown()` does not flush the buffer. If you tear the SDK down and it matters that pending mutations land, call `flushAttributes()` first.
Passing `null` as a value **deletes** the key rather than storing an empty value. The four named setters are thin wrappers over `setAttributes` for the most common reserved keys.
***
### `setAttributes` [#setattributes]
Queue a batch of subscriber-attribute mutations.
**Params**
| Parameter | Type | Required | Description |
| ------------ | -------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `attributes` | `Record` | Yes | Key → value. A `null` value deletes that key. Keys prefixed with `$` are reserved and map to first-class server-side fields. |
**Returns** — `Promise` (resolves on the local buffered write — see the write model above)
**Throws** — [`Storage`](/docs/reference/errors#error-table) if the local buffer cannot be written
```ts
await Rovenue.setAttributes({
plan_interest: 'annual',
beta_optin: null, // deletes the key
});
```
```swift
try await Rovenue.shared.setAttributes([
"plan_interest": "annual",
"beta_optin": nil, // deletes the key
])
```
```kotlin
Rovenue.shared.setAttributes(
mapOf(
"plan_interest" to "annual",
"beta_optin" to null, // deletes the key
),
)
```
**Errors:** [`Storage`](/docs/reference/errors#error-table)
**See also:** [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes)
***
### `setEmail` [#setemail]
Set the subscriber's email — the reserved `$email` attribute. Same buffered-write, background-flush contract as [`setAttributes`](#setattributes).
**Params**
| Parameter | Type | Required | Description |
| --------- | ---------------- | -------- | ------------------------------------ |
| `email` | `string \| null` | Yes | The email address; `null` clears it. |
**Returns** — `Promise`
**Throws** — [`Storage`](/docs/reference/errors#error-table)
```ts
await Rovenue.setEmail('ada@example.com');
```
```swift
try await Rovenue.shared.setEmail("ada@example.com")
```
```kotlin
Rovenue.shared.setEmail("ada@example.com")
```
**See also:** [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes)
***
### `setDisplayName` [#setdisplayname]
Set the subscriber's display name — the reserved `$displayName` attribute. Same contract as [`setEmail`](#setemail).
**Params**
| Parameter | Type | Required | Description |
| --------- | ---------------- | -------- | ----------------------------------- |
| `name` | `string \| null` | Yes | The display name; `null` clears it. |
**Returns** — `Promise`
**Throws** — [`Storage`](/docs/reference/errors#error-table)
```ts
await Rovenue.setDisplayName('Ada Lovelace');
```
```swift
try await Rovenue.shared.setDisplayName("Ada Lovelace")
```
```kotlin
Rovenue.shared.setDisplayName("Ada Lovelace")
```
**See also:** [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes)
***
### `setPhoneNumber` [#setphonenumber]
Set the subscriber's phone number — the reserved `$phoneNumber` attribute. Same contract as [`setEmail`](#setemail).
**Params**
| Parameter | Type | Required | Description |
| --------- | ---------------- | -------- | ----------------------------------- |
| `phone` | `string \| null` | Yes | The phone number; `null` clears it. |
**Returns** — `Promise`
**Throws** — [`Storage`](/docs/reference/errors#error-table)
```ts
await Rovenue.setPhoneNumber('+15551234567');
```
```swift
try await Rovenue.shared.setPhoneNumber("+15551234567")
```
```kotlin
Rovenue.shared.setPhoneNumber("+15551234567")
```
**See also:** [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes)
***
### `setPushToken` [#setpushtoken]
Store the device's push token. The reserved key is **platform-specific** — `$apnsTokens` on iOS, `$fcmTokens` on Android — and the SDK picks the right one for the device it is running on, so you pass the token and nothing else. Same contract as [`setEmail`](#setemail).
**Params**
| Parameter | Type | Required | Description |
| --------- | ---------------- | -------- | ---------------------------------------- |
| `token` | `string \| null` | Yes | The APNs or FCM token; `null` clears it. |
**Returns** — `Promise`
**Throws** — [`Storage`](/docs/reference/errors#error-table)
```ts
await Rovenue.setPushToken(deviceToken);
```
```swift
try await Rovenue.shared.setPushToken(deviceToken) // → $apnsTokens
```
```kotlin
Rovenue.shared.setPushToken(deviceToken) // → $fcmTokens
```
**See also:** [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes)
***
### `flushAttributes` [#flushattributes]
Force an immediate flush of buffered attribute mutations to the server, instead of waiting for the next background tick.
**Params** — none
**Returns** — `Promise` — the number of mutations actually sent
**Ordering** — most apps never need this; the background dispatcher already flushes on its own schedule and on foreground. Reach for it when you need the server to have the attributes *now* — for example immediately before `shutdown()`, which does not flush on your behalf.
**Throws** — [`NetworkUnavailable`](/docs/reference/errors#error-table), [`Timeout`](/docs/reference/errors#error-table), [`InvalidApiKey`](/docs/reference/errors#error-table), [`Forbidden`](/docs/reference/errors#error-table), [`ServerError`](/docs/reference/errors#error-table)
```ts
const sent = await Rovenue.flushAttributes();
```
```swift
let sent = try await Rovenue.shared.flushAttributes()
```
```kotlin
val sent = Rovenue.shared.flushAttributes()
```
**Errors:** [`NetworkUnavailable`](/docs/reference/errors#error-table) · [`Timeout`](/docs/reference/errors#error-table) · [`ServerError`](/docs/reference/errors#error-table)
**See also:** [Subscriber Identity Attributes](/docs/integrations/subscriber-identity-attributes)
***
## Account Token [#account-token]
### `getAppAccountToken` [#getappaccounttoken]
Return a stable UUID tied to the current `rovenueId`. Pass it to StoreKit's `.appAccountToken(uuid)` or Play Billing's `setObfuscatedAccountId()` before initiating a purchase so the store can attribute the transaction to the correct Rovenue subscriber. The token changes when `identify()` is called with a new user.
**Params** — none
**Returns** — `string` (UUID v4)
**Throws** — [`Storage`](/docs/reference/errors#error-table) if MMKV is unavailable.
```ts
const token = await Rovenue.getAppAccountToken();
// Pass to your custom StoreKit integration if needed.
// Most apps don't need to call this directly — purchase() handles it.
```
```swift
let token = try await Rovenue.shared.getAppAccountToken()
// Pass as .appAccountToken(UUID(uuidString: token)!) in a custom SKProduct request
```
```kotlin
val token = Rovenue.shared.getAppAccountToken()
// Pass as setObfuscatedAccountId(token) in a custom BillingFlowParams builder
```
**Errors:** [`Storage`](/docs/reference/errors#error-table)
***
## Lifecycle [#lifecycle]
### Method summary [#method-summary-7]
| Method | Signature (RN) | Returns | Throws |
| --------------- | ----------------------------- | ------- | ------ |
| `setForeground` | `setForeground(isForeground)` | `void` | — |
| `shutdown` | `shutdown()` | `void` | — |
Wire these into your platform's app lifecycle hooks so the SDK can pause background work when the app is in the background and trigger a refresh when it returns to the foreground.
***
### `setForeground` [#setforeground]
Notify the SDK of foreground/background transitions. When `true`, the SDK schedules a background entitlement sync to catch any server-side changes that occurred while the app was inactive.
**Params**
| Parameter | Type | Required | Description |
| -------------- | --------- | -------- | ---------------------------------------------------------------------------------- |
| `isForeground` | `boolean` | Yes | `true` when the app enters the foreground; `false` when it goes to the background. |
**Returns** — `void`
**Throws** — nothing
```ts
import { AppState } from 'react-native';
import { Rovenue } from '@rovenue/react-native-sdk';
AppState.addEventListener('change', (state) => {
Rovenue.setForeground(state === 'active');
});
```
```swift
// In SceneDelegate or SwiftUI .scenePhase observer:
.onChange(of: scenePhase) { phase in
Rovenue.shared.setForeground(phase == .active)
}
```
```kotlin
// In DefaultLifecycleObserver:
override fun onStart(owner: LifecycleOwner) {
Rovenue.shared.setForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
Rovenue.shared.setForeground(false)
}
```
***
### `shutdown` [#shutdown]
Flush pending work and cleanly release SDK resources. Call when the process is about to exit or on explicit user logout if you are not reusing the process.
**Params** — none
**Returns** — `void`
**Throws** — nothing
```ts
// On app teardown:
await Rovenue.shutdown();
```
```swift
await Rovenue.shared.shutdown()
```
```kotlin
Rovenue.shared.shutdown() // suspend fun
```
**See also:** [App lifecycle guide](/docs/guides/app-lifecycle)
***
## Logging [#logging]
### `setLogHandler` [#setloghandler]
Install a custom log handler to receive structured SDK log entries. Useful for routing SDK logs into your own logging infrastructure. On React Native, returns an unsubscribe function.
**Params**
| Parameter | Type | Required | Description |
| --------- | --------------------------- | -------- | ---------------------------------------- |
| `handler` | `(entry: LogEntry) => void` | Yes | Callback invoked for each SDK log entry. |
**Returns** — `() => void` on React Native (call to stop receiving logs); `void` on Swift and Kotlin (set to `nil` / `null` to detach).
**Throws** — nothing
The [`LogEntry`](/docs/reference/types#logentry) shape:
```ts
type LogEntry = {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
timestamp: string; // ISO 8601
};
```
→ Full type reference: [`LogEntry`](/docs/reference/types#logentry)
```ts
const unsub = Rovenue.setLogHandler((entry) => {
console.log(`[Rovenue ${entry.level}] ${entry.message}`);
});
// Later, to stop receiving logs:
unsub();
```
```swift
Rovenue.shared.setLogHandler { entry in
print("[Rovenue \(entry.level)] \(entry.message)")
}
// To detach:
Rovenue.shared.setLogHandler(nil)
```
```kotlin
Rovenue.shared.setLogHandler { entry ->
Log.d("Rovenue", "[${entry.level}] ${entry.message}")
}
// To detach:
Rovenue.shared.setLogHandler(null)
```
***
## Change Stream [#change-stream]
Subscribe to real-time state change notifications from the SDK. The SDK fires an event whenever the entitlement cache, identity, virtual-currency balances, or remote config are updated. React to these events to keep your UI in sync without polling.
| Method / Property | Platform | Description |
| ---------------------------- | ------------ | ----------------------------------------------------------------------- |
| `addChangeListener(handler)` | React Native | Register a callback; returns an unsubscribe function. |
| `changes` | Swift | `AsyncStream` — iterate with `for await`. |
| `changes` | Kotlin | `SharedFlow` — collect with `.onEach { }.launchIn(scope)`. |
The [`ChangeEvent`](/docs/reference/types#changeevent) values (shown as the React Native
string value — see [`ChangeEvent`](/docs/reference/types#changeevent) for the exact
Swift case / Kotlin constant spelling of each):
| Value (RN) | When it fires |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `'ENTITLEMENTS_CHANGED'` | After any entitlement cache refresh (purchase, `refreshEntitlements()`, foreground sync). |
| `'IDENTITY_CHANGED'` | After `identify()` succeeds or `logOut()` resets the user. |
| `'VIRTUAL_CURRENCIES_CHANGED'` | After `refreshVirtualCurrencies()` or a server-side grant/spend that updates the cache. |
| `'REMOTE_CONFIG_CHANGED'` | After every successful [`refreshRemoteConfig()`](#refreshremoteconfig). The SDK does not diff the payload, so this also fires when the refresh returned identical values. |
→ Full type reference: [`ChangeEvent`](/docs/reference/types#changeevent)
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const unsub = Rovenue.addChangeListener((event) => {
switch (event) {
case 'ENTITLEMENTS_CHANGED':
// Re-read entitlements and update gated UI
break;
case 'IDENTITY_CHANGED':
// User identity has changed — re-read currentUser()
break;
case 'VIRTUAL_CURRENCIES_CHANGED':
// Re-read virtualCurrencies() and update the balance display
break;
case 'REMOTE_CONFIG_CHANGED':
// Re-read your flags/experiments
break;
}
});
// When your component unmounts:
unsub();
```
```swift
// Typically in a Task {} inside your SwiftUI view or view model:
for await event in Rovenue.shared.changes {
switch event {
case .entitlementsChanged:
// Re-read entitlements and update gated UI
case .identityChanged:
// User identity has changed — re-read currentUser()
case .virtualCurrenciesChanged:
// Re-read virtualCurrencyBalances() and update the balance display
case .remoteConfigChanged:
// Re-read your flags/experiments
}
}
```
```kotlin
// In a ViewModel or LifecycleCoroutineScope:
Rovenue.shared.changes
.onEach { event ->
when (event) {
ChangeEvent.ENTITLEMENTS_CHANGED -> { /* re-read entitlements */ }
ChangeEvent.IDENTITY_CHANGED -> { /* user identity changed */ }
ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> { /* re-read balances */ }
ChangeEvent.REMOTE_CONFIG_CHANGED -> { /* re-read flags/experiments */ }
}
}
.launchIn(viewModelScope)
```
**See also:** [Reacting to changes guide](/docs/guides/reacting-to-changes)
***
## Funnel Attribution [#funnel-attribution]
### Method summary [#method-summary-8]
| Method | Signature (RN) | Returns | Throws |
| ------------------------ | ---------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `claimFunnelToken` | `claimFunnelToken(token)` | [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) | [`FunnelTokenNotFound`](/docs/reference/errors#error-table), [`FunnelTokenExpired`](/docs/reference/errors#error-table), [`FunnelTokenAlreadyClaimed`](/docs/reference/errors#error-table) |
| `claimInstall` | `claimInstall(params)` | [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `\| null` | — |
| `resolveFunnelClaim` | `resolveFunnelClaim(opts?)` | [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `\| null` | — |
| `claimFromClipboard` | `claimFromClipboard()` | [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `\| null` | — |
| `claimFromUrl` | `claimFromUrl(url)` | [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `\| null` | [`FunnelTokenNotFound`](/docs/reference/errors#error-table), [`FunnelTokenExpired`](/docs/reference/errors#error-table), [`FunnelTokenAlreadyClaimed`](/docs/reference/errors#error-table) |
| `claimViaEmail` | `claimViaEmail(email)` | `void` | — |
| `installId` | `installId()` | `string` | — |
| `addFunnelClaimListener` | `addFunnelClaimListener(cb)` | `() => void` | — |
***
### `claimFunnelToken` [#claimfunneltoken]
Exchange a funnel token (obtained from a deep link or QR code) for the subscriber's funnel data. This is the primary attribution path for token-based funnels.
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------------------------------ |
| `token` | `string` | Yes | The funnel token from the deep link (e.g. `rov_ft_…`). |
**Returns**
```ts
// React Native
type FunnelClaimResult = {
subscriberId: string; // Rovenue subscriber ID of the matched user
funnelAnswers: Record; // key-value map of funnel step answers, already parsed
};
```
**Swift and Kotlin return the funnel answers as a raw JSON string, not a parsed object.**
Their `FunnelClaimResult` is `{ subscriberId: String, funnelAnswersJson: String }` —
decode `funnelAnswersJson` yourself (e.g. `JSONSerialization` on Swift,
`org.json.JSONObject`/`kotlinx.serialization` on Kotlin) if you need the parsed shape.
Only the React Native binding pre-parses it into `funnelAnswers`.
→ Full type reference: [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult)
**Throws** — see table below
| Error | When it fires |
| ----------------------------------------------------------------- | ---------------------------------------------------- |
| [`FunnelTokenNotFound`](/docs/reference/errors#error-table) | The token does not match any known funnel session |
| [`FunnelTokenExpired`](/docs/reference/errors#error-table) | The token has passed its expiry window |
| [`FunnelTokenAlreadyClaimed`](/docs/reference/errors#error-table) | The token was already consumed by a previous install |
```ts
import { Rovenue, RovenueError } from '@rovenue/react-native-sdk';
// Called from your deep-link handler:
try {
const result = await Rovenue.claimFunnelToken(token);
console.log('Claimed subscriber:', result.subscriberId);
console.log('Funnel answers:', result.funnelAnswers);
} catch (e) {
if (!(e instanceof RovenueError)) throw e;
switch (e.kind) {
case 'FunnelTokenNotFound':
// Token unknown — may be an old or invalid link
break;
case 'FunnelTokenExpired':
// Prompt the user to re-visit the funnel
break;
case 'FunnelTokenAlreadyClaimed':
// Already claimed — proceed without re-claiming
break;
default:
throw e;
}
}
```
```swift
// Called from your deep-link handler (Scene(_:openURLContexts:) or onOpenURL):
do {
let result = try await Rovenue.shared.claimFunnelToken(token)
print("Claimed subscriber: \(result.subscriberId)")
print("Funnel answers (raw JSON): \(result.funnelAnswersJson)")
} catch let e as RovenueError {
switch e.kind {
case .funnelTokenNotFound:
break // Token unknown — may be an old or invalid link
case .funnelTokenExpired:
break // Prompt the user to re-visit the funnel
case .funnelTokenAlreadyClaimed:
break // Already claimed — proceed without re-claiming
default:
throw e
}
}
```
```kotlin
// Called from your deep-link handler (Activity.onNewIntent or NavController):
try {
val result = Rovenue.shared.claimFunnelToken(token) // suspend fun
Log.d("App", "Claimed subscriber: ${result.subscriberId}")
Log.d("App", "Funnel answers (raw JSON): ${result.funnelAnswersJson}")
} catch (e: RovenueException) {
when (e.kind) {
ErrorKind.FUNNEL_TOKEN_NOT_FOUND -> { /* Token unknown — may be an old or invalid link */ }
ErrorKind.FUNNEL_TOKEN_EXPIRED -> { /* Prompt the user to re-visit the funnel */ }
ErrorKind.FUNNEL_TOKEN_ALREADY_CLAIMED -> { /* Already claimed — proceed without re-claiming */ }
else -> throw e
}
}
```
***
### `claimInstall` [#claiminstall]
Recover a prior funnel session deterministically from caller-supplied install context (e.g. an Android Install Referrer token) and claim it. Returns a `FunnelClaimResult` if a prior funnel session is matched, or `null` if no match can be recovered. This is deterministic — it does **not** use device fingerprinting. Call this once on first launch, immediately after `configure`.
On **iOS**, when no explicit token is available, recovery falls back to a deterministic server-side **IP match within a 1-hour window** — no device fingerprinting is used. For clipboard-based recovery on iOS, see [`claimFromClipboard`](#claimfromclipboard).
**Params** — all optional on React Native; on native Swift and Kotlin every field must
be supplied (see the callout below).
On **React Native's Android bridge specifically**, the SDK auto-collects `platform`,
`locale`, `timezone`, `screenDims`, `deviceModel`, and the Google Play Install Referrer
from the native layer — pass any field only to override the auto-collected value. On
**React Native's iOS bridge**, callers still supply context explicitly (auto-collection
for iOS will land in a later release).
**Native Swift and Kotlin have no auto-collection at all.** The auto-collection above is
a convenience built into the React Native Android bridge specifically — the underlying
`packages/sdk-swift` and `packages/sdk-kotlin` façades take one `ClaimInstallParams`
struct with no default values, so a native caller must always compute and supply every
field itself (see the Swift/Kotlin tabs below for how to derive them).
| Parameter | Type | Required | Description |
| ----------------- | -------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `platform` | `"ios" \| "android"` | No (RN) / Yes (Swift, Kotlin) | Platform of the current install. Auto-collected on RN's Android bridge. |
| `locale` | `string` | No (RN) / Yes (Swift, Kotlin) | IETF BCP 47 locale tag (e.g. `"en-US"`). Auto-collected on RN's Android bridge. |
| `timezone` | `string` | No (RN) / Yes (Swift, Kotlin) | IANA timezone identifier (e.g. `"America/New_York"`). Auto-collected on RN's Android bridge. |
| `screenDims` | `string` | No (RN) / Yes (Swift, Kotlin) | Screen dimensions in pixels, formatted `"WIDTHxHEIGHT"` (e.g. `"1080x2400"`) — a plain string, not a width/height object. Auto-collected on RN's Android bridge. |
| `deviceModel` | `string \| null` | No | Device model string (e.g. `"iPhone16,1"`). Auto-collected on RN's Android bridge; pass `null` on native Swift/Kotlin if you have nothing to send. |
| `installReferrer` | `string \| null` | No | Android Install Referrer string from the Play Store; auto-collected on RN's Android bridge, `null` on iOS and on native Kotlin unless you read it yourself. |
**Returns** — [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `| null` (`null` means no prior funnel session was matched)
**Throws** — nothing
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Android: SDK auto-collects referrer + device context (platform, locale,
// timezone, screenDims, deviceModel). iOS: supply params explicitly.
const result = await Rovenue.claimInstall(); // Android: auto-collects referrer + context
if (result) {
console.log('Matched funnel session:', result.subscriberId);
} else {
console.log('No prior funnel session matched');
}
```
```swift
import UIKit
let screen = UIScreen.main.bounds
let scale = UIScreen.main.scale
let params = ClaimInstallParams(
platform: "ios",
locale: Locale.current.identifier,
timezone: TimeZone.current.identifier,
screenDims: "\(Int(screen.width * scale))x\(Int(screen.height * scale))",
deviceModel: UIDevice.current.model,
installReferrer: nil
)
let result = await Rovenue.shared.claimInstall(params)
if let result {
print("Matched funnel session: \(result.subscriberId)")
} else {
print("No prior funnel session matched")
}
```
```kotlin
import android.os.Build
import android.util.DisplayMetrics
import dev.rovenue.sdk.generated.ClaimInstallParams
import java.util.Locale
import java.util.TimeZone
val metrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(metrics)
val params = ClaimInstallParams(
platform = "android",
locale = Locale.getDefault().toLanguageTag(),
timezone = TimeZone.getDefault().id,
screenDims = "${metrics.widthPixels}x${metrics.heightPixels}",
deviceModel = Build.MODEL,
installReferrer = null, // or a Play Install Referrer string you've read yourself
)
val result = Rovenue.shared.claimInstall(params) // suspend fun
if (result != null) {
Log.d("App", "Matched funnel session: ${result.subscriberId}")
} else {
Log.d("App", "No prior funnel session matched")
}
```
***
### `resolveFunnelClaim` [#resolvefunnelclaim]
**React Native only.** Runs the no-gesture, deterministic first-launch attribution chain once per install and returns the result. The chain is:
1. **Direct deep link** — if a URL is supplied (or the app was cold-started from one), extract and claim the embedded funnel token via `claimFromUrl`.
2. **Platform deferred match** — if step 1 yields no result, call `claimInstall`, which on Android queries the Google Play Install Referrer and on iOS performs a server-side IP match within a 1-hour window. No device fingerprinting is used at any step.
Once a claim has succeeded for this install the method short-circuits immediately and returns the cached result without a network round-trip.
Internal claim errors (network failures, token not found, etc.) are treated as "unresolved" rather than thrown — the method never throws on its own. Resolved claims are also delivered to any listener registered with [`addFunnelClaimListener`](#addfunnelclaimlistener).
**Params** — all optional
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `url` | `string` | No | The URL the app was launched with (e.g. from `Linking.getInitialURL()`). Pass `undefined` to skip the deep-link step. |
**Returns** — [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `| null` (`null` when neither step resolves a claim)
**Throws** — nothing
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
import * as Linking from 'expo-linking';
// Recommended first-launch flow — call once after Rovenue.configure():
const url = await Linking.getInitialURL();
const result = await Rovenue.resolveFunnelClaim({ url: url ?? undefined });
if (result) {
// Claim resolved deterministically — apply funnel-specific onboarding.
console.log('Funnel claim resolved for subscriber:', result.subscriberId);
} else {
// No deterministic match. Try gesture-gated fallbacks in sequence:
// 1. iOS clipboard (show a "Continue" button, then on tap):
// const clipResult = await Rovenue.claimFromClipboard();
// 2. Email magic-link (show an email field, then on submit):
// await Rovenue.claimViaEmail(email);
// // Result arrives via addFunnelClaimListener when the link is tapped.
}
```
***
### `claimFromClipboard` [#claimfromclipboard]
**React Native only, and iOS only within that.** There is no `claimFromClipboard` on
the native Swift or Kotlin SDKs — `Rovenue.shared` has no equivalent method on either
platform (confirmed against `packages/sdk-swift/Sources/Rovenue/Rovenue.swift` and
`packages/sdk-kotlin/.../Rovenue.kt`; grepping both trees for `claimFromClipboard`
returns nothing outside the RN package). It exists purely as a convenience in
`@rovenue/react-native-sdk`, implemented on top of the standard claim flow: it reads a
`rovenue-funnel:` marker placed on the clipboard by the web funnel, extracts the
token, calls the same `claimFunnelToken` every platform already has, then clears the
marker from the clipboard so it can't be re-claimed or leaked. Returns a
`FunnelClaimResult` if the marker is present and matched, or `null` on Android or when
no marker is found.
**Call from a user-gesture handler** (e.g. a "Continue" tap on your first onboarding
screen) — iOS shows a one-time paste-access banner on the clipboard read.
**On native Swift or Kotlin, there is no built-in equivalent — replicate it yourself**
using [`claimFunnelToken`](#claimfunneltoken): read the clipboard string (`UIPasteboard.general.string`
on iOS; there's no Android equivalent, since the RN version is also a no-op there),
check it starts with `"rovenue-funnel:"`, strip the prefix to get the token, and call
`claimFunnelToken(token)`. Clear the clipboard afterward if you want the same
can't-be-reclaimed behavior the RN version has.
**Params** — none
**Returns** — [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) `| null` (`null` on Android, or when no `rovenue-funnel:` marker is present)
**Throws** — nothing
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// Call from a user-gesture handler (e.g. a "Continue" button press):
const result = await Rovenue.claimFromClipboard();
if (result) {
console.log('Clipboard funnel claim resolved:', result.subscriberId);
}
// Returns null on Android or when no rovenue-funnel: marker is present.
```
***
### `claimViaEmail` [#claimviaemail]
Initiate an email-based funnel claim. The SDK sends a magic-link email to the provided address; the claim resolves later when the user taps the link and the app handles the resulting deep link (see [`addFunnelClaimListener`](#addfunnelclaimlistener)).
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------------------------------------------------------ |
| `email` | `string` | Yes | The email address to send the claim link to. Must be a valid RFC 5322 address. |
**Returns** — `void` (resolves as soon as the magic-link request is dispatched; the actual claim fires via [`addFunnelClaimListener`](#addfunnelclaimlistener) when the deep link is opened)
**Throws** — [`InvalidArgument`](/docs/reference/errors#error-table) if the email address fails format validation.
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
// After the user enters their email in your onboarding flow:
await Rovenue.claimViaEmail('user@example.com');
// A magic-link email is now on its way.
// Listen for the resolved claim via addFunnelClaimListener().
```
```swift
// After the user enters their email in your onboarding flow:
try await Rovenue.shared.claimViaEmail("user@example.com")
// A magic-link email is now on its way.
// Listen for the resolved claim via the funnelClaims AsyncStream.
```
```kotlin
// After the user enters their email in your onboarding flow:
Rovenue.shared.claimViaEmail("user@example.com") // suspend fun
// A magic-link email is now on its way.
// Listen for the resolved claim via addFunnelClaimListener().
```
***
### `installId` [#installid]
Return the stable, persisted identifier for this install. The install ID is generated on first launch, survives app restarts, and is sent as the idempotency key for `claimInstall` / `claimViaEmail`. It does not change when `identify()` or `logOut()` is called.
**Params** — none
**Returns** — `string` (UUID v4 format, generated once per install and persisted in MMKV)
**Throws** — nothing
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const id = await Rovenue.installId();
console.log('Install ID:', id); // e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
```
```swift
let id = await Rovenue.shared.installId()
print("Install ID: \(id)") // e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
```
```kotlin
val id = Rovenue.shared.installId() // suspend fun
Log.d("App", "Install ID: $id") // e.g. "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
```
***
### `addFunnelClaimListener` [#addfunnelclaimlistener]
Register a callback that fires whenever a funnel claim resolves — whether triggered by a token deep link, a deterministic install match, or an email magic-link. On React Native, returns an unsubscribe function. On Swift, use the `funnelClaims` `AsyncStream`. On Kotlin, use the `funnelClaims` `SharedFlow`.
**Params**
| Parameter | Type | Required | Description |
| --------- | ------------------------------------- | -------- | --------------------------------------------------- |
| `cb` | `(result: FunnelClaimResult) => void` | Yes | Callback invoked each time a funnel claim resolves. |
**Returns** — `() => void` (call to unregister the listener)
**Throws** — nothing
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
const unsub = Rovenue.addFunnelClaimListener((result) => {
console.log('Funnel claim resolved for subscriber:', result.subscriberId);
console.log('Funnel answers:', result.funnelAnswers);
// Apply funnel-specific onboarding, unlock content, etc.
});
// When your component unmounts:
unsub();
```
```swift
// Typically in a Task {} inside your SwiftUI view or AppDelegate:
for await result in Rovenue.shared.funnelClaims {
print("Funnel claim resolved for subscriber: \(result.subscriberId)")
print("Funnel answers (raw JSON): \(result.funnelAnswersJson)")
// Apply funnel-specific onboarding, unlock content, etc.
}
```
```kotlin
// In a ViewModel or LifecycleCoroutineScope:
Rovenue.shared.funnelClaims
.onEach { result ->
Log.d("App", "Funnel claim resolved for subscriber: ${result.subscriberId}")
Log.d("App", "Funnel answers (raw JSON): ${result.funnelAnswersJson}")
// Apply funnel-specific onboarding, unlock content, etc.
}
.launchIn(viewModelScope)
```
***
### `claimFromUrl` [#claimfromurl]
Parse and claim a funnel token from any URL the app receives via a Universal Link, App Link, or custom-scheme deep link. Call this whenever the OS hands your app a URL — it is safe to pass **any** URL; non-funnel URLs return `null` immediately without a network request.
Recognized URL shapes:
| Shape | Example |
| ----------------------------------------------- | -------------------------------------------------------- |
| Universal Link / App Link path segment | `https://funnels.example.com/funnels/open/` |
| Query parameter (any host) | `https://example.com/promo?rovenue_funnel_token=` |
| `token=` on the `onboarding-complete` deep link | `myapp://onboarding-complete?token=` |
**Params**
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | ---------------------------------------------------------------------------------------- |
| `url` | `string` | Yes | Any URL the app received from the OS. Non-funnel URLs are ignored and resolve to `null`. |
**Returns** — `Promise<`[`FunnelClaimResult`](/docs/reference/types#funnelclaimresult)` | null>` (`null` for URLs that contain no recognizable funnel token — no network call is made)
**Throws** — funnel claim errors when a **recognized** token fails server-side: [`FunnelTokenNotFound`](/docs/reference/errors#error-table), [`FunnelTokenExpired`](/docs/reference/errors#error-table), or [`FunnelTokenAlreadyClaimed`](/docs/reference/errors#error-table). Non-funnel URLs resolve `null` and never throw — so always wrap the call (a fire-and-forget handler should `.catch`).
This is a React Native helper. On Swift/Kotlin, parse the token from the incoming URL yourself and call [`claimFunnelToken`](#claimfunneltoken).
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
import * as Linking from 'expo-linking';
// Warm start — app already open when the link is tapped:
Linking.addEventListener('url', ({ url }) => {
Rovenue.claimFromUrl(url).catch(() => {
// expired / already-claimed / not-found — recover via email or ignore
});
});
// Cold start — app launched via the link:
const initial = await Linking.getInitialURL();
if (initial) {
try {
await Rovenue.claimFromUrl(initial);
} catch {
// recognized token failed server-side — fall back to email recovery
}
}
```
The OS-level deep-link configuration — iOS Associated Domains (for the funnel Universal Link domain), Android App Links intent filter, and any custom URL scheme — lives in the **consuming app**, not the SDK. See your platform's documentation for setup instructions.
When a funnel token is found and successfully claimed, the result is delivered via [`addFunnelClaimListener`](#addfunnelclaimlistener). The return value of `claimFromUrl` itself is `null` for non-funnel URLs and the [`FunnelClaimResult`](/docs/reference/types#funnelclaimresult) for matched ones.
# SDK API Reference (/docs/reference/sdk-reference)
Every Rovenue SDK ships a full, generated API reference — every public
type, method, and property, straight from doc comments in the source. These
aren't hand-written: each one is produced by the standard documentation
generator for its language (rustdoc, DocC, Dokka, TypeDoc, dartdoc) and
served here as a static site.
A card that isn't a link means that SDK's reference wasn't generated as part
of *this* docs build — each toolchain only runs on a runner that can build
it (DocC needs macOS + Xcode, Dokka needs a JVM, dartdoc needs the Flutter
SDK), so a local `pnpm --filter @rovenue/docs build` will usually be missing
whichever toolchains aren't installed on your machine. The production build
(`.github/workflows/sdk-docs.yml`) generates all six on the runners that can
build them, so `docs.rovenue.io/api//` always has the complete set.
# Types (/docs/reference/types)
Type gallery: [RovenueConfig](#rovenueconfig) · [User](#user) · [Entitlement](#entitlement) · [ProductType](#producttype) · [StoreProduct](#storeproduct) · [Package](#package) · [Offering](#offering) · [Offerings](#offerings) · [PurchaseResult](#purchaseresult) · [ChangeEvent](#changeevent) · [LogEntry](#logentry)
`consumable` products in `ProductType` add virtual-currency balances — configured in the Rovenue dashboard — to the subscriber's account on purchase.
***
## RovenueConfig [#rovenueconfig]
The configuration object passed to [`configure()`](/docs/reference/methods#configuration) on React Native. Swift and Kotlin use labelled parameters directly.
```ts
type RovenueConfig = {
apiKey: string;
baseUrl?: string;
logLevel?: 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
appVersion?: string;
};
```
**Fields**
| Field | Type | Required | Description |
| ------------ | ------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | `string` | Yes | Your project's public API key. Prefix `rov_pub_`. Obtain it from the Rovenue dashboard → API Keys. |
| `baseUrl` | `string` | No | API endpoint URL. Defaults to the hosted endpoint `https://api.rovenue.io`; override only when self-hosting (`https://`; `http://` allowed for `localhost` only). |
| `logLevel` | `'off' \| 'error' \| 'warn' \| 'info' \| 'debug' \| 'trace'` | No | Verbosity of SDK logs. Defaults to `'warn'` — turn up to `'info'`/`'debug'` locally, never ship `'debug'`/`'trace'` in production. |
| `appVersion` | `string` | No | App version string forwarded to the server. Defaults to the value reported by the OS. |
**Returned by / used by:** [`configure()`](/docs/reference/methods#configuration)
**Usage**
```ts
import { Rovenue } from '@rovenue/react-native-sdk';
Rovenue.configure({
apiKey: 'rov_pub_…',
logLevel: 'info',
appVersion: '3.1.0',
// baseUrl: 'https://api.acme.com', // optional — only when self-hosting
});
```
***
## User [#user]
Represents the current device's identity.
```ts
type User = {
rovenueId: string;
appUserId: string | null;
};
```
**Fields**
| Field | Type | Description |
| ----------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `rovenueId` | `string` | Permanent per-install identifier generated on first launch (prefix `rov_`). Always present; never changes for the lifetime of the install. |
| `appUserId` | `string \| null` | Your backend's user ID, attached after a successful [`identify()`](/docs/reference/methods#identify) call. `null` until the user is identified. |
**Returned by:** [`currentUser()`](/docs/reference/methods#currentuser)
**Usage**
```ts
const user = await Rovenue.currentUser();
if (user.appUserId) {
console.log(`Known user: ${user.appUserId}`);
} else {
console.log(`Anonymous install: ${user.rovenueId}`);
}
```
```swift
let user = await Rovenue.shared.currentUser()
if let appUserId = user.appUserId {
print("Known user: \(appUserId)")
} else {
print("Anonymous install: \(user.rovenueId)")
}
```
```kotlin
val user = Rovenue.shared.currentUser()
if (user.appUserId != null) {
Log.d("App", "Known user: ${user.appUserId}")
} else {
Log.d("App", "Anonymous install: ${user.rovenueId}")
}
```
***
## Entitlement [#entitlement]
Represents a single access entitlement. An entitlement is active when the user has a valid subscription or has made a one-time unlock purchase for the corresponding product.
```ts
type Entitlement = {
id: string;
isActive: boolean;
productIdentifier: string;
store: string;
expiresIso?: string;
};
```
**Fields**
| Field | Type | Description |
| ------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | The entitlement key as configured in the Rovenue dashboard (e.g. `"pro"`, `"credits_pack"`). |
| `isActive` | `boolean` | `true` if the entitlement is currently active (not expired, not refunded). Always check this field before granting access. |
| `productIdentifier` | `string` | The store product ID that granted this entitlement (e.g. `"com.example.pro_monthly"`). |
| `store` | `string` | The store that originated the purchase. One of `"app_store"`, `"play_store"`, or `"stripe"`. |
| `expiresIso` | `string \| undefined` | ISO 8601 expiry timestamp (e.g. `"2026-12-31T23:59:59Z"`). `undefined` for non-expiring (lifetime) entitlements. |
**Returned by:** [`entitlement(id)`](/docs/reference/methods#entitlement), [`entitlementsAll()`](/docs/reference/methods#entitlementsall), [`PurchaseResult.entitlements`](#purchaseresult)
**Usage**
```ts
const ent = await Rovenue.entitlement('pro');
if (ent?.isActive) {
// Grant Pro access
const expiresAt = ent.expiresIso
? new Date(ent.expiresIso)
: null; // null = lifetime
}
```
```swift
let ent = await Rovenue.shared.entitlement(id: "pro")
if ent?.isActive == true {
// Grant Pro access
let expiresAt = ent?.expiresIso.flatMap { ISO8601DateFormatter().date(from: $0) }
}
```
```kotlin
val ent = Rovenue.shared.entitlement(id = "pro")
if (ent?.isActive == true) {
// Grant Pro access
val expiresAt = ent.expiresIso?.let { Instant.parse(it) } // null = lifetime
}
```
***
## ProductType [#producttype]
A string enum describing what a store product delivers.
```ts
type ProductType = 'subscription' | 'consumable' | 'non_consumable';
```
**Values**
| Value | Description |
| ---------------- | ---------------------------------------------------------------------------- |
| `subscription` | Auto-renewing subscription. Grants entitlements for the subscription period. |
| `consumable` | One-time purchase that adds credits to the user's balance. |
| `non_consumable` | One-time permanent unlock. Grants a lifetime entitlement. |
**Used by:** [`StoreProduct.type`](#storeproduct)
***
## StoreProduct [#storeproduct]
Represents a store product with localised pricing and subscription metadata from the App Store or Google Play. You receive this as `Package.product` — you do not construct it directly.
### Basic schema [#basic-schema]
```ts
type StoreProduct = {
// Core fields
id: string;
type: ProductType;
productCategory: 'subscription' | 'nonSubscription';
displayName: string;
description: string | null;
priceString: string | null;
price: number | null;
currencyCode: string | null;
// Subscription metadata
subscriptionPeriod: Period | null;
subscriptionGroupIdentifier: string | null; // iOS only
isFamilyShareable: boolean; // iOS only
introPrice: IntroPrice | null; // iOS only
discounts: Discount[]; // iOS promotional offers (empty on Android)
isEligibleForIntroOffer: boolean | null;
// Android subscription options (null on iOS)
subscriptionOptions: SubscriptionOption[] | null;
defaultOption: SubscriptionOption | null;
// Per-unit pricing (approximated)
pricePerWeek: number | null;
pricePerMonth: number | null;
pricePerYear: number | null;
pricePerWeekString: string | null;
pricePerMonthString: string | null;
pricePerYearString: string | null;
// Platform-native escape hatch (Swift & Kotlin only; null on React Native)
rawStoreProduct: StoreKit.Product | ProductDetails | null;
};
```
**Core Fields**
| Field | Type | Description |
| ----------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | The store product identifier (e.g. `"com.example.pro_monthly"`). |
| `type` | [`ProductType`](#producttype) | What the product delivers: subscription, consumable, or non-consumable. |
| `productCategory` | `'subscription' \| 'nonSubscription'` | Product category from the store. Maps to `type`: subscriptions have category `'subscription'`; consumables and non-consumables have `'nonSubscription'`. |
| `displayName` | `string` | Localised product name from the store (e.g. `"Pro Monthly"`). |
| `description` | `string \| null` | Localised product description. `null` if not provided by the store. |
| `priceString` | `string \| null` | Localised, formatted price string (e.g. `"$4.99"`, `"€4,99"`). Display this directly in your paywall. `null` if unavailable. |
| `price` | `number \| null` | Numeric price in the user's currency (e.g. `4.99`). `null` if unavailable. |
| `currencyCode` | `string \| null` | ISO 4217 currency code (e.g. `"USD"`, `"EUR"`). `null` if unavailable. |
**Subscription Fields**
| Field | Type | Platform | Description |
| ----------------------------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `subscriptionPeriod` | [`Period`](#period) `\| null` | All | The billing interval (e.g. 1 month, 1 year). `null` for non-subscription products. |
| `subscriptionGroupIdentifier` | `string \| null` | iOS | The App Store subscription group. Groups related subscriptions so users can switch between them. `null` on Android and non-subscription products. |
| `isFamilyShareable` | `boolean` | iOS | `true` if eligible for family sharing on iOS. Always `false` on Android. |
| `introPrice` | [`IntroPrice`](#introprice) `\| null` | iOS | The introductory offer (free trial or discounted period). iOS only; `null` on Android. |
| `discounts` | [`Discount[]`](#discount) | iOS | iOS promotional offers (introductory + winback offers). Empty array on Android. |
| `isEligibleForIntroOffer` | `boolean \| null` | All | **iOS:** the real StoreKit value (true if intro offer eligible). **Android:** derived; `true` iff a free or intro phase is present. Play enforces actual eligibility at purchase time. `null` if unavailable. |
| `subscriptionOptions` | [`SubscriptionOption[]`](#subscriptionoption) `\| null` | Android | Android subscription options (base plans + offers). `null` on iOS. |
| `defaultOption` | [`SubscriptionOption`](#subscriptionoption) `\| null` | Android | The recommended option for this product (e.g. the base plan without an offer). `null` on iOS. |
**Per-Unit Pricing (Approximated)**
| Field | Type | Description |
| --------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pricePerWeek` | `number \| null` | Estimated weekly price, calculated from `subscriptionPeriod` and `price` (7 days per week). For a 2-week plan priced at $5, this is \~$3.50/week. An approximation only; exact billing varies by period. |
| `pricePerMonth` | `number \| null` | Estimated monthly price (30 days per month). |
| `pricePerYear` | `number \| null` | Estimated annual price (365 days per year). |
| `pricePerWeekString` | `string \| null` | Localised price-per-week string. |
| `pricePerMonthString` | `string \| null` | Localised price-per-month string. |
| `pricePerYearString` | `string \| null` | Localised price-per-year string. |
**Platform-Native Escape Hatch**
| Field | Type | Platform | Description |
| ----------------- | ------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `rawStoreProduct` | `StoreKit.Product` (iOS) / `ProductDetails` (Android) / `null` (RN) | Swift & Kotlin | Direct reference to the platform's native product object. Use only when the Rovenue wrapper is insufficient. Not available in React Native. |
**Used by:** [`Package.product`](#package)
**Usage**
```ts
const pkg = offerings.current?.packages[0];
if (pkg) {
const {
displayName, priceString, type, description, subscriptionPeriod,
pricePerMonthString, isEligibleForIntroOffer
} = pkg.product;
console.log(`${displayName} — ${priceString}`);
console.log(`Description: ${description}`);
if (subscriptionPeriod) {
console.log(`Billing: every ${subscriptionPeriod.value} ${subscriptionPeriod.unit}`);
console.log(`~${pricePerMonthString}/month`);
}
if (isEligibleForIntroOffer) {
console.log('Eligible for intro offer');
}
}
```
```swift
let pkg = offerings.current?.packages.first
if let product = pkg?.product {
print("\(product.displayName) — \(product.priceString ?? "N/A")")
if let period = product.subscriptionPeriod {
print("Billing: every \(period.value) \(period.unit)")
print("~\(product.pricePerMonthString ?? "N/A")/month")
}
if let intro = product.introPrice {
print("Intro: \(intro.priceString ?? "free") for \(intro.cycles) cycles")
}
// Access promotional offers (iOS-specific)
for discount in product.discounts {
print("Promo: \(discount.priceString ?? "free")")
}
// Access native StoreKit.Product if needed
if let native = product.rawStoreProduct {
print("Native product: \(native.id)")
}
}
```
```kotlin
val pkg = offerings.current?.packages.firstOrNull()
pkg?.product?.let { product ->
println("${product.displayName} — ${product.priceString ?: "N/A"}")
product.subscriptionPeriod?.let { period ->
println("Billing: every ${period.value} ${period.unit}")
println("~${product.pricePerMonthString ?: "N/A"}/month")
}
product.introPrice?.let { intro ->
println("Intro: ${intro.priceString ?: "free"} for ${intro.cycles} cycles")
}
// Access Android subscription options
product.subscriptionOptions?.forEach { option ->
println("Option: ${option.id}")
option.pricingPhases.forEach { phase ->
println(" Phase: ${phase.priceString} (${phase.recurrenceMode})")
}
}
// Access native ProductDetails if needed
product.rawStoreProduct?.let { native ->
println("Native product: ${native.productId}")
}
}
```
***
## Supporting types for StoreProduct [#supporting-types-for-storeproduct]
### Period [#period]
Represents a billing or time interval.
```ts
type Period = {
value: number;
unit: 'day' | 'week' | 'month' | 'year';
iso8601: string;
};
```
**Fields**
| Field | Type | Description |
| --------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `value` | `number` | The magnitude (e.g. `1`, `3`, `12`). |
| `unit` | `'day' \| 'week' \| 'month' \| 'year'` | The time unit. |
| `iso8601` | `string` | ISO 8601 duration string (e.g. `"P1M"` for 1 month, `"P1Y"` for 1 year). Useful for standardised duration calculations. |
**Used by:** [`StoreProduct.subscriptionPeriod`](#storeproduct), [`IntroPrice.period`](#introprice), [`Discount.period`](#discount), [`PricingPhase.billingPeriod`](#pricingphase)
**Example**
```ts
const period = product.subscriptionPeriod;
if (period?.unit === 'month' && period.value === 1) {
console.log('Billing monthly'); // period.iso8601 → "P1M"
}
```
***
### IntroPrice [#introprice]
The introductory offer on an iOS subscription (free trial or discounted period).
```ts
type IntroPrice = {
price: number | null;
priceString: string | null;
currencyCode: string | null;
period: Period;
cycles: number;
paymentMode: 'freeTrial' | 'payAsYouGo' | 'payUpFront';
};
```
**Fields**
| Field | Type | Description |
| -------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `price` | `number \| null` | The price per cycle (e.g. `0` for free trial, `0.99` for discounted). `null` if unavailable. |
| `priceString` | `string \| null` | Localised price string. `null` if unavailable. |
| `currencyCode` | `string \| null` | ISO 4217 code. `null` if unavailable. |
| `period` | [`Period`](#period) | The duration of each intro cycle (e.g. 1 week free). |
| `cycles` | `number` | How many intro periods apply (e.g. `1` = 1-week free trial; `3` = 3-week trial). |
| `paymentMode` | `'freeTrial' \| 'payAsYouGo' \| 'payUpFront'` | When payment is charged: `'freeTrial'` (after trial ends), `'payAsYouGo'` (each cycle, starting now), `'payUpFront'` (upfront). |
**Platform:** iOS only. `null` on Android (use `subscriptionOptions.introPhase` instead).
**Used by:** [`StoreProduct.introPrice`](#storeproduct)
**Example**
```ts
const intro = product.introPrice;
if (intro) {
if (intro.paymentMode === 'freeTrial' && intro.price === 0) {
console.log(`${intro.cycles}-${intro.period.unit} free trial`);
} else {
console.log(`${intro.priceString}/${intro.period.value} ${intro.period.unit} for ${intro.cycles} cycles`);
}
}
```
***
### Discount [#discount]
An iOS promotional offer (winback or ongoing discount on a subscription).
```ts
type Discount = {
identifier: string | null;
price: number | null;
priceString: string | null;
currencyCode: string | null;
period: Period;
numberOfPeriods: number;
paymentMode: 'freeTrial' | 'payAsYouGo' | 'payUpFront';
type: 'introductory' | 'promotional' | 'winBack';
};
```
**Fields**
| Field | Type | Description |
| ----------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identifier` | `string \| null` | The offer identifier from the App Store dashboard. `null` if unavailable. |
| `price` | `number \| null` | The discounted price per cycle. `null` if unavailable. |
| `priceString` | `string \| null` | Localised price string. |
| `currencyCode` | `string \| null` | ISO 4217 code. |
| `period` | [`Period`](#period) | The duration of each discount cycle. |
| `numberOfPeriods` | `number` | How many cycles the discount applies (e.g. `3` = 3 months at discount price). |
| `paymentMode` | `'freeTrial' \| 'payAsYouGo' \| 'payUpFront'` | When payment is charged. |
| `type` | `'introductory' \| 'promotional' \| 'winBack'` | Offer type: introductory (new subs only, applied automatically), promotional (all users), or winBack (lapsed subs). **Note:** only `.promotional` and `.winBack` can be passed to `purchase()`; `.introductory` is applied automatically by the App Store. |
**Platform:** iOS only. Empty array on Android.
**Used by:** [`StoreProduct.discounts`](#storeproduct)
**See also:** [Redeeming promotional offers (iOS)](/docs/guides/processing-purchases#redeeming-promotional-offers-ios)
***
### PricingPhase [#pricingphase]
An Android subscription pricing phase (free, intro, or full price; part of a `SubscriptionOption`).
```ts
type PricingPhase = {
price: number | null;
priceString: string | null;
currencyCode: string | null;
billingPeriod: Period;
billingCycleCount: number | null;
recurrenceMode: 'infiniteRecurring' | 'finiteRecurring' | 'nonRecurring';
paymentMode: 'freeTrial' | 'payAsYouGo' | 'payUpFront' | null;
};
```
**Fields**
| Field | Type | Description |
| ------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `price` | `number \| null` | Price per cycle in this phase. `0` for free phases. `null` if unavailable. |
| `priceString` | `string \| null` | Localised price string. |
| `currencyCode` | `string \| null` | ISO 4217 code. |
| `billingPeriod` | [`Period`](#period) | How often the user is billed in this phase. |
| `billingCycleCount` | `number \| null` | How many billing cycles this phase lasts (e.g. `1` for a 1-month free trial, `12` for year-long offer). `null` for infinite recurrence. |
| `recurrenceMode` | `'infiniteRecurring' \| 'finiteRecurring' \| 'nonRecurring'` | `'infiniteRecurring'` (lasts forever), `'finiteRecurring'` (ends after `billingCycleCount` cycles), `'nonRecurring'` (one-time). |
| `paymentMode` | `'freeTrial' \| 'payAsYouGo' \| 'payUpFront' \| null` | When charged. `null` if unavailable. |
**Platform:** Android only. Not used on iOS.
**Used by:** [`SubscriptionOption.pricingPhases`](#subscriptionoption), [`SubscriptionOption.freePhase`](#subscriptionoption), [`SubscriptionOption.introPhase`](#subscriptionoption), [`SubscriptionOption.fullPricePhase`](#subscriptionoption)
***
### SubscriptionOption [#subscriptionoption]
An Android subscription option (base plan + offer combination, or just base plan). Equivalent to an iOS product in a subscription group but with explicit phase structure.
```ts
type SubscriptionOption = {
id: string;
basePlanId: string | null;
offerId: string | null;
tags: string[];
isBasePlan: boolean;
isPrepaid: boolean;
pricingPhases: PricingPhase[];
freePhase: PricingPhase | null;
introPhase: PricingPhase | null;
fullPricePhase: PricingPhase | null;
};
```
**Fields**
| Field | Type | Description |
| ---------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | Unique identifier for this option (e.g. `"base"`, `"base:offer_30_day_trial"`). |
| `basePlanId` | `string \| null` | The base plan this option belongs to (e.g. `"base"`, `"annual"`). `null` if unavailable. |
| `offerId` | `string \| null` | The offer ID if this is a base plan + offer combo (e.g. `"30_day_trial"`). `null` if this is the base plan without an offer. |
| `tags` | `string[]` | User-assigned tags from the Play Console (e.g. `["trial", "new_user"]`). Empty if none. |
| `isBasePlan` | `boolean` | `true` if this option is the base plan (no offer applied). |
| `isPrepaid` | `boolean` | `true` if prepaid (e.g. annual upfront). |
| `pricingPhases` | [`PricingPhase[]`](#pricingphase) | All pricing phases in order (free → intro → full price, or just full price). Used to compute net price. |
| `freePhase` | [`PricingPhase`](#pricingphase) `\| null` | The free phase if present (e.g. 2-week free trial). `null` if none. |
| `introPhase` | [`PricingPhase`](#pricingphase) `\| null` | The introductory discounted phase if present. `null` if none. |
| `fullPricePhase` | [`PricingPhase`](#pricingphase) `\| null` | The full-price phase that lasts indefinitely (the recurring rate). `null` if unavailable. |
**Platform:** Android only. `null` on iOS.
**Used by:** [`StoreProduct.subscriptionOptions`](#storeproduct), [`StoreProduct.defaultOption`](#storeproduct)
**Example**
```ts
const option = product.defaultOption || product.subscriptionOptions?.[0];
if (option) {
console.log(`Option: ${option.id}`);
option.pricingPhases.forEach((phase, i) => {
console.log(`Phase ${i}: ${phase.priceString} (${phase.recurrenceMode})`);
});
if (option.freePhase) {
console.log(`Free trial: ${option.freePhase.billingCycleCount} ${option.freePhase.billingPeriod.unit}`);
}
if (option.fullPricePhase) {
console.log(`Then: ${option.fullPricePhase.priceString}/${option.fullPricePhase.billingPeriod.value} ${option.fullPricePhase.billingPeriod.unit}`);
}
}
```
***
### PackageType [#packagetype]
A convenience enum classifying packages into common categories (e.g. monthly, annual, lifetime) for UI presentation.
```ts
type PackageType =
| 'unknown' | 'custom'
| 'lifetime' | 'annual' | 'sixMonth' | 'threeMonth' | 'twoMonth' | 'monthly' | 'weekly';
```
**Values**
| Value | Description |
| -------------- | -------------------------------------------------------- |
| `'lifetime'` | One-time permanent unlock. |
| `'annual'` | 1-year subscription. |
| `'sixMonth'` | 6-month subscription. |
| `'threeMonth'` | 3-month subscription. |
| `'twoMonth'` | 2-month subscription. |
| `'monthly'` | 1-month subscription. |
| `'weekly'` | 1-week subscription. |
| `'custom'` | Billed on a custom interval (e.g. 2 weeks, 90 days). |
| `'unknown'` | Unable to determine. Fallback for unrecognized patterns. |
**Used by:** [`Package.packageType`](#package)
***
## Offering convenience accessors [#offering-convenience-accessors]
The `Offering` type provides accessors to quickly find packages by type:
React Native does not expose convenience accessors on the `Offering` object itself. Use [`Package.packageType`](#package) to filter packages manually, or look them up by `identifier`:
```ts
const offerings = await Rovenue.getOfferings();
const offering = offerings.current;
// Find by packageType
const monthly = offering?.packages.find(p => p.packageType === 'monthly');
// Find by identifier
const annual = offering?.packages.find(p => p.identifier === 'annual');
```
Swift `Offering` includes convenience properties:
```swift
let offering = offerings.current
// Access by PackageType enum
let monthly = offering?.monthly // Package? matching .monthly
let annual = offering?.annual // Package? matching .annual
let weekly = offering?.weekly // Package? matching .weekly
let sixMonth = offering?.sixMonth
let threeMonth = offering?.threeMonth
let twoMonth = offering?.twoMonth
let lifetime = offering?.lifetime
// Or look up by identifier
let custom = offering?.package(identifier: "pro_custom")
```
Kotlin `Offering` includes convenience properties:
```kotlin
val offering = offerings.current
// Access by PackageType enum
val monthly = offering?.monthly // Package? matching MONTHLY
val annual = offering?.annual // Package? matching ANNUAL
val weekly = offering?.weekly // Package? matching WEEKLY
val sixMonth = offering?.sixMonth
val threeMonth = offering?.threeMonth
val twoMonth = offering?.twoMonth
val lifetime = offering?.lifetime
// Or look up by identifier
val custom = offering?.packageBy("\$rov_monthly")
```
***
## Package [#package]
A wrapper around a [`StoreProduct`](#storeproduct) as configured in the Rovenue dashboard. Packages are the unit you pass to [`purchase()`](/docs/reference/methods#purchase).
```ts
type Package = {
identifier: string;
packageType: PackageType;
product: StoreProduct;
};
```
**Fields**
| Field | Type | Description |
| ------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `identifier` | `string` | The package identifier as configured in the Rovenue dashboard (e.g. `"monthly"`, `"annual"`, `"credits_100"`). |
| `packageType` | [`PackageType`](#packagetype) | A convenience category for the package (e.g. `'monthly'`, `'annual'`, `'lifetime'`). Use this to simplify paywall UI logic. |
| `product` | [`StoreProduct`](#storeproduct) | The underlying store product with localised pricing and subscription metadata. |
**Returned by:** [`Offering.packages`](#offering) (via [`getOfferings()`](/docs/reference/methods#getofferings))
**Usage**
```ts
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages.find(p => p.identifier === 'annual');
if (pkg) {
console.log(`Annual plan: ${pkg.product.priceString}`);
console.log(`Type: ${pkg.packageType}`);
const result = await Rovenue.purchase(pkg);
}
```
```swift
let offerings = try await Rovenue.shared.getOfferings()
let pkg = offerings.current?.packages.first { $0.identifier == "annual" }
if let pkg = pkg {
print("Annual plan: \(pkg.product.priceString ?? "N/A")")
print("Type: \(pkg.packageType)")
let result = try await Rovenue.shared.purchase(pkg)
}
```
```kotlin
val offerings = Rovenue.shared.getOfferings()
val pkg = offerings.current?.packages?.find { it.identifier == "annual" }
pkg?.let {
println("Annual plan: ${it.product.priceString}")
println("Type: ${it.packageType}")
val result = Rovenue.shared.purchase(it)
}
```
***
## Offering [#offering]
A named group of packages configured in the Rovenue dashboard. The dashboard controls which offering is marked as `current` (default), allowing remote configuration changes without an app release.
```ts
type Offering = {
id: string;
packages: Package[];
};
```
**Fields**
| Field | Type | Description |
| ---------- | ----------------------- | --------------------------------------------------------------------------------------- |
| `id` | `string` | The offering identifier as configured in the dashboard (e.g. `"default"`, `"sale_q4"`). |
| `packages` | [`Package[]`](#package) | The packages belonging to this offering, in dashboard-configured order. |
**Used by:** [`Offerings.current`](#offerings), [`Offerings.all`](#offerings)
**Usage**
```ts
const offering = offerings.all['sale_q4'];
offering?.packages.forEach(pkg => {
console.log(pkg.id, pkg.product.priceString);
});
```
***
## Offerings [#offerings]
The top-level response from [`getOfferings()`](/docs/reference/methods#getofferings).
```ts
type Offerings = {
current: Offering | null;
all: Record;
};
```
**Fields**
| Field | Type | Description |
| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------ |
| `current` | [`Offering`](#offering) `\| null` | The offering marked as default in the dashboard. `null` if no default is configured. |
| `all` | `Record` | All configured offerings, keyed by their identifier. |
**Returned by:** [`getOfferings()`](/docs/reference/methods#getofferings)
**Usage**
```ts
const offerings = await Rovenue.getOfferings();
// Show the default paywall:
const packages = offerings.current?.packages ?? [];
// Or show a specific named offering (e.g. for an A/B test):
const saleOffering = offerings.all['sale_q4'];
```
```swift
let offerings = try await Rovenue.shared.getOfferings()
// Show the default paywall:
let packages = offerings.current?.packages ?? []
// Or show a specific offering:
let saleOffering = offerings.all["sale_q4"]
```
```kotlin
val offerings = Rovenue.shared.getOfferings()
// Show the default paywall:
val packages = offerings.current?.packages ?: emptyList()
// Or show a specific offering:
val saleOffering = offerings.all["sale_q4"]
```
***
## PurchaseResult [#purchaseresult]
Returned by [`purchase()`](/docs/reference/methods#purchase) after the store transaction and server-side validation both succeed. Contains the fully-refreshed state — no additional polling is needed.
```ts
type PurchaseResult = {
entitlements: Entitlement[];
virtualCurrencies: Record;
productId: string;
storeTransactionId: string;
isDeferred: boolean;
};
```
**Fields**
| Field | Type | Description |
| -------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entitlements` | [`Entitlement[]`](#entitlement) | The user's full set of active entitlements after the purchase. Empty when `isDeferred` is `true` — nothing has been granted yet. Use this to update gated UI immediately. |
| `virtualCurrencies` | `Record` | The user's virtual-currency balances after the purchase (currency code → amount). Reflects any balances added by a currency-pack product. |
| `productId` | `string` | The store product ID that was purchased (e.g. `"com.example.pro_monthly"`). |
| `storeTransactionId` | `string` | The platform transaction ID: StoreKit 2 transaction ID on iOS, Play Billing order ID on Android. Empty string when `isDeferred` is `true`. |
| `isDeferred` | `boolean` | `true` when the purchase is pending external approval (e.g. Ask-to-Buy / parental controls) rather than completed. This is **not an error** — `purchase()` resolves normally. See [Handling a deferred purchase](/docs/reference/methods#handling-a-deferred-purchase). |
**Returned by:** [`purchase()`](/docs/reference/methods#purchase)
**Usage**
```ts
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) {
// Ask-to-Buy — not an error. Wait for a change-stream event before granting anything.
} else {
// Grant access based on updated entitlements:
const hasPro = result.entitlements.some(e => e.id === 'pro' && e.isActive);
console.log(`Pro: ${hasPro}, COINS: ${result.virtualCurrencies['COINS'] ?? 0}`);
console.log(`Transaction: ${result.storeTransactionId}`);
}
```
***
## ChangeEvent [#changeevent]
Emitted by the [change stream](/docs/reference/methods#change-stream) whenever internal SDK state is updated. React to these events to keep your UI in sync without polling.
```ts
// React Native — string union (UPPER_SNAKE_CASE — matches the Rust core's wire values)
type ChangeEvent =
| 'ENTITLEMENTS_CHANGED'
| 'IDENTITY_CHANGED'
| 'VIRTUAL_CURRENCIES_CHANGED'
| 'REMOTE_CONFIG_CHANGED';
```
On Swift, `ChangeEvent` is a Swift enum with cases `.entitlementsChanged`, `.identityChanged`, `.virtualCurrenciesChanged`, and `.remoteConfigChanged`, delivered as `AsyncStream` via `Rovenue.shared.changes`.
On Kotlin, `ChangeEvent` is a Kotlin enum with constants `ENTITLEMENTS_CHANGED`, `IDENTITY_CHANGED`, `VIRTUAL_CURRENCIES_CHANGED`, and `REMOTE_CONFIG_CHANGED`, delivered as `SharedFlow` via `Rovenue.shared.changes`.
**Values**
| Value (RN) | Swift case | Kotlin constant | When it fires |
| ------------------------------ | --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `'ENTITLEMENTS_CHANGED'` | `.entitlementsChanged` | `ENTITLEMENTS_CHANGED` | After the entitlement cache is refreshed — e.g. after a successful purchase, `refreshEntitlements()`, or a foreground transition that triggers a background sync. |
| `'IDENTITY_CHANGED'` | `.identityChanged` | `IDENTITY_CHANGED` | After `identify()` succeeds or `logOut()` resets the user. |
| `'VIRTUAL_CURRENCIES_CHANGED'` | `.virtualCurrenciesChanged` | `VIRTUAL_CURRENCIES_CHANGED` | After `refreshVirtualCurrencies()` or a server-side grant/spend that updates the cache. |
| `'REMOTE_CONFIG_CHANGED'` | `.remoteConfigChanged` | `REMOTE_CONFIG_CHANGED` | After every successful [`refreshRemoteConfig()`](/docs/reference/methods#refreshremoteconfig). The SDK does not diff the payload, so this also fires when the refresh returned identical values. |
**Emitted by:** [`addChangeListener()`](/docs/reference/methods#change-stream) (RN), `Rovenue.shared.changes` (Swift/Kotlin)
**Usage**
```ts
const unsub = Rovenue.addChangeListener(async (event) => {
if (event === 'ENTITLEMENTS_CHANGED') {
const ent = await Rovenue.entitlement('pro');
setHasPro(ent?.isActive ?? false);
}
if (event === 'VIRTUAL_CURRENCIES_CHANGED') {
const balances = Rovenue.virtualCurrencies();
setCoins(balances['COINS'] ?? 0);
}
});
return () => unsub(); // cleanup on unmount
```
```swift
Task {
for await event in Rovenue.shared.changes {
switch event {
case .entitlementsChanged:
let ent = await Rovenue.shared.entitlement(id: "pro")
await MainActor.run { hasPro = ent?.isActive ?? false }
case .virtualCurrenciesChanged:
let balances = await Rovenue.shared.virtualCurrencyBalances()
await MainActor.run { self.coins = balances["COINS"] ?? 0 }
case .identityChanged:
break
case .remoteConfigChanged:
break
}
}
}
```
```kotlin
Rovenue.shared.changes
.onEach { event ->
when (event) {
ChangeEvent.ENTITLEMENTS_CHANGED -> {
val ent = Rovenue.shared.entitlement(id = "pro")
_hasPro.value = ent?.isActive == true
}
ChangeEvent.VIRTUAL_CURRENCIES_CHANGED -> {
val balances = Rovenue.shared.virtualCurrencyBalances()
_coins.value = balances["COINS"] ?: 0
}
ChangeEvent.IDENTITY_CHANGED -> { /* re-read user if needed */ }
ChangeEvent.REMOTE_CONFIG_CHANGED -> { /* re-read flags/experiments */ }
}
}
.launchIn(viewModelScope)
```
***
## LogEntry [#logentry]
A structured log entry delivered to the handler registered via [`setLogHandler()`](/docs/reference/methods#setloghandler).
```ts
type LogEntry = {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
timestamp: string;
};
```
**Fields**
| Field | Type | Description |
| ----------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `level` | `'debug' \| 'info' \| 'warn' \| 'error'` | Severity level. `debug` entries are only emitted when `logLevel` is set to `'debug'` or `'trace'` in [`RovenueConfig`](#rovenueconfig). |
| `message` | `string` | Human-readable log message from the SDK. |
| `timestamp` | `string` | ISO 8601 timestamp of when the entry was generated (e.g. `"2026-06-15T10:30:00.000Z"`). |
**Used by:** [`setLogHandler()`](/docs/reference/methods#logging)
**Usage**
```ts
const unsub = Rovenue.setLogHandler((entry) => {
if (entry.level === 'error' || entry.level === 'warn') {
// Forward to your error tracking service
Sentry.addBreadcrumb({
message: entry.message,
level: entry.level,
timestamp: new Date(entry.timestamp).getTime() / 1000,
});
}
});
```
***
## FunnelClaimResult [#funnelclaimresult]
Returned by the [funnel-claim methods](/docs/reference/methods#claimfunneltoken) (`claimFunnelToken`, `claimInstall`, `resolveFunnelClaim`, `claimFromClipboard`, `claimFromUrl`) when a prior funnel session is matched, or delivered via [`addFunnelClaimListener`](/docs/reference/methods#addfunnelclaimlistener).
```ts
// React Native
type FunnelClaimResult = {
subscriberId: string; // Rovenue subscriber ID of the matched user
funnelAnswers: Record; // key-value map of funnel step answers, already parsed
};
```
**Fields**
| Field | Type | Description |
| --------------- | ------------------------- | ------------------------------------------------------------------------------------ |
| `subscriberId` | `string` | Rovenue subscriber ID of the matched funnel session. |
| `funnelAnswers` | `Record` | Key-value map of funnel step answers, already parsed. React Native only — see below. |
**Swift and Kotlin return the funnel answers as a raw JSON string, not a parsed object.**
Their `FunnelClaimResult` is `{ subscriberId: String, funnelAnswersJson: String }` —
decode `funnelAnswersJson` yourself (e.g. `JSONSerialization` on Swift,
`org.json.JSONObject`/`kotlinx.serialization` on Kotlin) if you need the parsed shape.
Only the React Native binding pre-parses it into `funnelAnswers`.
**Returned by:** [`claimFunnelToken()`](/docs/reference/methods#claimfunneltoken), [`claimInstall()`](/docs/reference/methods#claiminstall), [`resolveFunnelClaim()`](/docs/reference/methods#resolvefunnelclaim), [`claimFromClipboard()`](/docs/reference/methods#claimfromclipboard), [`claimFromUrl()`](/docs/reference/methods#claimfromurl)
# Migrating from Adapty (/docs/resources/migrating-from-adapty)
Rovenue covers the same subscription-management use cases as Adapty but runs on your own infrastructure. This page maps familiar Adapty concepts to their Rovenue equivalents, then walks through importing your existing subscriber and transaction history with the built-in data import tool.
## Concept mapping [#concept-mapping]
| Adapty | Rovenue |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `Adapty.activate(apiKey)` | `Rovenue.configure({ apiKey })` — `baseUrl` is optional (self-hosting only) |
| `Adapty.getProfile().accessLevels` | `entitlement(id)` / `entitlementsAll()` |
| `Adapty.identify(customerUserId)` | `identify(appUserId)` — server-side bind + auto-transfer of prior installs |
| `Adapty.getPaywall()` | `getFunnel(placementId)` — placements serve native funnels (see [Placements](/docs/guides/placements)) |
| `Adapty.getPaywallProducts()` | `Rovenue.getOfferings()` |
| SDK-driven `Adapty.makePurchase(product)` | `Rovenue.purchase(pkg)` — `getOfferings()` then `purchase(pkg)` |
| `Adapty.Delegates.onLatestProfileLoad` | `addChangeListener` (RN) / `changes` AsyncStream (Swift) / SharedFlow (Kotlin) |
## Purchase flow [#purchase-flow]
The purchase flow in Rovenue mirrors Adapty's: call `getOfferings()` to
fetch your configured products, then pass the chosen package to `purchase(pkg)`.
The SDK triggers the native store sheet (StoreKit 2 on iOS, Play Billing on
Android), validates the receipt server-side, and resolves with updated
entitlements and credit balance — no separate receipt-posting step required.
```ts
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
if (pkg) {
try {
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) { /* Ask-to-Buy / deferred — not an error, wait for it to resolve */ }
// result.entitlements / result.virtualCurrencies are already up to date
} catch (e) {
if (e instanceof RovenueError && e.kind === 'PurchaseCanceled') { /* user dismissed — ignore */ }
else throw e;
}
}
await Rovenue.restorePurchases();
```
## Key differences from Adapty [#key-differences-from-adapty]
* **Self-hosted.** Rovenue runs on your own infrastructure — no revenue share,
no third-party data processing. You own the database and control retention.
* **Virtual currencies built-in.** Rovenue ships a first-class multi-currency ledger for consumable products, loyalty systems, and metered billing. Balances are read client-side; spending is a server-side operation via `POST /v1/virtual-currencies/:appUserId/:code/transactions`.
* **Identity model.** `identify(appUserId)` performs a server-side bind and automatically transfers any prior install's purchases and credits to the current device (similar to Adapty's identify-and-merge behaviour). Call `logOut()` on sign-out to mint a fresh `rovenueId`. Bulk or programmatic merges are available via the secret-key backend API.
* **Open source (AGPLv3).** You can read, audit, and fork the full stack.
## Importing your history [#importing-your-history]
Rovenue's data import tool is a column-mapping engine with vendor presets, not
a vendor-specific parser. It has built-in presets for RevenueCat's export
shapes, but **deliberately no Adapty preset**: Adapty's own S3 export column
table could not be confirmed against first-party documentation, and shipping
a guessed mapping would risk silently mis-importing your data rather than
flagging what it can't confirm. Every Adapty import instead goes through the
generic mapper, described below — this is a deliberate design choice made
because guessing wrong here is worse than asking you to map columns once by
hand, not a stopgap for a mapping we simply haven't gotten to yet.
Because there's no preset to detect against, the upload step never proposes
a mapping automatically for an Adapty export. That's expected — proceed
straight to mapping the columns yourself.
### Export from Adapty [#export-from-adapty]
From the Adapty dashboard, request a data export of your subscribers and
transactions (Adapty delivers this as files in your own S3 bucket or a
downloadable CSV, depending on your plan). Download the transaction-level
file — the one with a row per purchase/renewal/refund, not a
customer-summary rollup — since that is what has enough detail to
reconstruct purchase history.
### Upload and map columns by hand [#upload-and-map-columns-by-hand]
Upload the file to the import tool. Since no preset matches an Adapty
export, you'll see an empty mapping — assign each of your file's source
columns to the closest canonical field:
| Canonical field | Typical Adapty column | Required |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| `subscriberExternalId` | your `customer_user_id` | yes |
| `store` | store / platform column — accepted values only: `app_store`, `play_store`, `stripe`, `promotional` (case-insensitive; nothing else is guessed) | yes |
| `productIdentifier` | product id / vendor product id | yes |
| `purchaseDate` | purchase/transaction date | yes |
| `storeTransactionId` | store transaction id (Apple original transaction id / Google purchase token / Stripe subscription id) | no, but strongly recommended |
| `expiresDate` | expiration date | no |
| `isTrial` / `isIntroOffer` | trial / introductory-price flags | no |
| `priceUsd` | price in USD | no |
| `refundedAt` | refund date | no |
Column names in your actual export may differ from the ones above — Adapty's
exact export schema is not something this guide can promise, which is the
same reason there's no built-in preset for it. Use the import tool's column
preview (it peeks your uploaded file's header) to see your file's real
column names and map from there. Only `subscriberExternalId`, `store`,
`productIdentifier`, and `purchaseDate` are required; leave anything you
can't map unmapped rather than guessing — an unmapped optional field is
simply not imported, which is always safer than a wrong mapping.
The `store` column only recognizes exactly `app_store`, `play_store`,
`stripe`, and `promotional` (case-insensitive, nothing else is guessed). A
column that instead reads `ios`, `android`, or a platform name Adapty
itself uses will fail **every row** in the file with an unrecognized-value
error — recode the column to one of the four accepted values before
importing.
A row with no usable value for `storeTransactionId` at all is **not**
imported — it fails as an invalid row (`MISSING_STORE_TRANSACTION_ID`,
visible in the dry run's `invalidRow` count) because there is nothing to
key a purchase on, and this importer never invents a value that claims to
be a real store transaction identifier. This is different from a row that
DOES have a transaction id but can't be re-verified live — the
"history-only" outcome the RevenueCat guide describes for Android rows
missing a purchase token — which imports fine, just without live-store
re-verification. If your export can't reliably provide a
`storeTransactionId`, get one into it before importing; there is no
separate supplemental pass for Adapty the way there is for RevenueCat's
Google-token file (and that supplemental pass isn't functional yet either
— see that guide's note on it).
### Dry run [#dry-run]
Every import runs a dry run before it can commit — there's no way to skip
it. Starting one queues an asynchronous job (the dashboard polls for
completion); on a large export this can take a while, exactly because it
reads the whole file. The dry run reports, per row, whether it will create
a new purchase, update an existing one, or be skipped (unresolved product,
sandbox, no store transaction id, invalid data). Since your mapping was
built by hand rather than detected, review this output carefully —
it's the first real signal that your column assignments matched your
file's actual data.
### Verify and commit [#verify-and-commit]
Committing runs two phases. Phase A writes every row the file carries as
history — subscribers, purchases, revenue events — without contacting Apple,
Google, or Stripe. Phase B then re-validates every row that carries a real
store transaction id against the store itself and sets its live status. A
row the store no longer recognises is left alone as history, never
deleted — this is a deliberate difference from Adapty's own importer, which
drops a stale transaction outright.
Large imports can outrun a single verification pass — store APIs are
rate-limited, and Android in particular is a documented quota hazard. If
Phase B runs out of budget partway through, the job ends in
`VERIFICATION_INCOMPLETE` rather than failing outright; every anchor already
resolved is remembered, so resuming the job continues from where it left
off instead of re-verifying from scratch. A job the tool is still actively
verifying shows as `VERIFYING`.
### Cut the SDK over [#cut-the-sdk-over]
Once you're satisfied with the import, point your app at Rovenue: install
the SDK for your platform, call `configure()`, and call `identify(appUserId)`
with the same id your export used as `customer_user_id` /
`subscriberExternalId` so the SDK resolves to the subscriber your import
created. `restorePurchases()` re-validates any purchase the store still
knows about, which is a useful sanity check immediately after cutover.
## What doesn't survive the move [#what-doesnt-survive-the-move]
Rovenue's import is a best-effort history reconstruction, not a full data
migration, and no importer — in either direction — can promise otherwise.
Adapty's own documentation makes the same disclaimer about importing
competitors' data, and it applies here too. Specifically, the following are
**not** carried over:
* **Full event history.** The import writes purchases and revenue events
from the export's transaction rows; it does not reconstruct every
underlying lifecycle event Adapty ever recorded.
* **Original historical prices** where the export itself doesn't carry
them — the import only ever uses the price a row actually reports; it
never estimates or backfills one.
* **Full renewal chains** beyond what the export lists as individual rows.
If your export doesn't include a renewal, Rovenue has no way to know it
happened.
* **Promotional entitlements with no underlying store transaction.** An
access grant Adapty made outside a real purchase has nothing to import
from unless your export represents it as a row with its own identifiable
transaction id.
Uploaded export files are end-user PII and are deleted automatically after
a retention window once a job finishes. A job still actively resumable
(`VERIFICATION_INCOMPLETE` / `VERIFYING`) is never swept while it's in
that state — the sweep would strand it incomplete forever. A `FAILED` or
`CANCELLED` job, though also re-runnable from the mapping-editor step
without re-uploading, IS swept on the same clock as a finished job once
the window elapses; plan any mapping fix and retry within that window, or
re-upload the same file as a new job afterward.
# Migrating from RevenueCat (/docs/resources/migrating-from-revenuecat)
Rovenue covers the same subscription-management use cases as RevenueCat but runs on your own infrastructure. This page maps familiar RevenueCat concepts to their Rovenue equivalents so you can orient quickly, then walks through importing your existing subscriber and transaction history with the built-in data import tool.
## Concept mapping [#concept-mapping]
| RevenueCat | Rovenue |
| -------------------------------- | ------------------------------------------------------------------------------ |
| `Purchases.configure(apiKey)` | `Rovenue.configure({ apiKey })` — `baseUrl` is optional (self-hosting only) |
| `getCustomerInfo().entitlements` | `entitlement(id)` / `entitlementsAll()` |
| `logIn(appUserID)` | `identify(appUserId)` — server-side bind + auto-transfer of prior installs |
| `Purchases.getOfferings()` | `Rovenue.getOfferings()` |
| SDK-driven `purchase(package)` | `Rovenue.purchase(pkg)` — same pattern; `getOfferings()` then `purchase(pkg)` |
| `addCustomerInfoUpdateListener` | `addChangeListener` (RN) / `changes` AsyncStream (Swift) / SharedFlow (Kotlin) |
## Purchase flow [#purchase-flow]
The purchase flow in Rovenue mirrors RevenueCat's: call `getOfferings()` to
fetch your configured products, then pass the chosen package to `purchase(pkg)`.
The SDK triggers the native store sheet (StoreKit 2 on iOS, Play Billing on
Android), validates the receipt server-side, and resolves with updated
entitlements and credit balance — no separate receipt-posting step required.
```ts
const offerings = await Rovenue.getOfferings();
const pkg = offerings.current?.packages[0];
if (pkg) {
try {
const result = await Rovenue.purchase(pkg);
if (result.isDeferred) { /* Ask-to-Buy / deferred — not an error, wait for it to resolve */ }
// result.entitlements / result.virtualCurrencies are already up to date
} catch (e) {
if (e instanceof RovenueError && e.kind === 'PurchaseCanceled') { /* user dismissed — ignore */ }
else throw e;
}
}
await Rovenue.restorePurchases();
```
## Key differences from RevenueCat [#key-differences-from-revenuecat]
* **Self-hosted.** Rovenue runs on your own infrastructure — no revenue share,
no third-party data processing. You own the database and control retention.
* **Virtual currencies built-in.** Rovenue ships a first-class multi-currency ledger for consumable products, loyalty systems, and metered billing. Balances are read client-side; spending is a server-side operation via `POST /v1/virtual-currencies/:appUserId/:code/transactions`.
* **Identity model.** `identify(appUserId)` performs a server-side bind and automatically transfers any prior install's purchases and credits to the current device (similar to RevenueCat's "transfer to new ID" behaviour). Call `logOut()` on sign-out to mint a fresh `rovenueId`. Bulk or programmatic merges are available via the secret-key backend API.
* **Open source (AGPLv3).** You can read, audit, and fork the full stack.
## Importing your history [#importing-your-history]
Rovenue's data import tool is a column-mapping engine with vendor presets, not
a hand-written RevenueCat parser — it reads a CSV, maps its columns onto a
fixed set of canonical fields (subscriber id, store, product identifier,
purchase date, and so on), and writes the result as history. If a column
layout ever drifts, the import falls back to the generic mapper below
instead of guessing.
A full RevenueCat migration is **two uploads**, not one: the Transactions
export (your whole history), then — for Android only — the supplemental
purchase-token file, which attaches Google Play purchase tokens to the
Android purchases the first import already wrote. Both are covered below,
in order.
### Export from RevenueCat [#export-from-revenuecat]
From the RevenueCat dashboard, request a **Transactions export**. This is a
CSV with one row per transaction (initial purchase, renewal, refund, and so
on) — the closest RevenueCat gets to a full history dump.
The standard Transactions export does **not** include `google_purchase_token`.
Android rows from it import as history only — they still create a
subscriber, purchase and revenue-event history, so your dashboards and LTV
numbers are complete — but they cannot be re-verified against the Play
Developer API, so they carry no live entitlement grant from this import.
The dry run quantifies exactly how many rows this affects before you
commit, and the purchase-token second pass further down recovers them.
Every other row-level field the exporter needs — subscriber id, store,
product identifier, purchase/expiry dates, trial and intro flags, USD
price, entitlement identifiers — is present in the standard export and is
picked up automatically once the file is uploaded, because its header
matches the RevenueCat Transactions preset. `store_transaction_id`
specifically is included on the strength of RevenueCat's own documented
guidance that it's the transaction row's unique key, not confirmed
column-by-column against a real export at the time this guide was
written — if your export happens to lack it, preset detection simply
won't match and you'll map by hand in the generic-mapper step below.
### Upload the export [#upload-the-export]
Upload the Transactions export to the import tool. Its header carries the
RevenueCat Transactions preset's anchor columns
(`rc_original_app_user_id`, `store_transaction_id`), so the tool proposes
that preset's mapping automatically — review it, adjust anything you've
customized, and continue to the dry run and commit steps below. This
single pass imports every row — iOS, Android, Stripe — as history, and
grants live access for every row that already carries a usable store
anchor (everything except Android rows, covered next).
### Android live entitlements: the purchase-token second pass [#android-live-entitlements-the-purchase-token-second-pass]
Ask RevenueCat support for the supplemental **purchase-token CSV**. It has
three columns — `user_id`, `google_purchase_token`, `google_product_id` —
one row per Android subscription. Upload it to the import tool exactly like
the Transactions export; its header matches the `revenuecat_google_token`
preset, so the tool recognises it, proposes the three-column mapping, and
marks the job as a **Google purchase-token second pass**.
This pass **creates nothing**. It attaches each token to the Android
purchase rows your history import already wrote, which is what lets Rovenue
re-verify those subscriptions against the Play Developer API and grant live
access. Run it *after* the history import has completed, or it will find
nothing to attach to.
**Run the history import first.** A token whose subscriber or product has no
imported Play purchase is reported as `No matching Play purchase` and
skipped. That is also the signal to check that the product's Google store id
is in your Rovenue catalog — the pass matches on the catalog product, not on
the raw string.
Unlike the history import, this pass reads the whole file into memory before
it resolves anything — it has to, because "this file gives one subscriber and
product two different tokens" is a property of the file, not of any single
row. Files above 500,000 rows are therefore refused outright rather than
partly enriched, with a message saying so; split one that large and upload
each part as its own import. The pass is idempotent, so overlapping parts are
safe.
The dry run classifies every row and writes nothing, exactly as it does for
a history import. Its buckets are:
| Outcome | Meaning |
| ---------------------------------------------- | --------------------------------------------------------------------------------- |
| **Token applied** | The token was matched to one subscription and will be written to every row of it. |
| **Already had this token** | Nothing to do — re-running the same file is a no-op. |
| **Renewals not linked — needs an opt-in** | See below. |
| **Ambiguous — skipped** | Reported, never guessed at. See below. |
| **Conflicting token already stored — skipped** | A purchase row already stores a *different* token. Never overwritten. |
| **No matching Play purchase** | No imported Play purchase for that subscriber and product. |
| **Invalid row** | The row is missing one of the three required values. |
#### What it refuses to guess [#what-it-refuses-to-guess]
A subscriber can hold more than one subscription to the same product — they
lapsed and resubscribed. `(subscriber, product)` is therefore **not** a
unique key on purchases, and picking one subscription silently would be a
guess dressed up as a lookup. So when a row's subscriber and product span
**more than one subscription chain**, the pass reports it as ambiguous and
**skips it** rather than choosing. Stamping one subscription's token onto
another's renewals would make verification confidently re-check the wrong
subscription — worse than leaving the rows as history.
The same applies to an ambiguity your *file* asserts: if it supplies two
different tokens for one subscriber and product, nothing can resolve that
and no setting overrides it.
#### `enrichUngroupedChains` — the opt-in you will probably need [#enrichungroupedchains--the-opt-in-you-will-probably-need]
The Transactions export has no original-transaction column. Rovenue's
importer therefore cannot tell that a subscriber's four Android renewals
belong to one subscription, and records each as its own one-row chain. Under
the strict rule above, that is "four chains" and every one of them is
refused — so on a stock RevenueCat export the second pass can end up
enriching nothing at all.
That is what the **Link unlinked renewals** checkbox
(`enrichUngroupedChains`) is for. Turn it on and the pass treats a
subscriber's *unlinked* renewals of one product as a single subscription.
The dry-run summary names it, with a count, whenever rows land in that
bucket — turn it on, save, and re-run the dry run to see what it changes
before committing.
It is deliberately narrow:
* It applies **only** when the history export expressed no subscription
chains at all. If even one imported row carries a real
original-transaction id, the export *did* express chains, and more than
one of them is a genuine resubscribe — still refused.
* It never resolves two different tokens for one subscriber and product.
* It relaxes **grouping**, never **overwriting**. A stored token that
disagrees with your file is never replaced under any setting.
It is off by default because the strict rule is the safe direction.
#### If you skip this pass entirely [#if-you-skip-this-pass-entirely]
Nothing breaks. Android rows stay as complete purchase/revenue history and
LTV; only the live entitlement grant is missing. Once you cut a user's app
over to the Rovenue SDK (last step below), their next `restorePurchases()`
call — or any live purchase/renewal webhook — re-verifies directly against
the Play Developer API and grants access from then on. The second pass is
what covers the users whose devices never open the app again.
### Map anything the presets don't cover [#map-anything-the-presets-dont-cover]
If your export has been customized (extra columns, a renamed subscriber
attribute) or a column doesn't match either preset by name, the import tool
falls back to the generic mapper: you assign each source column to one of
the canonical fields yourself. `subscriberExternalId`, `store`,
`productIdentifier`, and `purchaseDate` are required; everything else is
optional and simply isn't imported if left unmapped. Preset detection is
anchor-gated — a file with no vendor-identifying column detects nothing,
which is expected, not an error, and just means you map by hand.
The `store` column only recognizes exactly these values (matching is
case-insensitive, but nothing else is guessed): `app_store`, `play_store`,
`stripe`, `promotional`. A column that instead reads `App Store`, `ios`,
`android`, or `google` will fail **every row** in the file with an
unrecognized-value error — this is the single most common way a hand-mapped
file fails completely. Recode the column (or transform it before export) to
one of the four accepted values first.
### Dry run [#dry-run]
Every import runs a dry run before it can commit — there's no way to skip
it. Starting one queues an asynchronous job (the dashboard polls for
completion); on a large export this can take a while, exactly because it
reads the whole file. The dry run reports, per row, whether it will create
a new purchase, update an existing one, or be skipped (unresolved product,
sandbox, no Android token, invalid data) — including the count of rows that
will import as history-only with no live entitlement — the rows the
purchase-token second pass above exists to recover. Review those numbers
before committing.
### Verify and commit [#verify-and-commit]
Committing runs two phases. Phase A writes every row the file carries as
history — subscribers, purchases, revenue events — without contacting Apple,
Google, or Stripe. Phase B then re-validates every row that carries a real
store anchor against the store itself and sets its live status. A row the
store no longer recognises is left alone as history, never deleted — this
is a deliberate difference from how some other importers handle a stale
transaction, and it means your data is never silently discarded because a
transaction predates what the store API still remembers.
Large imports can outrun a single verification pass — store APIs are
rate-limited, and Android in particular is a documented quota hazard. If
Phase B runs out of budget partway through, the job ends in
`VERIFICATION_INCOMPLETE` rather than failing outright; every anchor already
resolved is remembered, so resuming the job continues from where it left
off instead of re-verifying from scratch. A job the tool is still actively
verifying shows as `VERIFYING`.
### Cut the SDK over [#cut-the-sdk-over]
Once you're satisfied with the import, point your app at Rovenue: install
the SDK for your platform, call `configure()`, and call `identify(appUserId)`
with the same id your export used as `rc_original_app_user_id` /
`subscriberExternalId` so the SDK resolves to the subscriber your import
created. `restorePurchases()` re-validates any purchase the store still
knows about, which is a useful sanity check immediately after cutover.
## What doesn't survive the move [#what-doesnt-survive-the-move]
Rovenue's import is a best-effort history reconstruction, not a full data
migration, and no importer — in either direction — can promise otherwise.
Specifically, the following are **not** carried over:
* **Full event history.** The import writes purchases and revenue events
from the export's transaction rows; it does not reconstruct every
underlying lifecycle event (webhooks, state transitions) RevenueCat ever
recorded.
* **Original historical prices** where the export itself doesn't carry
them — the import only ever uses the price a row actually reports; it
never estimates or backfills one.
* **Full renewal chains** beyond what the export lists as individual rows.
If RevenueCat's export doesn't include a renewal, Rovenue has no way to
know it happened.
* **Promotional entitlements with no underlying store transaction**
import as a `MANUAL` grant with a deterministic id (so re-running the
import doesn't duplicate them) — but only when the export's `store`
column marks the row `promotional`. A grant made outside RevenueCat's
own exported data model has nothing to import from.
Uploaded export files are end-user PII and are deleted automatically after
a retention window once a job finishes. A job still actively resumable
(`VERIFICATION_INCOMPLETE` / `VERIFYING`) is never swept while it's in
that state — the sweep would strand it incomplete forever. A `FAILED` or
`CANCELLED` job, though also re-runnable from the mapping-editor step
without re-uploading, IS swept on the same clock as a finished job once
the window elapses; plan any mapping fix and retry within that window, or
re-upload the same file as a new job afterward.
# Using Rovenue with AI & LLMs (/docs/resources/using-with-ai)
Rovenue's documentation is structured so that LLMs and coding agents can consume it directly — no scraping required. Every page is available as raw markdown, and the whole site is indexed in a single flat file. Use these endpoints to give an AI assistant full context before asking it to write Rovenue integration code.
***
## LLM-ready endpoints [#llm-ready-endpoints]
The docs site exposes three machine-readable endpoints following the [llmstxt.org](https://llmstxt.org) convention:
| Endpoint | Contents |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [`/llms.txt`](/llms.txt) | A concise index of all docs pages — titles, paths, and one-line summaries. |
| [`/llms-full.txt`](/llms-full.txt) | The entire documentation as a single concatenated markdown file. Paste it or fetch it for full context. |
| `/llms.mdx/docs//content.md` | Any single page as raw markdown. Example: [`/llms.mdx/docs/guides/configuring/content.md`](/llms.mdx/docs/guides/configuring/content.md). |
These endpoints can be:
* **Pasted directly** into ChatGPT, Claude, or any other chat interface.
* **Fetched by an agent** using an HTTP GET — no authentication required.
* **Embedded** in a RAG pipeline as a continuously up-to-date source.
For quick questions about a specific area (e.g. purchases or identity), use the per-page endpoint (`/llms.mdx/docs//content.md`) to keep context small. For broad integration questions, use `/llms-full.txt`.
***
## Copy-paste context primer [#copy-paste-context-primer]
Paste the following block into any LLM before asking it to write Rovenue integration code. It covers the complete SDK surface in a compact form and links to the full docs for detail.
```text
# Rovenue SDK — context primer
## Package names
- React Native: @rovenue/react-native-sdk
- Swift: import Rovenue (package url: https://github.com/broverse/rovenue-ios)
- Kotlin: import dev.rovenue.sdk.Rovenue (artifact: io.rovenue:sdk:0.16.0)
## Configuration
- Public API key prefix: rov_pub_
- baseUrl: OPTIONAL — defaults to the hosted endpoint https://api.rovenue.io. Only pass it when self-hosting; the host must use https:// (plain http:// is accepted only for localhost in local dev).
- Call configure() once at app start, before any other SDK call.
RN: Rovenue.configure({ apiKey: 'rov_pub_...' })
Swift: Rovenue.configure(apiKey: "rov_pub_...")
Kotlin: Rovenue.configure(apiKey = "rov_pub_...", appVersion = ...)
- Self-hosting override:
RN: Rovenue.configure({ apiKey: 'rov_pub_...', baseUrl: 'https://api.acme.com' })
Swift: Rovenue.configure(apiKey: "rov_pub_...", baseUrl: "https://api.acme.com")
Kotlin: Rovenue.configure(apiKey = "rov_pub_...", baseUrl = "https://api.acme.com", appVersion = ...)
## Core methods (TypeScript names; Swift/Kotlin mirror with idiomatic casing)
configure(config) — initialise SDK (call once)
currentUser() — returns User { rovenueId, appUserId }
identify(appUserId) — bind your backend user id (optimistic local + background server bind)
logOut() — mint fresh rovenueId, clear appUserId and caches
entitlement(id) — Entitlement | null for a given entitlement id
entitlementsAll() — Entitlement[] all active entitlements
refreshEntitlements() — force network refresh of entitlements
virtualCurrencies() — Record — all virtual-currency balances (cache read)
virtualCurrency(code) — number — balance for a single currency code (0 if absent)
refreshVirtualCurrencies() — force network refresh of virtual-currency balances
getOfferings() — Offerings { current, all } — fetch products from dashboard
purchase(pkg) — Promise — native store sheet + server validation
restorePurchases() — re-validate all prior transactions (e.g. after reinstall)
getAppAccountToken() — opaque token for App Store fraud prevention
setForeground(bool) — signal foreground/background to manage polling cadence
shutdown() — stop all background work (call only at full app teardown)
setLogHandler(fn) — receive structured SDK log entries
addChangeListener(cb) — subscribe to ChangeEvent; returns unsubscribe fn (RN/TS)
Swift: Rovenue.shared.changes AsyncStream
Kotlin: Rovenue.shared.changes SharedFlow
## Spending virtual currency (SERVER-SIDE ONLY)
Spending a virtual currency is a server-side operation — there is NO client-side spend method.
From your backend: POST /v1/virtual-currencies/:appUserId/:code/transactions
Authorization: Bearer
Body: { "amount": -5, "idempotencyKey": "...", "description": "AI generation" }
Negative amount = debit; positive amount = currency grant.
## React Native hooks
useCurrentUser() — User | null
useEntitlement(id) — Entitlement | null
useEntitlements() — Entitlement[]
useVirtualCurrencies() — Record
useVirtualCurrency(code) — number (0 if absent)
## Key types
type RovenueConfig = { apiKey: string; baseUrl?: string; debug?: boolean; appVersion?: string }
type User = { rovenueId: string; appUserId: string | null }
type Entitlement = { id: string; isActive: boolean; productIdentifier: string; store: string; expiresIso?: string }
type Offerings = { current: Offering | null; all: Record }
type Offering = { identifier: string; isDefault: boolean; packages: Package[] }
type Package = { identifier: string; product: StoreProduct }
type StoreProduct = { id: string; type: ProductType; displayName: string; priceString: string | null; price: number | null; currencyCode: string | null }
type PurchaseResult = { entitlements: Entitlement[]; virtualCurrencies: Record; productId: string; storeTransactionId: string }
type ChangeEvent = 'EntitlementsChanged' | 'IdentityChanged' | 'VirtualCurrenciesChanged'
type ProductType = 'subscription' | 'consumable' | 'non_consumable'
## Identity model
- rovenueId: permanent per-install id (prefix rov_); always present, never changes.
- appUserId: your backend user id; null until identify() is called.
- identify() binds the anonymous rovenueId to your user and transfers any entitlements.
- logOut() mints a fresh anonymous rovenueId and clears all caches.
## Platform integrations
- iOS: SDK drives StoreKit 2; receipt chain-validated against Apple Root CA G3.
- Android: SDK drives Play Billing 6; receipt validated against Google Play Developer API.
- Server-side: all receipts validated by Rovenue backend — never trust the client.
## Full docs
https://docs.rovenue.io/llms-full.txt — complete documentation as a single markdown file.
```
***
## Tips for AI-assisted integration [#tips-for-ai-assisted-integration]
Keep these facts in mind when prompting an LLM to write Rovenue code:
* **Use an opaque `appUserId`.** Pass your backend's internal user ID — not an email address or any PII. The `appUserId` is sent to the Rovenue server and stored as-is.
* **Call `configure` exactly once.** Place it in `App.tsx` (React Native), `AppDelegate` (Swift), or `Application.onCreate` (Kotlin). It must run before any hook or SDK method.
* **Call `identify` after login.** Once your auth flow resolves with a user ID, call `identify(userId)`. This binds the current anonymous subscriber to your user and transfers their entitlements.
* **Call `logOut` on sign-out.** This mints a fresh anonymous `rovenueId` so the next user who logs in starts with a clean slate.
* **Handle `PurchaseCancelled` and `PurchasePending` gracefully.** These are not errors — they are normal user flows. Do not show an error dialog for either.
See [Identifying Users](/docs/guides/identifying-users) for the full identity model, including the server-side transfer flow. See [Processing Purchases](/docs/guides/processing-purchases) for the complete error type reference.
# Versioning (/docs/resources/versioning)
All three Rovenue SDKs — Swift (`Rovenue`), Kotlin (`io.rovenue:sdk`), and React Native (`@rovenue/react-native-sdk`) — are thin façades over a single Rust core library called `librovenue`. The native SDKs (Swift and Kotlin) version in lockstep with the core: their version number is the exact `librovenue` revision they embed. The current core line is `0.6.x`.
The React Native package is published separately on npm and may trail the native versions for a release or two while the JavaScript layer catches up — always install the latest from npm rather than pinning to match the native version.
Rovenue SDKs follow [Semantic Versioning](https://semver.org):
* **Patch** (`0.6.x`): bug fixes and internal improvements with no API change. Safe to adopt immediately.
* **Minor** (`0.x.0`): new, backwards-compatible features. Existing call sites continue to compile without modification.
* **Major** (`x.0.0`): breaking changes. Breaking changes are announced in the release notes for the corresponding GitHub release before they ship.
The native SDKs advance together, so an iOS app pinned to Swift `0.6.0` and an Android app on Kotlin `0.6.0` run the identical core behaviour.
There is no separate "migrating from Flows" guide: the Flow → Funnel rename is recorded in the BREAKING entries of each SDK's CHANGELOG (`packages/sdk-swift/CHANGELOG.md`, `packages/sdk-kotlin/CHANGELOG.md`, `packages/sdk-rn/CHANGELOG.md`).