Crossdeck Docs
Dashboard

Identify users — connect Crossdeck to your auth provider

Bolt-on Read time: ~10 min · SDK: @cross-deck/web · Updated May 18, 2026

The SDK auto-captures sessions, page views, clicks, Web Vitals, and uncaught errors the moment you call init() — but every event is attached to an anonymous device ID. To turn that anonymous timeline into an identified user, you call Crossdeck.identify(userId) with the same stable ID your auth provider already gave you. This page is the bolt-on: it takes you from "the SDK is installed but every visitor is anonymous" to "every signed-in user is identified, the dashboard People page shows them by name, and the timeline before they signed up is still attached" in under ten minutes.

TL;DR

Does my app need this?

Quick triage before you read the rest. identify() needs an identity anchor — a stable id for a person. That anchor is often an auth login, but it does not have to be.

The hybrid case (public marketing pages + an authenticated app, often two Crossdeck apps on the same project) is handled by treating each app on its own merits — read this doc for the authenticated one, ignore it for the marketing one.

Public sites without login — no backend, email is the join rail

Auth is only one source of identity. A charity donation site, a storefront, or a campaign page has no login — but it absolutely has identity moments: a donation, a newsletter or volunteer signup, a waitlist, a checkout. At that moment the visitor hands you an email, and on a public site with no backend that's all you need.

Crossdeck's canonical identity is the cdcust. Its identity graph collapses every alias you ever hand it — the anonymous device, an email, and later any durable rail id (stripe_customer:cus_…, hubspot_contact:…, a Blackbaud/Donorfy constituent, a Shopify customer) — into one person, joined by email. So you don't pick one "canonical" external id and get locked in: you hand it the most stable id you have plus email, and the graph unifies the rest.

Email is the no-backend join rail, not the canonical identity. On a public site with no backend, the most stable id in the browser is the email the visitor just typed — so identify with it. It isn't the final person object (the cdcust is); it's the rail the graph joins on, and stronger rail ids attach to the same person later.

The pattern — public site, no backend

  1. Install the Web SDK. Crossdeck.init(...) on every page — the anonymous journey (campaign viewed → story viewed → donation started) auto-captures from the first visit.
  2. When the visitor gives an email through a donation, newsletter, volunteer, waitlist, checkout, or lead form, call identify(email, { email, traits }). The whole anonymous journey back-attributes to them instantly.
  3. Track the conversion event.
  4. If/when an integration or backend exposes a stronger durable rail id, identify with it too — the graph links it into the same person. It upgrades durability and richness; it never blocks the core story.
  5. If your campaign links carry a Crossdeck contact tag, a landing session materialises against the known contact before any form submit (see connect-on-arrival).
// The guaranteed no-backend path — the charity backbone.
Crossdeck.identify(email, {
  email,
  traits: { name, source: "donation_form" },
});

Crossdeck.track("donation_completed", { amount, campaign });

No backend required, and no token in the browser — that's the whole story: anonymous journey → identified donor → donation, depending on nothing else being built. Never put a CRM / HubSpot private token in browser code; durable ids are resolved server-side.

Upgrade later — durable rail ids attach to the same person

Connect a CRM, a payment rail, or a store and Crossdeck collapses their ids into the one cdcust, joined by email:

anonymous device
email:amina@example.org
hubspot_contact:123
stripe_customer:cus_123
blackbaud_constituent:456
        ⟶  one cdcust

If the durable id only exists server-side (e.g. the Stripe customer a webhook creates), forward the browser's anonymous id so the server-side identify stitches the pre-conversion journey: read it with Crossdeck.diagnostics().anonymousId (a first-class integration primitive, not just a debug field), put it in the Stripe Checkout Session metadata (or client_reference_id), and in your checkout.session.completed handler call the Node SDK await crossdeck.identify("stripe_customer:" + customerId, anonymousId, { email, traits }) — the 2nd argument is the browser anonymous id.

Connect-on-arrival — the carried tag

