Crossdeck Docs
Dashboard

How Crossdeck verifies and identifies users of your app

Architecture Audience: senior engineer / CTO · ~18 min read · Updated May 15, 2026

Putting Crossdeck behind your paywall means trusting a third party with the single most consequential question your app asks at runtime: which customer is this, and what are they allowed to do? This document is the architecture answer. It describes the identity model the platform is built around, the append-only audit chain that records every decision, the secret-key boundary that protects the customer base from accidental relinking, and the bank-grade migration contract that lets you hand over source-of-truth from your own backend without losing a single paying customer. The reader is a senior engineer or a CTO; the goal is to leave you confident that what runs underneath is the kind of plumbing a regulated bank would ship.

TL;DR

If you read nothing else.

Crossdeck assigns every paying human a single canonical cdcust_… that spans every payment rail you connect (Stripe, Apple, Google) and joins to your existing auth system through a developer-supplied user ID. Every identity decision — mint, attach, merge, rail-link — lands on an append-only, hash-chained journal scoped to one project. Mass relink is gated by secret keys; inbound rail signals are HMAC-verified; auto-merge of conflicts is refused. The migration path lets your backend hand over source-of-truth atomically and only when a data-driven verification step confirms zero unlinked customers. The result: one cross-rail lifetime view per human, provable history per decision, and a cut-over that is reversible until you're ready to commit.

The model — one customer, three identifiers

The shape of Crossdeck's identity surface is small and deliberate. Three identifiers exist; only one is canonical.

IdentifierOriginRoleCanonical?
cdcust_… Crossdeck-minted The single ID for one human inside one of your projects. Every entitlement, every event, every rail subscription points at one of these. Yes — this is the only canonical handle.
developerUserId Your authentication system Your foreign key into Crossdeck. Typically a Firebase UID, an Auth0 sub, a Supabase user ID, or your own database primary key. Stable across devices. No — a hint that resolves to one cdcust_….
anonymousId SDK-generated The pre-authentication device handle. Minted on first SDK install, persisted across reloads. Identifies the device, not the human. No — a hint that resolves to a cdcust_… once a human attaches.

