Crossdeck Docs
Dashboard

Migrate to Crossdeck — move your customers without breaking a thing

Guide For products that already have paying customers · ~15 min read · Updated May 17, 2026

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

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.
The cut-over is data-driven, never a button you press.

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.

  1. 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.)
  2. Install the Crossdeck SDK. Add the SDK to your app and wire identify() so every user is linked from now on. (~10 minutes.)
  3. 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.)
  4. 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.)
  5. 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:

For most accounts this finishes in 2–5 minutes; large historical accounts take longer, and the dashboard's migration banner shows progress throughout.

What the backfill can and cannot know.

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)
Use one stable user ID everywhere.

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:

OutcomeWhat it means
matchedA rail key on the row resolved to a customer Crossdeck already had. Your user ID was attached to it. This is the common case.
createdNo rail record matched. A fresh customer was created from the row's data — e.g. a free user who has never paid.
conflictTwo 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.
errorThe row failed validation (e.g. missing developerUserId). Other rows in the same batch are unaffected.
The import is safe to re-run.

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.

The script uses your secret key — keep it server-side.

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:

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 showsWhat it means
Migration pendingCrossdeck found existing customers from the rail; you have not started the import yet. (Underlying signal status: pending.)
Migrating customersRows are arriving from your script. A live count tracks the unlinked customers down — “412 of 8,920 linked”. (Signal status: started.)
Review N conflictsThe 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 completeThe 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
}
On the server: warm the cache, then check.

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 keypro, 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 isProisEntitled("pro") swap is correct only if your product genuinely has exactly one tier and no trial.

Entitlements are per-customer; per-seat / quantity licensing is not supported yet.

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.

Dropping the column is destructive and one-way. Do not run it until the rest of Step 5 is provably done.

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.

Do not keep 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

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:

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


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.