The graph also works the other way. When a connected integration (HubSpot, Blackbaud, Donorfy, Shopify) emails a known contact, the link can carry that contact's Crossdeck tag. The moment they click and land, the tag binds the anonymous session to that exact person — so their on-site journey and the eventual donation attach to one donor, with no guessed email match. Email 2,000 donors, they click, land, donate — fully attributed. It's a rail capability, uniform across every integration.

Cross-platform note. Email is the join rail within a web property. To share one identity across web and a mobile app, use a stable account/rail id as the shared id — Sign in with Apple's Private Relay hands mobile a different email than web, so email alone won't match across platforms. The graph still joins by email where it can, but a durable id is what stitches devices.

Why identify is manual

The SDK is aggressive about doing things for you. It auto-emits page.viewed on every pushState. It pins a session to a 30-minute idle window. It captures every uncaught window.onerror. It wraps fetch and XMLHttpRequest to log HTTP 5xx as error.http. So why doesn't it auto-detect the signed-in user too?

Because "who is signed in" is not knowable from inside a generic browser SDK. The shape of "the current user" depends entirely on which auth provider you wired:

An SDK that "auto-detected" any of these would silently mis-identify users in the other six, or call identify() too early on a half-authed state, or worst — call identify() with the wrong ID from a stale localStorage cache during the brief window before the auth provider rehydrated. We don't ship that ambiguity.

Identify is intentional, not magic.

One line of code, wired exactly once, at the exact moment your auth provider tells you the user is real. That intentionality is the contract. The SDK cannot pick the right user ID for you — but it can guarantee that the one you pass is the one every subsequent event, error, and entitlement check is attached to.

The alias model — what identify() actually does

Crossdeck tracks two identity axes inside the SDK:

A third value — crossdeckCustomerId (the cdcust_… handle) — is the Crossdeck-side primary key. The backend resolves it from the other two via an identity-graph merge and the SDK persists it locally so subsequent boots can read entitlements directly without a round-trip alias call.

What happens when identify() fires on an anon session

Imagine a typical visitor flow. Day 1: a visitor lands on your marketing site from a Google Ad with gclid=abc. The SDK mints anon_lr9k82xxyy. Over the next four minutes the SDK auto-captures session.started, three page.viewed events, two element.clicked events, and one webvitals.lcp. All attached to anon_lr9k82xxyy. The visitor closes the tab.

Day 3: the same visitor (same device, same browser) comes back. The SDK reads anon_lr9k82xxyy from localStorage and continues attaching events to it. They sign up. Your auth provider fires its "signed in" callback with user_847. You call:

await Crossdeck.identify("user_847", {
  email: "wes@example.com",
  traits: { name: "Wes" },
});

Why plan isn't a trait. A customer's plan is an entitlement, kept current by the payment rail — not a string you maintain by hand. See What NOT to call instead below for the full rule. The example above carries only name deliberately; pushing a plan field here would create exactly the drift the rest of the doc tells you to avoid.

The SDK POSTs to /identity/alias with both IDs. The server:

The SDK persists the crossdeckCustomerId locally. From this moment on, every event carries developerUserId: "user_847" and resolves to the same cdcust_…. Funnels stay intact across the sign-up boundary.

What reset() does to the alias

Crossdeck.reset() breaks the link cleanly. The cached crossdeckCustomerId is wiped, the developerUserId is cleared, a new anonymousId is minted (so the next pre-login session is a fresh identity graph entry), super-properties / groups / breadcrumbs / error context are all cleared. Server-side, the previous cdcust_… and user_847 stay linked forever — reset is a client-side wipe, not a server-side unlink.

Same user id everywhere — how a web subscription reaches the phone

This is the most important section in this doc for any product that runs on more than one platform. The entitlement a customer buys on your website appears in your iOS or Android app for exactly one reason: you called identify() with the same user id on every platform. That shared id is the whole mechanism — it is what tells Crossdeck the person on the web and the person on the phone are one human, so their subscription follows them onto every device they sign in on.

Use the stable id your auth provider issues for the account — the Firebase uid, the Auth0 sub, your own user-table primary key. Whatever it is, it is the same string on web and mobile, because it is the same account. Pass it to identify() on each platform and the rest is automatic:

Skip this in your mobile app and paying web customers see “free” on their phone.

If your iOS or Android app never calls identify() — or calls it with a different id than the web used — the install stays anonymous. The customer paid on the web, opens the app, and the entitlement check finds no human to attach to: they are silently downgraded to free on the device they expected to use it on. That is a churned paying customer, and it fails quietly in production, not loudly at integration time. The single most important line of code in a cross-platform Crossdeck integration is identify() in the mobile app, called with the same id as the web.

The rule, in one line: same account → same id → call identify() with it on every platform, right after login (and reset() on logout on every platform — see Reset on logout). The snippets below give you the exact code per platform; the native iOS / Swift versions are in iOS / macOS — native Swift SDK.

Pass the id, never the email.

The bridge is the shared id, so both platforms must authenticate the same account against the same identity system (e.g. one Firebase project across web + iOS). Do not use the email as the identifier — Sign in with Apple's Private Relay hands your iOS app a different address than the web, so emails won't match across platforms. The stable account id is what stitches; the email is just a trait.

Wire it up by provider

One subsection per provider. The pattern is always the same shape: inside the auth-state listener your provider gives you, mirror the user into Crossdeck.identify(userId, { email, traits }) when signed in and Crossdeck.reset() when signed out. The code below is what to drop in. Replace the placeholder app ID and key with the real ones from your Apps page.

Every snippet below is a recommended pattern, not auto-detected.

Crossdeck doesn't ship provider-specific subpackages. Each snippet is derived from that provider's own contract (Firebase's onAuthStateChanged, Supabase's onAuthStateChange, Auth0's useAuth0 hook, Clerk's useUser, and so on). The shape is portable — if your auth library exposes any "user signed in / out" event with a stable user ID, the same wiring works. The provider versions below are the ones we've vetted; adapt the import line and the event source as needed.

React shortcut — <CrossdeckProvider>

If your app is React (Next.js, Vite, Remix, CRA — anything that mounts React components), there's a shorter path. @cross-deck/web@1.9.1+ ships a CrossdeckProvider component at @cross-deck/web/react that handles init(), identify(), and reset() internally. You pass your auth library's user ID as a prop; the provider mirrors it into the SDK on mount and on every change. No useEffect boilerplate, no loading-state guard — eight lines instead of forty.

// app/providers.tsx (Next.js) — or src/main.tsx (Vite / CRA)
"use client"
import { CrossdeckProvider } from "@cross-deck/web/react"
import { useSession } from "next-auth/react"

export function Providers({ children }: { children: React.ReactNode }) {
  const { data: session } = useSession()
  return (
    <CrossdeckProvider
      appId="app_web_xxxxxxxxxxxx"
      publicKey="cd_pub_test_…"
      environment="sandbox"
      userId={session?.user?.id}
    >
      {children}
    </CrossdeckProvider>
  )
}

Swap the useSession hook for whatever your auth library exposes — useAuth0().user?.sub, useUser().user?.id (Clerk), session?.user?.id (Supabase), user?.uid (Firebase). The provider's contract is "whatever you put in userId becomes the identified user; undefined means logged out." Idempotent across React 18 StrictMode re-mounts; SSR-safe (every side effect lives inside useEffect); no DOM wrapper.

The framework-specific subsections below stay accurate and are the right path if you want fine-grained control (passing custom traits, gating on status transitions, or wiring identify from a non-React context like a Node middleware). For most React apps, the provider above is the one-line answer.

Firebase Auth

Drop the listener anywhere you have the auth instance — typically your root provider, layout file, or main entry. onAuthStateChanged fires once on boot with the rehydrated user (or null) and again on every sign-in / sign-out. No useEffect needed — it's a plain callback.

// Firebase Auth — anywhere with the auth instance.
// onAuthStateChanged is a callback, no await/effect needed.
onAuthStateChanged(auth, (user) => {
  if (user) {
    Crossdeck.identify(user.uid, {
      email: user.email,
      traits: { name: user.displayName },
    })
  } else {
    Crossdeck.reset() // call on logout
  }
})