The identity graph is the bookkeeping that links these signals to the canonical record. When the SDK calls identify(userId), the graph attaches the device's anonymous trail to the human's cdcust. When Stripe fires a customer.created webhook, the graph mints a new cdcust (or finds the existing one by the user's email or developer-supplied metadata) and attaches the Stripe customer ID to it. When the developer's backend POSTs a migration row, the graph reconciles the developer's stable user ID against rail-keyed customers already minted from past Stripe activity.

Most developers never have to think about the cdcust_… handle directly. The SDK persists it on the device after the first identify() resolves and uses it for subsequent calls; your server-side code references customers by your own developerUserId and never has to learn Crossdeck's. The identifier exists primarily as the durable spine that every other claim hangs off of.

Why three identifiers, not one.

Real apps see users before auth (the anonymous device), at auth (your stable user ID), and through payment (Stripe customer ID, Apple appAccountToken, Google obfuscatedAccountId). A platform that demands one ID up-front loses everything that happened before. The graph is the price of capturing the full lifetime.

How an identity decision is made

Every customer-touching request — an entitlement check, an event ingest, a webhook delivery, a migration row — passes through the identity resolver. The resolver answers a single question: which cdcust_… does this signal point at?

The resolver is read-only by design. Creating customers is the privileged path; resolving them is the hot path. The strict priority order is the same in every call site:

  1. Direct cdcust_… lookup. If the caller passes a Crossdeck customer ID, the resolver verifies that the ID exists inside the calling project (a leaked ID from another tenant returns nothing) and follows any merge chain to land on the live, canonical record.
  2. Developer-supplied user ID. If the caller passes a developerUserId, the resolver looks it up in the project's customer index. Hit → return the linked cdcust. Miss → fall through.
  3. Rail key. Stripe customer ID, Apple appAccountToken, Apple originalTransactionId, Google purchaseToken, Google obfuscatedAccountId. Each is its own indexed key; if any of them was minted by an earlier rail event, the resolver lands on that customer.
  4. Anonymous device ID. The SDK's pre-auth device handle. Used to stitch anonymous traffic onto a human when the human attaches.
  5. Fresh mint. No prior signal matched. A new cdcust_… is minted, seeded with whatever hints the caller supplied, and the relevant index entries are written so the next call resolves to it.

The order is load-bearing. A direct cdcust reference is the most authoritative claim a caller can make and always wins. Below it, developer-asserted identity outranks rail-derived identity (rails are noisy: the same human can have two Stripe customers from two checkout sessions), and rail-derived identity outranks device-only identity (devices change hands; payment instruments are tied to the person paying).

Every resolution that lands on an existing customer follows the merge chain before returning. If an earlier operator merge redirected the original customer into a winner, the resolver transitively walks the pointer until it hits a live, non-archived record. This is the safety property that closes the worst failure mode in the platform: a paying customer locked out of features they paid for because the entitlement check resolved to an archived ID. Bounded at eight hops with a cycle guard — eight covers more than 99.9% of real merge chains in production data, and the guard prevents infinite traversal on malformed pointers.

The identity journal — every decision, append-only

The identity journal is the platform's audit spine. Every identity decision Crossdeck makes — every mint, every attach, every merge, every conflict — lands as one entry in an append-only, hash-chained, sequence-numbered log scoped to a single project and environment.

The journal exists because bank-grade systems require provable history. Compliance, forensics, customer disputes, and post-incident review all eventually ask the same question: at this moment in the past, why did the system believe this human was that customer? Without a journal, the answer is whatever the current state happens to be — which may have been overwritten by a later decision and is fundamentally untrustworthy. With a journal, the answer is a row you can point at and a hash chain that proves nobody edited the row after it was written.

The journal records a small, fixed vocabulary of decision kinds. New kinds are additive — older entries remain valid because the chain verifier checks hash continuity, not enum coverage.

DecisionWhen it fires
create_customerA new cdcust_… was minted.
attach_user_to_anonAn anonymous device record was promoted by attaching a developerUserId.
attach_anon_to_userAn existing user record was extended by attaching a new device handle.
already_linkedBoth identifiers in the call already pointed at the same customer — no-op, journaled for traceability.
merge_pendingTwo identifiers resolved to two different customers; the conflict was queued for human review.
merge_executedTwo customers were merged after explicit operator review. Carries a typed operator rationale.
rail_customer_createdA payment rail webhook (Stripe / Apple / Google) created a customer.
rail_attachedA payment rail was attached to an existing customer (e.g. the human added a card).
profile_updatedProfile fields changed on an existing customer.
customer_standalone_acknowledgedAn operator acknowledged that a rail-only customer has no corresponding application user (a Stripe one-off payer, a long-churned account). Excluded from migration counts thereafter.
erasure_executedA GDPR / CCPA erasure ran; PII was anonymised.
migration_linkA migration row attached a developerUserId to a rail-keyed customer minted from earlier rail discovery.
migration_conflictA migration row's rail keys resolved to two or more different customers. Auto-merge refused; queued for review.

Each entry carries the provenance of the identity claim that drove it. The evidence kind is a small enum — firebase_id_token, auth0_jwt, stripe_webhook_signed, apple_jws_verified, google_pubsub_verified, dashboard_oauth, internal_admin, self_asserted — and anything stronger than self-asserted is cryptographically verifiable after the fact. The dashboard's audit-log view lets compliance filter by evidence kind to surface the population of weak-evidence decisions for review.

The journal is reversible — nothing is ever destructively overwritten.

Merges and erasures land as new journal entries. To undo a merge, you append an unmerge entry. The customer record's state at any historical moment can be replayed from the journal. There is no "DELETE FROM" path; there is no editor; there is no admin tool that bypasses the chain. Every mutation is a journal entry.

The journal write happens inside the same database transaction as the customer mutation it audits. If the journal write fails, the customer mutation rolls back. There is no path that mutates identity without an audit entry — that is the platform's contract, and it is enforced by the transactional boundary, not by code review.

The dashboard surfaces a filtered, human-readable view of this journal under Audit log. Operators can scroll the chain, filter by decision kind, filter by evidence kind, and pin any entry for forensic reference.

Conflicts — why auto-merge is refused

The most consequential thing the platform does not do is silently merge customers when signals disagree. The architectural rule is the one quoted across the code as the platform's identity invariant:

Identity merges are explicit, auditable, reversible. Prefer under-linking to incorrect linking.

A conflict happens when two pieces of evidence about the same human point at two different cdcust_… records. Concretely: a developer's migration row carries both a Stripe customer ID and an Apple appAccountToken, but those two rail keys were earlier minted as two distinct customers — perhaps because the human paid on iOS under one email, then re-subscribed on web under another. The migration row asserts they are the same human; the existing graph disagrees.

Two choices are available:

Crossdeck always picks the second. Conflicts surface as a count in the dashboard's migration progress card with a Resolve N conflicts → CTA pointing at Settings → Identity. Each conflict shows the competing customers, the resolutions for each rail key, and the developer-supplied developerUserId that triggered the case. The operator chooses which customer is canonical and which gets merged in; the result lands on the journal as an explicit merge_executed decision with operator attribution. Merges require a typed rationale of at least twenty characters — short enough not to be annoying, long enough that the audit record is meaningful.

The same refusal logic applies to the POST /v1/identity/alias endpoint the SDK uses when wiring identify(): if both a developer user ID and an anonymous device ID already point at different customers, the call returns the developer-asserted customer as canonical, journals a merge_pending decision, and surfaces the case in the dashboard. The SDK gets a deterministic answer; the platform retains the option to correct it later.

It is worse to be silently wrong than to require a human glance.

This is the single sentence summary of the model. A queued conflict costs an operator thirty seconds. An auto-merge that turns out to be wrong costs you a customer-trust incident, an unbounded number of downstream incorrect actions, and a compliance footnote you can't erase.

Multi-tenant isolation

Every cdcust_… is scoped to a single project. The same identifier in a different project resolves to nothing — there is no global customer table.

Project scoping is enforced at the resolver, not at a downstream guard. A direct cdcust lookup verifies that the record exists inside the calling project before returning it; a leaked ID from project A returns null if presented inside project B. The customer index, the identity journal, the conflicts queue, the access log — every per-customer data structure lives under projects/{projectId}/… and is unreachable from a different project's API key.

The practical implication: if one of your developers leaks a customer ID into a public bug report, the worst case is that the leaked ID is recognisable inside your own project. It is not a key, it does not authenticate anything, and it has no meaning outside the project that minted it.

The secret-key boundary

Crossdeck issues two kinds of API keys per project, and they live at very different points on the risk spectrum.

KeyPrefixCan doCannot do
Publishable cd_pub_… Fire events; check the current device's entitlements; resolve a customer by its public hints. Mutate identity at scale. Alias a customer. Run migration. Grant or revoke entitlements. Read another customer's records.
Secret cd_sk_… Everything the publishable key can do, plus mass-mutate identity: alias, migration, manual grants, conflict resolution, customer merges. Pretend to be a different project. Bypass HMAC checks on inbound rail webhooks.

The boundary is enforced at the request handler, before any business logic runs. The migration endpoint, for example, rejects a publishable key with a clear error message that explains why: "Migration requires a secret key (cd_sk_*). Publishable keys cannot mass-relink customer records — call this endpoint from your backend, not from a browser or mobile app."

The reasoning behind the boundary is operational, not theoretical:

Migration is a backend-to-backend call by design.

There is no SDK code path that calls the migration endpoint. The SDK only ever sees the publishable key; the publishable key only ever sees per-device scope. Mass-relink lives on your server, behind your secrets, where it belongs.

Source-of-truth handover during migration

If you are evaluating Crossdeck while operating an existing application with its own user table, its own subscription state, and its own entitlement gates, the question that matters most is: how do I hand source-of-truth to Crossdeck without breaking my live customers?

The answer is a three-phase handover that runs your existing system and Crossdeck in parallel until verification proves they agree, then transitions canonically in a single atomic step. At no point in the transition is there a window where neither system can answer a customer's entitlement check.

Phase Source of truth What runs in your backend What Crossdeck does
Pre-migration Your backend Your existing user table, subscription state, and entitlement gates remain authoritative. Your app reads entitlements from your own database, as it always has. Observing only. Rail webhooks (Stripe, Apple, Google) flow into Crossdeck and mint rail-keyed customers, but the link to your developerUserIds hasn't been drawn yet.
Migration window Your backend (still authoritative) You install a one-time script that reads your user table and POSTs to POST /v1/migration/users in batches. Both systems run side-by-side. The migration API attaches your developerUserIds onto the rail-keyed customers Crossdeck already minted. Per-row outcomes (matched / created / conflict / error) are returned. The dashboard surfaces the progress.
Post-migration Crossdeck Your backend can decommission its subscription-state tracking and entitlement gates. The Node SDK reads entitlements from Crossdeck; your app's gating code is now a thin isEntitled("pro") check. Canonical. New rail events update Crossdeck directly. The identity journal accumulates the running record of every change.

The cut-over from Phase 2 to Phase 3 is not a self-attestation. It is a data-driven verification that the platform runs server-side: POST /v1/migration/verify atomically (a) counts the customers on your chosen rail that still lack a developerUserId, (b) requires that count to be zero, and (c) stamps the project's migration signal as completed. Until verify succeeds, your backend is still the source of truth. There is no path that flips the project to migrated on the developer's word alone — the data has to agree.

"Completed" is set ONLY by data-driven verification.

The migration is never marked complete on self-assertion. The verify endpoint reads the actual customer-record state and refuses to transition the project unless the unlinked count is genuinely zero. A button labelled "Migrate" that flips a flag would not survive a compliance review; a count that has to reach zero is the kind of evidence a regulator can audit.

The atomic transition emits a project-scoped signal that the front-page banner watches. The cut-over surfaces in the dashboard and in the migrationSignals state for the project. (Shipping next: outbound migration_complete webhooks to your own backend, firing on the same atomic transition.)

The migration flow, end-to-end

The six steps below run end-to-end. Steps 1, 3, 5, and 6 happen inside Crossdeck; steps 2 and 4 are work you do.

  1. Rail discovery (silent, read-only).

    When you connect Stripe via OAuth, Crossdeck walks your Stripe customer + subscription + product graph in the background. Every API call is read-only; Crossdeck never writes to your Stripe account. The scan pages through up to ten thousand customers, ten thousand subscriptions, every active price and product, and the last thirty days of paid invoices. Each existing Stripe customer becomes a rail-keyed cdcust_… inside your project — empty of a developerUserId for now, but ready to be linked. The same model applies to Apple App Store Server Notifications and Google RTDN once those rails are connected.

  2. Identity script in your backend.

    A short script in your backend reads your user table and POSTs to POST /v1/migration/users in batches of up to one thousand rows. Each row carries your stable developerUserId, optional contact fields (email, displayName), and the rail keys the user is associated with (stripeCustomerId, appleAppAccountToken, appleOriginalTransactionId, googlePurchaseToken, googleObfuscatedAccountId). Optional traits and entitlements let you backfill plan tier and current pro state in the same call. The endpoint requires a secret key and rejects publishable keys with a clear message.

    The endpoint is sequential, not parallel — migration is a one-time admin operation and correctness outranks throughput. Each row mutates the identity index and writes a journal entry; processing in order keeps the chain readable and avoids contention on the journal head.

    Per-row outcomes are returned in the response and journaled for audit:

    OutcomeMeaning
    matchedA rail key resolved to an existing cdcust_…. The row's developerUserId was attached. Journaled as migration_link.
    createdNo rail matched and no prior signal existed. A new cdcust_… was minted, seeded with the row's data. Journaled as create_customer.
    conflictTwo or more rail keys on the same row resolved to different cdcust_… records. Auto-merge refused. The case is queued in the dashboard for human review and journaled as migration_conflict.
    errorThe row failed validation (missing developerUserId, oversize fields, malformed input). Sibling rows in the same batch are unaffected.
  3. Polling for completion.

    The dashboard's migration banner watches the identity journal in real time. As migration_link and migration_conflict entries accumulate, the banner advances through its states (see the next section) and shows a live progress bar — "412 of 8,920 linked · polling live". The poll runs every two seconds while the count is moving, slows to every five seconds when it stalls, and times out at ten minutes regardless. The state is persisted to localStorage so reloading the page mid-migration resumes the watcher transparently.

  4. Conflict resolution.

    If conflicts appeared during step 2, they surface as a count in the banner with a Resolve N conflicts → CTA. Each conflict in the queue shows the competing cdcust_… records, the rail keys that resolved to each, and the developerUserId the migration row asserted. The operator picks the canonical record and either merges the loser in (recording a typed rationale and journaling merge_executed) or marks the pair as legitimately distinct (journaling customer_standalone_acknowledged if appropriate). The conflict ID is deterministic on the competing pair, so re-running the migration after resolution does not duplicate the queue.

  5. Atomic verification.

    POST /v1/migration/verify is the cut-over. The handler reads every customer on the chosen rail, excludes archived (merged-loser) records and operator-acknowledged standalone customers, and counts how many still lack a developerUserId. If the count is zero, the migration signal transitions to completed with verifiedAt and verifiedBy stamps; if not, the signal moves to started with a lastVerificationCount field so the banner can show progress. The transition is atomic — the project's migration is marked complete in the same call that confirms the data.

  6. Switch over.

    After verify succeeds, your backend can decommission its subscription-state tracking. The dashboard surfaces the cut-over as the complete state in the front-page banner ("Stripe migration complete · All 8,920 customers are linked. Banner will close shortly.") and the project is canonically migrated. From this moment forward, Crossdeck answers every entitlement check; your app reads entitlements through the SDK or the Node SDK; new rail events update Crossdeck directly without round-tripping through your database.

The migration banner is a single component pinned to the top of every dashboard page until a project's migration is verified complete. It is the operator's window into the cut-over. Module-scoped state preserves the live poll across SPA navigation, and the state is mirrored to localStorage so a hard reload resumes the watcher without losing place.

The banner runs as a small state machine. Five states; each renders a distinct title, body line, and action set.

StateWhat it meansTitle shownActions
pending Rail discovery detected existing customers; you have not yet started the migration script. Migration pending Migrate now · Dismiss
watching Rows are arriving from your migration script. The identity journal is accumulating migration_link decisions; the progress bar tracks the unlinked count downward. Migrating Stripe customers Stop watching
blocked The unlinked count has stopped decreasing and pending conflicts account for the remaining rows. Migration cannot complete without operator review. Migration blocked by identity conflicts Resolve N conflicts → · Stop watching
complete The verify call returned zero unlinked customers. The project's migration is marked complete; the banner flashes success and auto-hides. Stripe migration complete (none)
partial / error Either the count stalled with no conflicts to blame (script needs another run) or verification failed transiently. Migration paused / Verification failed Verify again · Stop watching

The body line carries the live count — "412 of 8,920 linked · polling live" in watching, "All 8,920 customers are linked. Banner will close shortly." in complete, "7 records need review." in blocked. The verbatim strings come from the same render function that drives the production banner, so the experience you see in the dashboard is the experience this document describes.

Idempotency

The migration API is idempotent at every layer. Re-running a partially-complete migration converges; replays are safe; webhooks that arrive twice produce one decision, not two.

The practical guarantee: if your migration script crashes halfway through a five-million-row import, you can re-run it from the start with no rollback step. The handler will re-attach what was already attached, mint what is genuinely new, surface a stable set of conflicts, and converge to the same end state.

What happens if migration is never run

Migration is the bridge from your historical customer base to Crossdeck's identity graph. If you skip it, two things happen:

Migration backfills the past in one batch. identify() handles the present one user at a time. They produce the same end state; migration just gets you there in an afternoon instead of over the natural sign-in distribution.

After migration — operating as a source-of-truth

Post-migration, every customer-touching operation flows through Crossdeck. The contract:

For audit-sensitive workloads we also maintain a separate access log that records every dashboard query that touched customer data — who looked at which records, when, and from where. Access-log entries survive customer erasure: the fact of someone-viewed-someone's-record is a compliance fact independent of the subject's data, and standard banking practice is to retain it regardless.

Cross-rail unification — one human, every rail, one lifetime

This is the part of the model that Sentry, Mixpanel, Segment, and Stripe Sigma structurally cannot do.

Each of those tools is anchored to a single source of truth — your auth system, your event stream, your CRM, or Stripe itself. None of them owns the join across payment rails. When a human pays on Stripe through your web checkout in 2024, churns, and re-subscribes on Apple App Store in 2026 from the same iOS account, the rail-only tools see two unrelated subscriptions. Stripe Sigma can never see the Apple side at all; Segment sees both events but does not natively reconcile them onto one customer; Sentry treats them as two error-budget cohorts.

Crossdeck reconciles them. The same developerUserId you populated during migration — the foreign key from your auth system — appears on both rail-keyed customer records. The identity graph collapses them onto a single cdcust_…, and the lifetime view (every event, every entitlement, every conversion, every churn) is preserved across rails. A customer who paid you $40 on Stripe in 2024 and is paying you $9.99/month on Apple in 2026 shows as one human with $69.88 of lifetime value, not two customers worth $40 and $9.99.

This is automatic the moment the cross-rail customer's developer-user ID is identified — no operator action required, no per-customer mapping to maintain. The rail-agnostic cdcust_… spine is what makes it possible.

Security guarantees

Six guarantees, written in plain sentences, no implementation details.

Limits & boundaries

This section says what the platform does not claim. Honest edge cases are part of the trust posture.


Last updated May 15, 2026. The architectural contract described here — append-only journal, refused auto-merge, secret-key boundary, atomic migration verify — is the platform's invariant set; documentation updates as new decision kinds, new rails, or new evidence types ship.