How Crossdeck verifies and identifies users of your app
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
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.
- One canonical customer ID. Every human Crossdeck has ever heard of is a single
cdcust_…. Auth events, rail events, anonymous device traffic, and developer-supplied user IDs all collapse onto the same record through an identity graph. - Every identity decision is journaled. An append-only, hash-chained, sequence-numbered audit log records every mint, attach, merge, rail-create, rail-attach, profile-update, migration-link, and conflict. Tampering with any earlier entry invalidates every chained-forward hash.
- Auto-merge is refused for conflicts. When two signals point at two different customers, Crossdeck never silently merges them. The case is queued in the dashboard for an explicit human decision. A silent merge is impossible to undo cleanly; a refused-and-queued conflict preserves the option of correction.
- The secret-key boundary protects mass relink. Only
cd_sk_*keys can mutate identity at scale — alias calls, migration, manual grants. Publishable keys can fire events and check entitlements but cannot relink customers. - Every inbound rail signal is HMAC-verified. Stripe webhooks, Apple JWS, and Google Pub/Sub messages are signature-checked before they touch the customer graph. The provenance is recorded on the journal entry that resulted.
- Tenant scoped. Every
cdcust_…is scoped to a single project. A leaked ID from one project resolves to nothing in another. Cross-tenant reads are not a code path that exists. - Source-of-truth handover is bank-grade. Before migration completes, your backend is canonical and Crossdeck is observing. After verify succeeds, Crossdeck is canonical and your backend can decommission its own subscription state. The transition is atomic, evidenced, and reversible.
The model — one customer, three identifiers
The shape of Crossdeck's identity surface is small and deliberate. Three identifiers exist; only one is canonical.
| Identifier | Origin | Role | Canonical? |
|---|---|---|---|
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.
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:
- 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. - Developer-supplied user ID. If the caller passes a
developerUserId, the resolver looks it up in the project's customer index. Hit → return the linkedcdcust. Miss → fall through. - Rail key. Stripe customer ID, Apple
appAccountToken, AppleoriginalTransactionId, GooglepurchaseToken, GoogleobfuscatedAccountId. Each is its own indexed key; if any of them was minted by an earlier rail event, the resolver lands on that customer. - Anonymous device ID. The SDK's pre-auth device handle. Used to stitch anonymous traffic onto a human when the human attaches.
- 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.
| Decision | When it fires |
|---|---|
create_customer | A new cdcust_… was minted. |
attach_user_to_anon | An anonymous device record was promoted by attaching a developerUserId. |
attach_anon_to_user | An existing user record was extended by attaching a new device handle. |
already_linked | Both identifiers in the call already pointed at the same customer — no-op, journaled for traceability. |
merge_pending | Two identifiers resolved to two different customers; the conflict was queued for human review. |
merge_executed | Two customers were merged after explicit operator review. Carries a typed operator rationale. |
rail_customer_created | A payment rail webhook (Stripe / Apple / Google) created a customer. |
rail_attached | A payment rail was attached to an existing customer (e.g. the human added a card). |
profile_updated | Profile fields changed on an existing customer. |
customer_standalone_acknowledged | An 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_executed | A GDPR / CCPA erasure ran; PII was anonymised. |
migration_link | A migration row attached a developerUserId to a rail-keyed customer minted from earlier rail discovery. |
migration_conflict | A 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.
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:
- Auto-merge. Quietly absorb one customer into the other. Fast, requires zero human attention, and is impossible to undo cleanly if the system guessed wrong. The merged-loser's history is now welded onto a record that may not actually be the right human; any downstream action — refund, plan change, GDPR erasure — touches the wrong account. Worse, no later signal can rescue you, because both customers now share the same identity from the merge moment forward.
- Refuse and queue. Surface the conflict in the dashboard with the competing customer IDs, the evidence behind each, and an explicit operator decision. Slower, requires human attention, and is reversible: if the operator picks the wrong winner, the merge is journaled as
merge_executedand can be undone by appending an unmerge entry.
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.
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.
| Key | Prefix | Can do | Cannot 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:
- A leaked publishable key is a per-device problem. An attacker can fire events as a fake device or check what entitlements that fake device has. Bad, but bounded.
- A leaked secret key is everything. An attacker can rewrite your entire customer graph, mint or merge identities at will, and grant themselves any entitlement. Treat secret keys like database passwords — never ship them to a client, never put them in a public repo, rotate them on key-personnel changes.
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.
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.
-
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 adeveloperUserIdfor now, but ready to be linked. The same model applies to Apple App Store Server Notifications and Google RTDN once those rails are connected. -
Identity script in your backend.
A short script in your backend reads your user table and POSTs to
POST /v1/migration/usersin batches of up to one thousand rows. Each row carries your stabledeveloperUserId, optional contact fields (email,displayName), and the rail keys the user is associated with (stripeCustomerId,appleAppAccountToken,appleOriginalTransactionId,googlePurchaseToken,googleObfuscatedAccountId). Optionaltraitsandentitlementslet 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:
Outcome Meaning matchedA rail key resolved to an existing cdcust_…. The row'sdeveloperUserIdwas attached. Journaled asmigration_link.createdNo rail matched and no prior signal existed. A new cdcust_…was minted, seeded with the row's data. Journaled ascreate_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 asmigration_conflict.errorThe row failed validation (missing developerUserId, oversize fields, malformed input). Sibling rows in the same batch are unaffected. -
Polling for completion.
The dashboard's migration banner watches the identity journal in real time. As
migration_linkandmigration_conflictentries 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 tolocalStorageso reloading the page mid-migration resumes the watcher transparently. -
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 thedeveloperUserIdthe migration row asserted. The operator picks the canonical record and either merges the loser in (recording a typed rationale and journalingmerge_executed) or marks the pair as legitimately distinct (journalingcustomer_standalone_acknowledgedif appropriate). The conflict ID is deterministic on the competing pair, so re-running the migration after resolution does not duplicate the queue. -
Atomic verification.
POST /v1/migration/verifyis 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 adeveloperUserId. If the count is zero, the migration signal transitions tocompletedwithverifiedAtandverifiedBystamps; if not, the signal moves tostartedwith alastVerificationCountfield 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. -
Switch over.
After verify succeeds, your backend can decommission its subscription-state tracking. The dashboard surfaces the cut-over as the
completestate 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 dashboard's migration banner
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.
| State | What it means | Title shown | Actions |
|---|---|---|---|
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.
- At the journal layer, every entry carries an idempotency key derived deterministically from the decision and its primary identifiers. If a retried call produces the same idempotency key as an earlier successful call, the existing entry is returned unchanged — no duplicate row, no double-counted decision.
- At the row layer, the migration handler uses keys like
migration:link:{developerUserId}andmigration:create:{developerUserId}. A retry after a network glitch resolves to the same key and the same outcome. - At the conflict layer, the conflict record's ID is deterministic on the sorted competing pair plus the developer-supplied user ID. Re-running migration after a conflict is resolved does not produce a fresh duplicate row in the queue.
- At the customer layer, the merge chain on the resolver follows redirected customers transitively, so a developer that hard-codes an old
cdcustreference (because they took the value down before a merge) still gets the canonical live record back.
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:
- Your existing customers do not lose pro status, provided your application wires
Crossdeck.identify(developerUserId)correctly. The first time a paying customer signs in to your app after Crossdeck is installed, the SDK callsidentify()with the developer-supplied user ID. The rail-keyedcdcustCrossdeck minted from your Stripe webhooks gets attached to the human at that moment — same outcome as a migration row would have produced, just lazy. - Your historical revenue is not pulled into Crossdeck until each customer comes back. Until a human signs in, the rail-keyed customer carries no
developerUserId, no traits, no historical entitlements; it exists in Crossdeck only as far as the rail webhook described it. The dashboards will catch up to your real customer base on the schedule that customers return.
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:
- Entitlement checks read from the SDK cache (microsecond) or the Node SDK (typed, server-side). The cache is warmed by a single network round-trip after
identify()resolves; subsequent reads are local. - Conversion events, refunds, plan changes arrive at Crossdeck via rail webhooks (HMAC-verified) and update the customer graph directly. The journal records each as
rail_attached,profile_updated, or a sibling kind. - Manual operator actions — granting an entitlement, revoking a plan, merging two customers, resolving a conflict — happen in the dashboard and land on the journal with operator attribution.
- Audit grows append-only. The journal records every decision indefinitely. There is no retention cliff on identity history — only customer-PII fields are subject to erasure under GDPR / CCPA, and even those leave the audit row standing with the PII anonymised.
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.
- Append-only audit. Identity decisions are never edited or deleted. The journal is hash-chained and sequence-numbered. Tampering with any earlier entry invalidates every chained-forward hash and is detectable on a verification pass. Merges and erasures land as new entries; nothing is overwritten in place.
- Tamper-evident against Crossdeck itself. The hash chain validates against any in-place edit — including by our own engineers. A platform-side operator who altered an earlier journal entry would invalidate every chained-forward hash and the verification pass would catch it. Every platform-side touch of a project's data is recorded in a separate internal access log that customers can audit on request. The journal is not a thing we can quietly rewrite for ourselves.
- Signed inbound. Every rail webhook is HMAC-verified before it touches the customer graph. Every migration call is secret-key authorised. Every dashboard mutation is project-membership gated against your Crossdeck account's authentication. Provenance is recorded on every journal entry that resulted.
- Tenant scoped. Identity records, journal entries, conflicts, access logs, and migration signals are scoped per project. No cross-project read is a code path that exists. A leaked customer ID from one project resolves to nothing in another.
- Conflict resolution explicit. Auto-merge is refused. Every merge is a human decision with a typed rationale of at least twenty characters. The merge lands on the journal with operator attribution and is reversible by appending an unmerge entry.
- Replay-safe. Every write carries an idempotency key — caller-supplied or server-derived deterministically from the inputs. A retried request, a replayed webhook, or a re-run migration converges to the same end state without duplicating decisions.
Limits & boundaries
This section says what the platform does not claim. Honest edge cases are part of the trust posture.
-
Customers without any signal resolve to a fresh customer.
If a human shows up in your app with no rail key, no developer-supplied user ID, and no prior anonymous device handle, Crossdeck mints a new
cdcust_…. There is no magic deduplication based on email or browser fingerprint — those signals are too soft to use for canonical identity. Your application is the source of the stable hint that prevents fragmentation; passingdeveloperUserIdconsistently is what holds the graph together. - Identity decisions are eventually consistent across regions. The journal write happens inside a strongly-consistent transaction, but the read replicas the dashboard queries (and the entitlement caches the SDK holds) propagate within seconds of the write. A read issued microseconds after a write may see the old state; a read a second later sees the new one. This is the standard read-your-writes profile of a regional database with cross-region replication.
- Decommissioning your backend is possible after migration but requires planning. Crossdeck can answer every entitlement check, every customer query, every conversion event after the cut-over. But your application's hot-path latency budget for entitlement reads is yours to define — the SDK cache delivers microsecond reads after a warm-up call, and the Node SDK delivers single-digit millisecond reads on a typical region pair. If your application currently reads entitlements from a same-region database with a sub-millisecond budget, validate the SDK's profile against that budget before flipping the switch on your subscription-state tracking.
-
Conflicts surface only what the data warrants.
The conflict queue is not a fishing expedition. Cases land there only when two pieces of evidence about the same human resolve to two different
cdcust_…records — typically during the first migration of a project that has done aggressive customer deduplication on its own side, or during ongoing operation after a human swaps an iOS account between two App Store IDs. A well-instrumented application sees zero conflicts in steady state. - Erasure preserves the audit fact, not the audit detail. A GDPR / CCPA right-to-be-forgotten request anonymises PII inside the customer record and on prior journal entries, but the journal row itself — its decision kind, its hash chain link, its sequence number — survives. The platform can prove that on a given date a decision was made about a now-erased subject; it cannot return the subject's name or email after erasure. Standard regulated-industry posture.
Related
- Identify users (auth bridges) — the SDK-side wiring of
Crossdeck.identify(userId)from Firebase Auth, Auth0, Supabase, Clerk, and friends. - Node SDK reference — the typed server-side interface for entitlement reads, event ingest, and customer queries. The migration API is invoked via raw HTTP today; a typed Node SDK helper for batched migration ships in Phase 2.
- Entitlements & gating — defining entitlements, mapping rail products to entitlement keys, and the contract
isEntitled()answers. - Receiving Crossdeck webhooks — outbound HMAC-signed events your backend can subscribe to. The cut-over signal is currently surfaced in the dashboard; outbound
migration_completewebhooks are shipping next. - Server-side event ingestion — when to send events from your backend, raw HTTP versus the Node SDK, and identity-linking modes that align with the resolver priority order described in this document.
- API keys & authentication — publishable versus secret keys, rotation, origin allowlists, and the operational hygiene around the boundary this document describes.
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.