Where it goes. If you have a top-level Firebase initialisation file (src/lib/firebase.ts), put it right under the getAuth() call. In Next.js with the App Router, put it inside your client-component provider's useEffect with no dependencies — the listener installs once on mount.

What happens. First fire is the rehydration: if Firebase persisted a user, you get the real user.uid and identify resolves the customer. If no user, the reset() branch is a no-op on an already-anonymous device. Subsequent fires reflect actual sign-in / sign-out clicks. Firebase Auth deduplicates rehydration so you won't get a spurious identify-then-reset flicker.

Supabase Auth

Same shape as Firebase. onAuthStateChange is the listener; session.user.id is the stable Supabase user UUID.

// Supabase — anywhere with the supabase client. The
// onAuthStateChange callback receives session updates.
supabase.auth.onAuthStateChange((event, session) => {
  if (session?.user?.id) {
    Crossdeck.identify(session.user.id, {
      email: session.user.email,
      traits: { name: session.user.user_metadata?.full_name },
    })
  } else {
    Crossdeck.reset()
  }
})

Where it goes. Right where you create the Supabase client (src/lib/supabase.ts or equivalent). The listener returns an unsubscribe function — in long-lived app shells you don't need it; in HMR-aware dev setups you may want to capture and call it on module dispose.

What happens. Supabase fires INITIAL_SESSION on hydration, then SIGNED_IN / SIGNED_OUT on auth events. All three pass through the same branching above. The Supabase UUID maps 1:1 to your Crossdeck developerUserId.

NextAuth (App Router)

NextAuth is a React-context system, not a callback, so the wiring lives inside a client component with a useEffect. There's one prerequisite: NextAuth's default Session type doesn't expose session.user.id — you extend it once in your auth.ts callbacks so it carries the stable database user ID. This is the canonical NextAuth pattern, recommended by NextAuth itself for any app that needs a per-user identifier beyond email (which can change).

// auth.ts — extend the session ONCE so session.user.id is the
// stable database user ID. Recommended by NextAuth and required
// for any app that treats users as more than their email.
import NextAuth from "next-auth"

export const { handlers, auth } = NextAuth({
  callbacks: {
    async session({ session, token }) {
      if (token?.sub) session.user.id = token.sub
      return session
    },
  },
})

With that one-time extension in place, drop the bridge component near the top of your tree (typically inside app/providers.tsx next to SessionProvider):

// NextAuth — drop in, zero config. Uses session.user.id, the
// stable database ID exposed by the auth.ts callback above.
"use client"
import { useEffect } from "react"
import { useSession } from "next-auth/react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { data: session, status } = useSession()
  useEffect(() => {
    // Skip initial hydration — null data + status:"loading" would
    // briefly call reset() and wipe a persisted identity for ~50ms.
    if (status === "loading") return
    if (session?.user?.id) {
      Crossdeck.identify(session.user.id, {
        email: session.user.email,
        traits: { name: session.user.name },
      })
    } else {
      Crossdeck.reset()
    }
  }, [status, session?.user?.id])
  return null
}

Why not session.user.email as the ID. Emails change — users rebrand, switch providers, hand the account to a colleague. The Crossdeck customer ID needs to be stable for the lifetime of that user, which is why we route through session.user.id (the database primary key) and let email live in the trait/email field where it belongs. If you absolutely can't extend the session type (legacy code, no DB access, a prototype you'll throw away), passing session.user.email in place of session.user.id works as a fallback — but every email change after that will surface as a new customer in Crossdeck.

Why the status === "loading" guard. On boot, NextAuth returns { data: null, status: "loading" } for the first render. Without the guard, you'd call reset() on a freshly-mounted page, wiping a perfectly-good persisted identity for the ~50ms before the session resolves. The guard skips that flicker.

Auth0

Auth0's React SDK exposes the current user via the useAuth0 hook. The pattern matches NextAuth's: a client component with a useEffect that mirrors the hook state into identify() / reset(). The recommended ID field is user.sub — Auth0's canonical, stable subject claim (a string like auth0|abc123 or google-oauth2|xyz depending on the connection).

