Migrate to Crossdeck — move your customers without breaking a thing
You already run a product. You have real paying customers, a user table, and subscription state your app reads today. This guide moves all of that onto Crossdeck without a single customer losing access — a side-by-side handover where your own database stays the source of truth until a data-driven check proves Crossdeck can take over. Migration is safe, reversible at every step before the cut-over, and the import is safe to re-run. It is also the fastest hour of work you will do this quarter.
TL;DR
- Migration is five moves: connect your payment rail, install the SDK, run a one-time import script, verify, then point your app's entitlement checks at Crossdeck. Most of it is automated — the last move is the only real code change in your product.
- Your database stays in charge until the data agrees. Nothing “flips” on your say-so. Crossdeck becomes the source of truth only after a verification step counts your customers and proves every one is linked.
- No customer loses access at any point. Your system and Crossdeck run side by side through the entire migration. There is never a window where neither can answer “is this customer paying?”
- Connecting your rail backfills history automatically. The moment you connect Stripe, Crossdeck imports your historical customers, subscriptions, products, and revenue — read-only, in the background.
- The import script is generated for you in the dashboard, runs once from your backend, sends data to your branded
api.cross-deck.comendpoint, and is safe to re-run — it is idempotent and converges on the same result. - Skipping the script is allowed. If you never run it, each customer links themselves the next time they sign in, through
identify(). Migration just does in one afternoon what would otherwise happen gradually.
The handover — who is the source of truth
Migration is, at its heart, the handover of one job: answering the question “is this customer entitled to pro?” Today, your own database answers that question. After migration, Crossdeck does. Everything else in this guide is mechanics; this is the idea.
The handover happens in three phases, and crucially, the source of truth only moves once — cleanly, after the data has been checked.
| Phase | Source of truth | Your backend | Crossdeck |
|---|---|---|---|
| Before migration | Your database | Your user table and entitlement checks remain authoritative. Your app reads access from your own database, exactly as it does today. | Observing only. Rail webhooks arrive and create rail-keyed customer records, but they are not yet linked to your user IDs. |
| During migration | Your database (still) | You install the SDK and run the one-time import script. Both systems run side by side. | The import attaches your user IDs onto the customer records Crossdeck already holds. The dashboard shows live progress. |
| After migration | Crossdeck | Your backend can retire its subscription tracking. Your gating code becomes a thin isEntitled("pro") check. |
Canonical. New rail events update Crossdeck directly. Every entitlement decision is answered here. |
Crossdeck only becomes the source of truth when the dashboard's migration banner verifies that zero customers on your rail are still missing a user-ID link. The banner runs that check itself — it is not an API call you write. Until the unlinked count reaches zero, your database is still authoritative. There is no path that marks a rail “migrated” on your word alone — the kind of evidence a regulator can audit is a count that reached zero, not a flag someone flipped.
For the full identity model underneath this handover — how Crossdeck represents one human across web, iOS, Android, and Stripe; the append-only audit journal; how identity conflicts are resolved — see Identity verification. This guide stays on the practical path.
The migration in five moves
Here is the whole journey before we go deep on each step. Steps 1, 3, and 5 are where you spend time; steps 2 and 4 are quick.
- Connect your payment rail. One click connects Stripe. This also kicks off a silent, read-only backfill of your historical customers and revenue. (~8 minutes; backfill runs on its own afterwards.)
-
Install the Crossdeck SDK. Add the SDK to your app and wire
identify()so every user is linked from now on. (~10 minutes.) - Run the import script. The dashboard generates a one-time script. It reads your user table and links every existing customer in one pass. (Minutes for a small base; longer for very large ones — it runs unattended.)
- Verify and cut over. Open the dashboard — the migration banner verifies for you, confirming every customer is linked before the source-of-truth role moves to Crossdeck. (A check you watch, not a call you make.)
- Rewire your app. Point your app's entitlement checks at Crossdeck and remove the access flag from your own database. This is the one move with real code changes in your product. (A focused pass over your gate sites.)
You do not have to do these in a single sitting. Connect the rail today, install the SDK tomorrow, run the import when you are ready. Nothing your customers can see changes until step 5 — steps 1 through 4 set Crossdeck up behind the scenes.
Step 1 — Connect your payment rail
Crossdeck verifies revenue from the rail itself — Stripe, Apple App Store, or Google Play — not from numbers you type in. Connecting the rail is the first move because it is what gives Crossdeck the raw material: your real customers and their real subscription state.
Connecting Stripe
On the Payment rails page, click Connect with Stripe. You approve Crossdeck inside Stripe's own screen — there is no Account ID to copy, no webhook URL to paste, and Crossdeck never sees your raw secret key. The full mechanics are in Connect Stripe.
The moment the connection lands, a silent migration begins in the background:
- Discovery — a read-only scan walks your Stripe account: customers, subscriptions, products, prices, and recent invoices. Crossdeck never writes to your Stripe account.
- Backfill — every discovered record is projected into Crossdeck's data model. Each Stripe customer becomes a Crossdeck customer record; each subscription gets full state; historical revenue is reconstructed.
For most accounts this finishes in 2–5 minutes; large historical accounts take longer, and the dashboard's migration banner shows progress throughout.
The rail backfill knows everything Stripe knows — who pays, for what, since when. What it does not know is your user ID for each of those customers. A Stripe customer cus_… is not the same thing as the user row in your database. That link — from Stripe customer to your user — is exactly what step 3, the import script, draws.
Apple App Store and Google Play connect on the same principle: their server notifications flow into Crossdeck and create rail-keyed customer records, ready to be linked. Connect every rail your product bills on before running the import, so the script can link customers across all of them at once.
Step 2 — Install the SDK
The SDK is what keeps everything linked going forward. The import script (step 3) handles your existing customers in one batch; the SDK's identify() call handles every customer from now on — including anyone the import did not cover.
Install
Install the Crossdeck SDK for the surface your product ships on. Web and Node are available today; iOS and Android SDKs are rolling out.
# In your web app
npm install @cross-deck/web
# In your backend / server
npm install @cross-deck/node
Dependencies. The SDKs are deliberately light. The Node SDK needs Node 18 or newer (it uses the built-in fetch — no extra HTTP library). The web SDK has no runtime dependencies and also ships as a CDN <script> tag if you are not using a bundler. Neither SDK pulls a heavy dependency tree into your app.
Configure and identify
Initialise the SDK with your publishable key (cd_pub_…, safe for client code), then call identify() with your own stable user ID once your auth resolves the signed-in user:
import { Crossdeck } from "@cross-deck/web"
Crossdeck.init({ publicKey: "cd_pub_live_xxx" })
// The SAME user ID you will send in the import script.
if (session?.user?.id) await Crossdeck.identify(session.user.id)
The value you pass to identify() must be the same identifier you send as developerUserId in the import script — typically your Firebase Auth UID, Auth0 sub, or your own user-table primary key. It is the join that links a human to their payment record. If identify() and the import disagree, a customer can end up split across two records.
Full method references: Web SDK, Node SDK. For wiring identify() to a specific auth provider (Firebase, Supabase, Auth0, Clerk, NextAuth), see Identify users.
Step 3 — Run the import script
The import script is a one-time job that reads your user table and tells Crossdeck, for every existing customer, “this payment record belongs to this user of mine.” It is what links the rail-keyed records from step 1 to real people.
Get your script
You do not write this from scratch. Open the migration banner in the dashboard and click Migrate now — Crossdeck generates a script tailored to the rail you connected. You adapt one line (your own database query) and run it once from your backend.
The generated script is a small Node program. It reads your users, shapes each into a row, and sends them to Crossdeck in small batches:
// Run ONCE from your backend. Uses your Crossdeck SECRET key.
// Never run this from a browser. Requires Node 18+.
(async () => {
// Replace with your own user query.
const users = await yourDb.users.findAll()
const batch = users.map((row) => ({
developerUserId: row.id, // your stable user ID
email: row.email,
displayName: row.name,
stripeCustomerId: row.stripe_customer_id,
entitlements: { pro: row.plan === "pro" },
}))
// Posted in small batches to your branded endpoint:
// https://api.cross-deck.com/v1/migration/users
})()
Each row carries one required field — developerUserId, your stable user ID — plus whatever else you have: contact fields, the rail keys that identify the customer on each rail (stripeCustomerId, appleAppAccountToken, appleOriginalTransactionId, googlePurchaseToken, googleObfuscatedAccountId), free-form traits, and current entitlements to backfill plan state.
What each row does
The endpoint — POST /v1/migration/users, on your branded api.cross-deck.com domain — reports an outcome per row:
| Outcome | What it means |
|---|---|
matched | A rail key on the row resolved to a customer Crossdeck already had. Your user ID was attached to it. This is the common case. |
created | No rail record matched. A fresh customer was created from the row's data — e.g. a free user who has never paid. |
conflict | Two rail keys on the same row pointed at two different existing customers. Crossdeck refuses to auto-merge them and queues the case for your review in the dashboard. |
error | The row failed validation (e.g. missing developerUserId). Other rows in the same batch are unaffected. |
Every row is idempotent — keyed on your developerUserId. If the script crashes halfway through a five-million-row import, you re-run it from the start with no rollback and no cleanup. It re-attaches what is already attached, creates only what is genuinely new, and converges on the same end state. The script also sends data in small batches and retries transient network errors, so it runs to completion unattended regardless of how large your customer base is.
Migration is a backend-to-backend operation. It authenticates with your Crossdeck secret key (cd_sk_…), read from an environment variable — never hard-coded, never shipped to a browser. The endpoint rejects publishable keys outright. A leaked publishable key can never be used to mass-relink your customer base.
For the deep mechanics — how a row is resolved, how the append-only journal records every decision, how conflicts are detected — see the migration flow in Identity verification.
Step 4 — Verify and cut over
This is the moment the source of truth moves — and it is the safest step in the whole migration, because it is a check, not a switch.
Verification is the dashboard's job, not a call you write. The migration banner reads every customer on your chosen rail and counts how many are still missing a developerUserId link — subscription customers only; one-off payers and archived (merged) records are deliberately excluded from the count — and:
- If the count is zero — every paying customer is linked — the rail's migration signal is marked complete, stamped with the time and the operator who verified it.
- If the count is not zero — verification does not pass. The rail stays in its pre-migration state, your database is still the source of truth, and the dashboard shows you how many customers remain so you can run the import again.
The dashboard's migration banner tracks this for you. It is pinned to the top of every dashboard page until migration verifies complete, and runs as a small state machine:
| What the banner shows | What it means |
|---|---|
| Migration pending | Crossdeck found existing customers from the rail; you have not started the import yet. (Underlying signal status: pending.) |
| Migrating customers | Rows are arriving from your script. A live count tracks the unlinked customers down — “412 of 8,920 linked”. (Signal status: started.) |
| Review N conflicts | The remaining unlinked customers are identity conflicts. The banner surfaces a “Review N conflicts” action — resolve them under Settings → Identity → Conflicts before the count can reach zero. |
| Migration complete | The unlinked count reached zero. The rail's migration signal flips to completed; the banner flashes success and retires itself. |
Because verification refuses to pass until the data genuinely agrees, there is no version of step 4 that breaks a live customer. The worst case is “not yet” — and “not yet” simply leaves you exactly where you were, with your own database still in charge.
Step 5 — Rewire your app to read from Crossdeck
Steps 1 through 4 put Crossdeck in charge of the truth. Step 5 is where your app starts using it — the one step with real code changes in your product. Until now nothing your customers can see has changed; your app still reads access from your own database. Step 5 moves every one of those reads to Crossdeck. Do it once Step 4's verify has passed, so your app only ever depends on a source that has been proven complete.
The principle behind every change below is one interpreter, one truth: after migration, Crossdeck's rail is the only thing that interprets your payment provider. Your app reads the result — it never computes entitlement state itself, and it never keeps its own copy.
1. Read entitlements from Crossdeck
Find every place your app asks “is this customer on Pro?” — route guards, feature flags, paywalls, server middleware — and swap each one to a Crossdeck check. There are three forms; use whichever fits the call site.
// CLIENT — React or Vue. The hook re-renders the gate by itself
// when the entitlement cache updates — no manual cache handling.
import { useEntitlement } from "@cross-deck/web/react"
const isPro = useEntitlement("pro")
// CLIENT — plain JS. Synchronous, microsecond cache read.
// Warm the cache once, after identify() resolves.
await Crossdeck.getEntitlements()
if (Crossdeck.isEntitled("pro")) { showPro() }
// SERVER — Node. Warm, then check.
await crossdeck.getEntitlements({ userId })
if (crossdeck.isEntitled({ userId }, "pro")) {
// …serve the Pro response
}
isEntitled() is a synchronous read off a durable last-known-good cache. It does not expire to false on a timer — once a customer is warm, the cache keeps serving them through a Crossdeck outage rather than failing a paying customer down to free. But it must be warmed once: a customer the process has never fetched is a genuine cold miss and isEntitled() returns false for them. Always await getEntitlements() before the first check for a customer. The client useEntitlement hook handles this; a raw server route does not — wire the warm step explicitly. On serverless (Cloud Run / Lambda) every cold container is empty until warmed, so also wire an entitlementStore — see the Node SDK reference.
2. Move explicit grants to grantEntitlement()
First, map your real access model — it is almost never one boolean. A users.isPro column reads like the whole story, but most products have more access shape than that: a free / pro / team ladder, a 14-day trial, an ai_addon bought on top. Crossdeck models each of those as its own entitlement key — pro, team, ai_addon — and a customer holds the set of keys their subscriptions and grants give them. Time-boxed trials are first-class: every entitlement carries its own validUntil, so a trial expires on its own without a cron job flipping a flag. Before you migrate grants, write down every distinct access state your isPro (or equivalent) column actually encoded, and define a CD Entitlement for each. A one-to-one isPro → isEntitled("pro") swap is correct only if your product genuinely has exactly one tier and no trial.
If your old schema tracked a seat count — seats_purchased, licenses, a per-org seat table — Crossdeck cannot mirror that today. An entitlement is granted or revoked for the whole customer record; there is no seat or quantity field on an entitlement, a resolved entitlement, or a subscription. A 5-seat plan grants the team entitlement to the customer holding the subscription, not five assignable seats. Until per-seat licensing ships, model each seat-holder as their own Crossdeck customer. Plan for this before cutover — it is a known gap, not a setting you've missed.
With the model mapped, the grants themselves are the one real logic change in this migration. Reads are a swap; grants are not. Anywhere your code granted access by writing your own flag — a coupon redemption, a comp account, a goodwill credit from support — it must now make an explicit, audited grant on the Crossdeck customer:
// Coupon redeem.
// BEFORE: await db.users.update(id, { isPro: true })
// AFTER: an explicit, audited grant — naming the exact entitlement.
await crossdeck.grantEntitlement({
customerId, // the Crossdeck customer
entitlementKey: "pro", // the specific tier / capability
reason: "Coupon LAUNCH50 redeemed",
})
This is deliberate, not incidental. In Crossdeck a one-off purchase or a coupon never auto-grants an entitlement — your server grants explicitly, with a reason, so revenue and access stay independently auditable. A manual grant outranks every rail projection and survives until you explicitly revoke it. grantEntitlement() takes the Crossdeck customer ID; for resolving that from your own user ID, and for the full signature, see Entitlements & gating and the Node SDK reference.
3. Stop interpreting the rail in your own code
If your backend runs its own Stripe (or Apple, or Google) webhook that writes subscription or entitlement state, remove those writes. After migration there is exactly one interpreter of the payment provider — Crossdeck's rail — and a second one in your codebase is the precise “muddiness” that single-source removes: two systems forming an opinion about the same Stripe event.
You may keep a webhook for work Crossdeck does not do for you — sending your customer a “your payment failed” email, say. That is fine: it triggers a notification, it does not decide access. The rule is narrow and absolute — nothing in your code writes entitlement truth except a deliberate grantEntitlement() call.
4. Delete your old access flag
Once every read goes through isEntitled() / useEntitlement(), delete the isPro column — or whatever your access flag was called — from your database. This is the last step, and it is the one you cannot quietly undo: dropping a column destroys the data in it. If a read path you missed still depends on that column, you find out in production, and the original per-customer values are gone.
Before the DROP COLUMN, all of the following must hold — not "mostly," all:
- A codebase-wide search for the column name returns zero live reads. Grep every service, job, and query layer.
- The new
isEntitled()path has run in production long enough to trust — not a deploy-and-drop in the same change. - You have a dated export of the column's values (customer ID → flag) kept somewhere safe, so a missed read path is a recovery, not a data-loss incident.
Ship the deletion as its own change, separate from the cutover. If you are not certain, stop the column being written first and leave the dormant data in place for a release before you drop it.
isPro as a “Crossdeck-synced cache.”
It is tempting to keep the field and have something write it from Crossdeck. Don't. The SDK's entitlement cache already is that projection — durable last-known-good, warmed from Crossdeck, read in microseconds, and resilient to a Crossdeck outage. A second copy in your own table is a second thing that can drift — the exact problem this migration exists to remove. Your database stops storing entitlement state; the SDK is your read path. One projection, kept by the SDK.
You have finished Step 5 when
- No code path reads an access flag from your own database.
- Every gate — client and server — calls
isEntitled()oruseEntitlement(), and every server check warms the cache first. - Coupons, comps, and goodwill credits call
grantEntitlement(). - No webhook or job in your code writes entitlement or subscription state.
- The old access flag is dropped from your schema.
After migration
With Step 5 complete, Crossdeck is the source of truth and your product is simpler — here is the steady state you now operate in:
- Access is one call. Every gate is an
isEntitled("pro")/useEntitlement("pro")read off the SDK cache. Your own database no longer stores or computes entitlement state — Step 5 removed it. - Revenue events — renewals, refunds, plan changes, churn — arrive at Crossdeck directly from the rail, signature-verified, and update the customer record without round-tripping through your backend.
- Cross-rail unification is automatic. A customer who paid on Stripe last year and re-subscribes on Apple this year resolves to the same person — through the user-ID link you populated during the import. Their lifetime view stays whole.
- The audit journal keeps an append-only record of every identity and entitlement decision, indefinitely. Nothing is overwritten; history is always reconstructable.
Your migration is not a bridge you burn. Historical data stays intact, and the import endpoint remains available — if you onboard a batch of customers from an acquisition later, you run the same script again.
Specific situations
Very large customer bases (100k+)
The import is built for any scale. It sends customers in small batches and processes them one at a time for a correct audit trail, so a large migration simply runs longer — unattended — rather than failing. Expect roughly (number of customers × 0.3s); a 30,000-customer base is about 2–3 hours. It will not time out, and if it is interrupted you re-run it and it resumes cleanly.
Identity conflicts
Occasionally one row's rail keys point at two different existing customers — usually a data artefact from before you used Crossdeck. Crossdeck never auto-merges identities. The conflict is queued in the dashboard with both records shown side by side; you choose the canonical one. Auto-merge is refused on purpose: identity merges must be explicit, auditable, and reversible. See Conflicts.
You would rather not run the script at all
That is a supported choice. If you skip the import, no customer loses access — provided your app calls Crossdeck.identify() with your user ID. The first time each paying customer signs in after the SDK is installed, Crossdeck links their rail record to them on the spot. The import does in one afternoon what identify() does gradually as customers return. Same destination; different pace.
Multiple payment rails
If you bill on Stripe and Apple and Google, connect all of them before running the import. A single import row can carry the keys for every rail a customer uses, so one pass links them everywhere and their cross-rail history unifies under one record.
Test it in sandbox first
Crossdeck keeps sandbox and production completely separate. Connect Stripe in test mode to a sandbox project and run the import against test data to rehearse the whole flow — no risk to production metrics — before doing it for real.
Troubleshooting
“The import script returns 401 / authentication errors”
The script must use your secret key (cd_sk_…), not a publishable key, and the key must match the environment you are migrating (a test key for a sandbox project, a live key for production). Read it from an environment variable; the generated script expects CROSSDECK_SECRET_KEY.
“Connected the rail, but no customers appear”
The silent backfill is still running. Watch the migration banner — it shows the current phase and live counts. Typical wait is 2–5 minutes; large accounts take longer. If it has not moved for more than 15 minutes, re-trigger it with Run backfill on the Rails page (it is idempotent).
“Verify will not pass”
That is verification doing its job — some customers on the rail are not yet linked. The dashboard shows the remaining count. Either run the import script again (it is safe to re-run), or, if the remaining records are conflicts, resolve them under Settings → Identity → Conflicts first. The banner clears the moment the unlinked count is genuinely zero.
“The script was interrupted”
Re-run it from the start. There is no rollback step and no cleanup. The import re-attaches what is already linked and processes only what is left, converging on the same result.
What's next
- Connect Stripe — the one-click OAuth flow and the silent backfill, in full detail.
- Identity verification — the identity model under migration: customer records, the append-only journal, conflict resolution.
- Web SDK and Node SDK — install references and every method.
- Identify users — wiring
identify()to Firebase, Supabase, Auth0, Clerk, or NextAuth. - Entitlements & gating — what to do with the entitlements migration projects onto your customers.
Migration is a side-by-side handover: your database stays the source of truth until the dashboard verifies every customer is linked. Need a hand? [email protected] — we help developers verify rail credentials, run the import, and confirm the cut-over.