Migrating your subscribers to SubSovereign
Moving from RevenueCat, Adapty, Qonversion or your own backend? Your existing subscribers come with you. This guide covers the import itself and — just as importantly — the cutover, which is the part that actually carries risk.
Read this first. The import is safe and repeatable. The cutover is the moment that needs planning, because until you repoint your store notifications at us, renewals are still being delivered to your old provider. Plan §4 before you run §2.
The two halves of a migration — read this before you plan one
Every migration has two mechanisms, and telling them apart is the difference between a smooth move and a promise you cannot keep.
Client-side re-validation is the GUARANTEE. Once your app ships our SDK, each subscriber's device re-sends its live receipt the next time they open the app. The identifier comes from Apple or Google themselves — current, correct and complete. It works for every store, needs no export from anyone, and cannot carry a stale identifier. Every subscriber who opens your app is migrated correctly, by construction.
The import is the ACCELERATOR. It exists so your subscribers are not locked out before they next open the app, and so your day-one reporting is not blank. It copies what your previous provider knew.
Why both: subscribers open apps at wildly different rates. Someone who does not launch your app for six weeks would, with re-validation alone, spend six weeks looking unentitled. The import gives them continuity from the first minute; re-validation makes it authoritative.
🚩 If you can only have one, keep re-validation. An import that cannot supply a matchable identifier for some store (see below) is not a migration for those subscribers — it is a record that expires quietly.
What your previous provider can actually give you
The import needs originalTransactionId, and what your provider hands over varies by store. Check yours
before you plan around it:
| Provider | Apple | |
|---|---|---|
| RevenueCat | ✅ The data export carries original_transaction_id. |
⚠️ The export gives an Order ID, which is not the purchase token — a different identifier that a renewal notification will not match on. Their v2 API is worse: it exposes only the latest transaction id, which changes at every renewal. |
| Adapty / Qonversion | Check the export for Apple's original transaction ID before planning. | Check whether you get the purchase token or an order/transaction reference. Confirm it, do not assume it. |
| Superwall | ⚠️ Confirm before planning. They run their own entitlements, purchase and receipt-validation layer, so the identifier exists on their side — the open question is whether the export hands you Apple's original_transaction_id or only Superwall's own subscription reference. |
⚠️ Same question, same answer: establish whether you get the purchase token or an order/transaction reference, in writing, before you plan around it. |
| Your own backend | Whatever you stored at purchase — usually the right thing. | You most likely hold the purchase token already, because you needed it to validate. |
| Stripe | n/a | n/a — we read your Stripe account directly, no export needed. See §3b. |
🚩 So a RevenueCat move imports cleanly for Apple and cannot import Google subscribers with a matchable identifier. That is not something we can code around: the identifier we need is not present in the data you are able to obtain. Those subscribers come across by client-side re-validation — ship the SDK, repoint the notifications (§4), and each one is migrated properly the next time they open the app. Import the Apple side for continuity, and let Google heal itself.
1. What you need
- Your subscriber export, containing one row per active entitlement (see the format below).
- A product map. Every product ID in your export must be mapped to one of your access levels first — Dashboard → your app → products. 🚩 The import will not guess which tier to grant. An unmapped product is refused and reported, because guessing would be a way around the same protection that stops a customer claiming a tier they did not buy.
- An admin login for the org that owns the app. Viewers cannot import: it grants paid access in bulk.
2. The format
One JSON object per subscriber entitlement:
{
"userId": "user_12345",
"email": "person@example.com",
"store": "apple",
"productId": "pro_monthly",
"originalTransactionId": "1000000987654321",
"expiresAt": "2027-01-01T00:00:00Z",
"willRenew": true,
"status": "active"
}
| Field | Required | Notes |
|---|---|---|
userId |
yes | Your own user identifier — the same one your app passes to our SDK. |
store |
yes | apple · google · amazon · roku · stripe |
productId |
yes | Must exist in your product map, or the row is refused. |
originalTransactionId |
yes | 🚩 See below. Apple's original transaction ID, Google's purchase token, or the provider's subscription ID. |
status |
yes | active · trialing · grace_period · expired · lifetime · cancelled · refunded · billing_failed |
expiresAt |
no | ISO 8601. Omit for lifetime. |
amazonUserId |
for Amazon | 🚩 Amazon's own user id, from their SDK — not your app user id. Amazon looks a receipt up by (that id, receiptId), so without it the entitlement could never be re-verified. Amazon rows without it are refused. |
email, willRenew |
no |
🚩 originalTransactionId is the field that decides whether this works
It is not just a key for us — it is how a renewal finds the subscriber months later. When Apple or Google sends a server notification for a renewal, that identifier is what it matches on.
A row imported without it produces the worst kind of failure: everything looks correct on day one, and then the subscriber silently loses access at their next renewal, with no error raised anywhere.
So we refuse those rows rather than accept them. Importing without it is not a shortcut, it is a delayed outage.
🚩 Not every provider can give you this field for every store — see the table above. Where your export genuinely cannot supply it (RevenueCat + Google is the known case), do not work around it by inventing a value: any placeholder produces exactly the silent renewal failure described here. Import the stores you have real identifiers for, and let client-side re-validation carry the rest.
3. Run it — dry run first, always
This section is the CSV / export route, used for Apple, Google, Amazon and Roku. For Stripe, skip to §3b — we read your account directly and there is nothing to build.
The easiest way is in the dashboard: Subscriber Migration (Insights → Customers) → From an export. Paste the export as CSV or JSON, or load the file. It checks the rows first and writes nothing until you ask; it batches large files for you; and it lists every refused row by row number, so you can fix them in the file you actually have. The API below is the same thing if you would rather script it.
🚩 These rows are a claim, not a confirmation. An export is whatever your previous provider handed you, so imported rows land unverified and join the re-check queue (§6), where we confirm each one against the real store afterwards. Rows read directly from Stripe (§3b) are different — they arrive already confirmed.
A dry run is the default. You have to ask explicitly to write anything.
curl -X POST https://api.miimagineai.com/api/v1/apps/YOUR_APP_ID/import/subscribers \
-H "Authorization: Bearer $DASHBOARD_TOKEN" -H 'Content-Type: application/json' \
-d '{ "source": "revenuecat", "rows": [ … up to 1000 rows … ] }'
You get a report back:
{
"dryRun": true, "received": 1000, "accepted": 987, "newUsers": 964,
"missingTransactionIds": 6,
"unmappedProducts": ["legacy_annual_v1"],
"rejected": [ { "index": 12, "code": "unmapped_product", "reason": "…" } ]
}
Read these three numbers before you commit:
missingTransactionIds— rows that would renew into nothing. Fix the export, do not proceed.unmappedProducts— map them, then re-run.newUsers— how many subscribers this will create. If it looks wrong, youruserIdfield is probably the wrong identifier.
When the report is clean, add "dryRun": false and send it again.
Batching and restarting
Send at most 1000 rows per request; a large migration is many batches. The import is idempotent — re-sending the same batch updates rather than duplicating, so a script that dies half way can simply be run again from the start. A bad row is reported and skipped; it never discards the good rows beside it.
Things it will refuse, and why
| Code | Meaning |
|---|---|
missing_transaction_id |
See above. The important one. |
unmapped_product |
Map the product first; we will not guess a tier. |
transaction_owned_by_another_user |
That store transaction already belongs to a different account in this app. One transaction, one owner — usually a merged account or bad data in the export. |
invalid_store / invalid_status / invalid_expiry |
Malformed value. |
3b. Stripe — we read your account directly
Stripe is the one store that can answer "who are all my subscribers?". Apple, Google, Amazon and Roku only answer "is this receipt still good?", one receipt at a time. So for Stripe there is no export to request and no CSV to build: Subscriber Migration in the dashboard (Insights → Customers) walks your account and imports it.
Before you start, two things must be in place, and the page tells you if they are not:
- Your Stripe secret key, stored under Apps → store credentials. There is no platform fallback and there never will be — without your key the import fails closed rather than reading somebody else's data.
- Your products mapped to tiers. Map either the Price (
price_…) or the Product (prod_…); both work. Unmapped products are refused rather than guessed, which is correct behaviour and not a bug.
Then tell us where your app's user id lives in Stripe — subscription metadata, customer metadata, or the
Stripe customer id itself. 🚩 There is no default and we will not guess. Stripe knows the customer as
cus_…; only you know which of your users that is, and guessing wrong does not fail loudly — it grants one
subscriber's paid access to somebody else's account.
Preview first. The preview changes nothing and shows you exactly what would happen: how many subscriptions were read, how many would import, and every one that would be refused with the reason in plain English. Read the refusals before you import — they are the list of things to fix, not noise.
Large accounts are walked over several requests automatically; the page paces itself to stay inside the rate limit and shows progress as it goes.
What it refuses, and why these are the right answers
| Code | Meaning |
|---|---|
never_paid |
The subscription is incomplete — it never completed a first payment. Importing it would grant paid access to someone who has never paid. |
access_expired |
The status grants access but the billing period has already ended. Access here is decided by status, so importing it would grant access indefinitely. |
ambiguous_product |
Items on one subscription map to two different tiers. We refuse rather than pick — choosing the higher would be tier escalation arriving by the migration route, and choosing the first would be arbitrary. |
no_user_id |
No app user id where you told us to look. See above: guessing this is the dangerous one. |
unmapped_product |
Map the product, then re-run. |
🚩 Products seen but not mapped are reported even when nothing was blocked by them. They are not blocking today, but the moment you map one, any subscription carrying it alongside another mapped product becomes ambiguous and starts being refused. Better to know now than on the second run.
Why Stripe rows arrive already verified
An imported row normally counts as a claim until we confirm it with the store (§6). Stripe rows are different: they were read from Stripe's own API using your credentials, so they land already store-confirmed. That is a statement about provenance, not about Stripe — any future processor we read directly will behave the same way, and any row a human supplied stays a claim regardless of which store it names.
Prefer the API? The page is a client for POST /apps/:appId/import/stripe; the same options are
dryRun, userIdSource, userIdKey and startingAfter for paging.
4. 🚩 The cutover — plan this before you import
The import copies state. It does not redirect the future. Until you repoint your store server notifications at SubSovereign, renewals, cancellations and refunds are still going to your old provider.
The recommended sequence:
- Import (this guide). Your entitlements now exist in both systems.
- Run in parallel. Keep the old provider live and compare. Nothing has moved yet.
- Ship an app build using our SDK. Entitlements resolve from us, and because you imported the same transaction identifiers, existing subscribers keep their access with no action on their part.
- Repoint store notifications to our webhook endpoints (Apple App Store Server Notifications, Google Real-Time Developer Notifications). This is the actual switch.
- Re-run the import for anything that changed during the window. It is idempotent — that is what this step is for.
- Retire the old provider once a full renewal cycle has passed cleanly.
5. What an imported entitlement is — and is not
An imported entitlement is a grant of paid access made on your previous provider's say-so. We have not yet checked it against Apple or Google ourselves.
We record that honestly rather than hiding it: imported rows carry imported_at and import_source, and
their store_verified_at stays empty until the entitlement has been confirmed against the real store.
So "which of these did we grant on trust?" is a question with an exact answer, at any time.
🚩 This matters if a figure ever looks wrong. Imported and verified entitlements are not the same class of fact, and an audit that treated them as identical would be misleading.
6. Re-verifying an import against the store
Once imported, you can have us check each entitlement against the real store:
curl -X POST https://api.miimagineai.com/api/v1/apps/YOUR_APP_ID/import/verify \
-H "Authorization: Bearer $DASHBOARD_TOKEN" -H 'Content-Type: application/json' \
-d '{ "limit": 50 }'
Each row comes back as one of three outcomes:
| Outcome | Meaning |
|---|---|
| verified | The store confirmed it. We adopt the store's expiry and renewal flag over the imported ones — it is the authority. |
| mismatch | The store contradicted the import: no active subscription, or an active one for a different product. |
| unverifiable | We could not get a usable answer — a rate limit, a credential problem, a transient error. This is not a contradiction. |
🚩 A mismatch never revokes access. Nothing about the entitlement changes; only the verification columns
are written, and the mismatch is surfaced for a person to judge. "The store did not confirm it" and "this
subscriber is not entitled" are different statements, and a store API can return the first for reasons that
have nothing to do with your customer. We are not prepared to cancel a paying subscriber on that basis —
particularly one who has just migrated to us. See DECISIONS.md #70.
Run it in batches (default 50, max 200) — store APIs are rate-limited and these calls use your store
credentials. GET /apps/YOUR_APP_ID/import/status shows the standing picture:
{ "imported": 4820, "verified": 4776, "awaiting": 44, "mismatch": 11, "unverifiable": 33 }
There is no automatic schedule for this yet, on purpose — we would rather not call your store credentials unattended on a timer you did not ask for. Run it when it suits you; it is idempotent and restartable.