// Auth0 — recommended pattern from the @auth0/auth0-react hook.
// useAuth0() rehydrates isLoading=true on boot; skip identify
// until isLoading flips to false to avoid a reset() flicker.
"use client"
import { useEffect } from "react"
import { useAuth0 } from "@auth0/auth0-react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { user, isAuthenticated, isLoading } = useAuth0()
  useEffect(() => {
    if (isLoading) return
    if (isAuthenticated && user?.sub) {
      Crossdeck.identify(user.sub, {
        email: user.email,
        traits: { name: user.name },
      })
    } else {
      Crossdeck.reset()
    }
  }, [isLoading, isAuthenticated, user?.sub])
  return null
}

Next.js SDK variant. If your app uses Auth0's Next.js SDK (@auth0/nextjs-auth0) instead of the React SDK, swap useAuth0 for useUser from @auth0/nextjs-auth0/client — the rest of the pattern is identical.

Clerk

Clerk's useUser hook exposes the current user object. user.id is the stable Clerk-side identifier (a string like user_2N3aBcDef…). isLoaded is Clerk's equivalent of NextAuth's status === "loading" — wait for it before identifying.

// Clerk — recommended pattern from @clerk/clerk-react useUser hook.
// Wait for isLoaded before identify/reset to avoid a hydration flicker.
"use client"
import { useEffect } from "react"
import { useUser } from "@clerk/clerk-react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { isLoaded, isSignedIn, user } = useUser()
  useEffect(() => {
    if (!isLoaded) return
    if (isSignedIn && user) {
      Crossdeck.identify(user.id, {
        email: user.primaryEmailAddress?.emailAddress,
        traits: {
          name: user.fullName,
          username: user.username,
        },
      })
    } else {
      Crossdeck.reset()
    }
  }, [isLoaded, isSignedIn, user?.id])
  return null
}

Next.js App Router note. If you're on Clerk's Next.js SDK (@clerk/nextjs), the hook lives at @clerk/nextjs with the same name and shape. Server components can read the user via currentUser() from @clerk/nextjs/server — but identify must run client-side, where the SDK lives.

Custom backend (session cookie + /api/me)

If you run your own auth — session cookies + a /api/me endpoint, or JWTs stored in HttpOnly cookies that get decoded server-side — there's no auth-provider listener to plug into. Instead, fetch the current user once at boot inside an async helper and call identify() when the fetch resolves.

// Custom backend — fetch the current user inside an
// async boot function (or a useEffect in a React component).
// Top-level await is not portable across all bundlers, so wrap.
async function bridgeCrossdeckIdentity() {
  const me = await fetch("/api/me").then((r) => r.json())
  if (me?.userId) {
    await Crossdeck.identify(me.userId, {
      email: me.email,
      traits: { name: me.name },
    })
  } else {
    Crossdeck.reset()
  }
}
bridgeCrossdeckIdentity()

On logout. Whatever code clears your session cookie should also call Crossdeck.reset() — typically your sign-out button's onClick handler, right after the /api/logout response resolves. You don't get an automatic listener like Firebase / Supabase / Clerk — you have to invoke reset() explicitly.

For SPAs that switch users without a full page reload (rare in custom-backend setups), wire a small event emitter at the auth boundary and call identify() / reset() from the same place that updates your in-app user state.

iOS / macOS — native Swift SDK

The Swift SDK (@cross-deck/swift) ships the identical identify(userId:email:traits:) shape as the Web SDK — same vocabulary, same merge contract, same $email-as-anchor semantics. Pattern: wire it inside whichever auth-provider listener your iOS app uses. Same conceptual shape as onAuthStateChanged on Web — just Swift idioms.

// Sign In with Apple — fires from the AuthorizationController delegate
import AuthenticationServices

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization auth: ASAuthorization) {
    guard let credential = auth.credential as? ASAuthorizationAppleIDCredential else { return }
    try? cd.identify(
        userId: credential.user,        // Apple's stable ID — same string across launches
        email: credential.email,        // Present on FIRST authorisation only — persist it
        traits: [
            "name": [credential.fullName?.givenName, credential.fullName?.familyName].compactMap { $0 }.joined(separator: " ")
        ]
    )
}
Sign In with Apple's email + name are first-authorisation-only.

