Migrating from RevenueCat
Concept mapping and step-by-step history import from RevenueCat to Rovenue.
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
| 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
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.
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
- 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). CalllogOut()on sign-out to mint a freshrovenueId. 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
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
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 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
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
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
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
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
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
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
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
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
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
MANUALgrant with a deterministic id (so re-running the import doesn't duplicate them) — but only when the export'sstorecolumn marks the rowpromotional. 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.