Apple returns email and fullName on the FIRST authorisation only. Subsequent sign-ins return nil for both. Persist these to your backend the first time you see them, and on subsequent sign-ins identify with the persisted values — never assume Apple will hand them back twice.

// Firebase Auth iOS — wire inside the addStateDidChangeListener callback
import FirebaseAuth

Auth.auth().addStateDidChangeListener { _, user in
    guard let user else {
        try? cd.reset()
        return
    }
    try? cd.identify(
        userId: user.uid,           // Same uid the Web Firebase SDK exposes
        email: user.email,
        traits: ["displayName": user.displayName ?? ""]
    )
}
// Auth0 iOS — wire after userInfo resolves with the access token
import Auth0

Auth0.authentication().userInfo(withAccessToken: token).start { result in
    if case .success(let profile) = result {
        try? cd.identify(
            userId: profile.sub,
            email: profile.email,
            traits: ["name": profile.name ?? ""]
        )
    }
}

On logout, call try? cd.reset(). It clears the developerUserId, regenerates the anonymousId, wipes super-properties + breadcrumbs + entitlement cache. The next anonymous session is fully unlinked from the prior identified user.

Where it goes. The Sign In with Apple snippet lives inside your ASAuthorizationControllerDelegate. The Firebase listener belongs in your App.init (SwiftUI) or application(_:didFinishLaunchingWithOptions:) (UIKit) — installs once on app launch, fires for every auth state change for the app's lifetime. The Auth0 example fires whenever you complete a token exchange.

React Native — same shape via @cross-deck/react-native

The React Native SDK (@cross-deck/react-native) uses the same JavaScript shape as the Web SDK — wire inside your auth provider's listener exactly as you would on Web. The signature is identical; the only difference is the import path:

import { cd } from "./crossdeck";
import auth from "@react-native-firebase/auth";

auth().onAuthStateChanged((user) => {
  if (user) {
    cd.identify(user.uid, { email: user.email, traits: { displayName: user.displayName } });
  } else {
    cd.reset();
  }
});

Identity traits — profile data on the customer record

The second argument to identify() is an IdentifyOptions bag with two fields: email and traits. Both flow through to the Crossdeck customer record and become queryable from the dashboard's People page.

await Crossdeck.identify("user_847", {
  email: "wes@example.com",
  traits: {
    name: "Wes",
    plan: "pro",
    signedUpAt: "2026-05-11",
    role: "admin",
  },
});

What flows through to the dashboard

How traits are merged

Additive, per-key. Each identify() call merges the supplied keys into the existing customer record. A later identify("user_847", { traits: { plan: "enterprise" } }) updates only plan — it does not wipe name, signedUpAt, or role. On identify(), passing a trait as null does not delete the key: the value is stored as null via the dot-path trait merge, so the trait persists with a null value. The clear-the-key convention (where null stops tracking a key) applies only to register() super-properties, not to identify traits.

How traits are sanitised

Traits ride through the same validator as track() event properties. The contract is bulletproof — a malformed bag (function, BigInt, circular reference) cannot crash the alias request:

Server-side a second-layer cap applies: max 32 keys, max 1024 characters per value (longer strings are truncated with an ellipsis), primitives only — nested objects and arrays are dropped silently. If you need richer structures (e.g. an embedded team object), denormalise: teamId + teamName as separate trait keys.

Don't put PII in traits beyond email and display name.

The SDK's PII scrubber auto-strips card-number patterns and most email patterns from event properties. Traits go through the same validator but the intent is "profile metadata that helps you support and segment customers" — not "phone number, physical address, government ID". If a field would land you in a GDPR DPIA, leave it out of traits and store it server-side keyed off the cdcust_… instead.

Reset on logout

Crossdeck.reset() is the bookend to identify(). Call it exactly once, exactly when your auth provider tells you the user signed out. It is synchronous — no await.

Crossdeck.reset();

What reset does

In order:

  1. Emits user.signed_out stamped with the outgoing user's developer ID and cdcust_…, so the Activity stream shows "Wes signed out" rather than an anonymous orphan event.
  2. Uninstalls the auto-tracker and reinstalls a fresh one, so the new session belongs to the new identity (not the old one).
  3. Wipes the identity store — both the anonymousId and the cached crossdeckCustomerId are cleared from localStorage and the 1st-party cookie. A new anonymousId is minted on the spot so the next pre-login session is a fresh identity-graph entry.
  4. Clears the entitlement cacheisEntitled("pro") goes back to false until the next getEntitlements() resolves for the new (or anonymous) user.
  5. Drops the event queue — any in-flight events are discarded rather than sent under the wrong identity.
  6. Clears super-properties, groups, breadcrumbs, error context, error tags — anything identity-scoped. These belong to the old session; they don't carry forward.
  7. Clears the developer user ID — the SDK is now anonymous again, ready for the next identify.

When to call

What NOT to call instead

Pre-auth visits — what happens before identify()

Everything captured before the first identify() call is attached to the anonymous device ID. That's not a degraded state — it's the canonical pre-sign-up timeline, and it gets back-attributed the moment the user identifies.

Concretely, every event captured on the anonymous device carries:

When identify(userId) finally fires, the server's alias merge back-attributes every one of those events to the now-identified customer. Your funnel for "Google Ads → marketing page → /pricing → /signup → subscription" survives the sign-up boundary intact — the Google Ad click is permanently linked to the customer who eventually paid.

This is the headline reason to identify even users with no traits.

Even if you have nothing to pass beyond the user ID itself — no email, no name, no plan — calling identify(userId) on sign-up is what links the marketing-attribution timeline to the customer record. Without it, your acquisition reporting stops at "anonymous device". With it, every dollar of subscription revenue traces back to the original referrer / UTM / click ID that brought the device in.

Server-side identify

Most apps only call identify() from the browser — that's where the anonymous device timeline lives and where the alias merge has the most to attribute. You identify server-side when your server learns who the user is before the browser does: an OAuth callback, an email-link click that resolves identity, or a server-side signup. That is the dedicated server SDK's job — @cross-deck/node — and Server-side events covers the full pattern, including forwarding the browser's anonymousId so the client and server timelines converge on one customer.

import { CrossdeckServer } from "@cross-deck/node";

const crossdeck = new CrossdeckServer({ secretKey: "cd_sk_live_…" });

// The server identify takes BOTH the userId AND the anonymousId —
// a server has no auto-stored device ID, so you pass the one the
// browser minted (forwarded via a cookie or header).
await crossdeck.identify(user.id, anonymousId, {
  email: user.email,
  traits: { name: user.name },
});

Subscription state is not something you push. Do not call identify() from a payment webhook to set a customer's plan. Crossdeck ingests your payment rail directly — once you connect Stripe (or Apple / Google), it receives every subscription event and projects each customer's entitlements automatically. A customer's plan is an entitlement, kept current by the rail — never a trait you maintain by hand.

Testing your wiring

Once the listener is dropped in and your app is running on a real (non-localhost) domain, verify the wiring from the dashboard's People page:

  1. State pill flips from "Anonymous" to "Identified". Before sign-up, your row shows an anon_… ID and a grey "Anonymous" pill. After the first identify() fires, the same row is now keyed by your developer user ID and the pill flips to a green "Identified".
  2. Developer ID column populates. Initially empty for anonymous rows; populated with the string you passed to identify() after the alias lands.
  3. Linked-IDs panel shows both anonymous and developer IDs. Open the customer drawer — the "Identities" panel lists every anon_… the alias merge has linked to this customer, plus the developer ID, plus the cdcust_…. If you sign in from a second device, that device's anon_… also lands on this list after its first identify().
  4. Pre-auth events back-attribute. Open the Activity tab on the customer drawer. The page.viewed and session.started events from before sign-up should now appear with the user's name attached — proof the alias merge ran.
  5. Traits surface in the People row. If you passed email, traits.name, and traits.plan, the People row shows them within ~1s of the alias landing. The drawer's "Traits" section lists every key you've sent.

If any of those don't happen — see Troubleshooting below.

For ad-hoc verification from DevTools, the SDK exposes Crossdeck.diagnostics() in the browser console. The shape:

Crossdeck.diagnostics()
// {
//   started: true,
//   anonymousId: "anon_lr9k82xxyy",
//   crossdeckCustomerId: "cdcust_01HXXX…",  // populated after first identify resolves
//   developerUserId: "user_847",            // populated after first identify resolves
//   sdkVersion: "1.10.0",
//   …
// }

Before identify fires: developerUserId and crossdeckCustomerId are null. After: both populated. That's your single-line proof the wiring is correct.

Troubleshooting

Identify fires before init()CrossdeckError({ code: "not_started" })

The auth listener installed before Crossdeck.init() ran, so the first identify() call hit a not-yet-started SDK. Fix the order: init() first, then install the listener. In a React app, both live inside the same useEffect in your root provider — keep them in that order. In a Firebase-managed init file, call Crossdeck.init() at the top of the module before the onAuthStateChanged registration.

"identify(userId) requires a non-empty userId" thrown

The listener is firing with a falsy user ID. Common causes: NextAuth returning session.user.id as undefined because the type wasn't extended (use session.user.email instead, or wire the auth.ts callback to populate id); Firebase Auth firing pre-rehydration before user.uid resolves (the listener should already handle this with the if (user) guard); a custom backend's /api/me returning { userId: null } for anonymous visitors (let the else branch call reset()).

Traits contain PII the SDK auto-scrubs

If a trait key's value matches the SDK's email or credit-card pattern, the validator emits a debug warning (visible when debug: true is passed at init) and the value passes through unmodified — traits are intentionally less aggressive about scrubbing than event properties, because email as a trait is the legitimate use case. If you're storing values like creditCard: "4111..." in traits, stop — server-side webhooks should write those to your own database keyed off cdcust_… instead.

Multiple identify() calls firing per page

Not harmful — every alias call is idempotent server-side, the same cdcust_… resolves every time. But it's wasted bandwidth and a hint that your listener is wired up too aggressively. Check: is the listener inside a component that re-mounts? Is the useEffect dependency array missing or wrong? Are you calling identify() from both an auth-state listener and a route guard? Pick one.

Never calling reset() on logout

The next visitor on the same device (shared family laptop, public kiosk, ex-employee's machine) will arrive with the previous user's identity persisted. Every event they emit will be attributed to the wrong customer until they sign in themselves. Always pair identify() with reset() in the same listener — the snippets in Wire it up by provider all do this.

Sign-in works but pre-auth events aren't back-attributed

Check that the SDK was actually running before sign-up — open DevTools → Application → Local Storage and look for the crossdeck:anon_id key. If it's missing, the SDK wasn't initialised on the pre-auth pages. If it's present, check that the anonymousId in Crossdeck.diagnostics() matches it (they should). If it does, the alias merge ran — confirm by opening the customer drawer on the People page and looking at the linked-IDs panel; the previous anon_… should be listed there. Pre-auth events tagged with that anon_… are now keyed to the customer.

Identify resolves but People page still shows "Anonymous"

The alias POST /identity/alias request failed silently. Check the Network tab — if you see a 401 / 403, the publishable key is wrong or doesn't match the environment. If you see a 200 but the response has mergePending: true, the server is queuing the merge for a sub-second background job; refresh the People page in a few seconds. If consent has been denied (Crossdeck.consent({ analytics: false }) was called), the SDK returns a synthetic no-op alias result without making the request — re-grant consent if you want identify to actually run.


Bolt-on doc for @cross-deck/web@1.9.1. The snippets in this page are mirrored from the SDK install-flow generator (_sdk-snippets.js → webIdentifyAuthSnippets()) and locked in by scripts/test-sdk-snippets.mjs — a regression in any provider snippet fails CI.