# Identify users

> Source: https://cross-deck.com/docs/identify-users/ · Crossdeck developer docs (generated from the published page).

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

- **Why identify matters.** Without it, every visitor is an `anon_…` ID in the dashboard, the visitor → user → customer funnel is broken at the sign-up step, and entitlement checks have no human to attach to.

- **What's auto vs manual.** Sessions, page views, clicks, Web Vitals, and errors auto-capture against the anonymous ID. **Identify is the one thing you wire by hand** — the SDK cannot guess which user object is the right one to alias.

- **One line per auth provider.** Inside your auth state listener (`onAuthStateChanged`, `onAuthStateChange`, `useSession`, `useUser`, `useAuth0`) call `Crossdeck.identify(userId)` when signed in and `Crossdeck.reset()` when signed out. That's the whole integration.

- **The alias model preserves the timeline.** When `identify()` fires, the anonymous device ID and your developer user ID are linked server-side. Every event captured *before* sign-up gets back-attributed to the now-identified user — so a marketing-attribution funnel that starts at `page.viewed` on a Google Ad and ends at `subscription.activated` two days later survives intact.

- **Traits flow through.** Pass `{ email, traits: { name, plan, … } }` and they land on the customer record additively — later `identify()` calls update only the keys you supply, never wipe the rest.

- **Cross-platform: same id, same human.** Call `identify()` with the *same* user id on web and mobile, and a subscription bought on the web releases on the phone the moment the user signs in. Skip it in the mobile app and a paying web customer sees &ldquo;free&rdquo; on their device. This is the single most important step in a multi-platform integration — [details](#cross-platform).

## 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.

- **Your app signs users in** (auth provider, login wall, user accounts) → **read this doc.** Your anchor is the auth id. Wire `Crossdeck.identify()` at the auth-resolved hook and `Crossdeck.reset()` at sign-out. The snippets in [Wire it up by provider](#wire-it-up) are what you need.

- **Your app is a public site with no login but with conversion moments** (a charity donation page, a storefront, a campaign page with a newsletter / volunteer / donation form) → **read this doc — especially [Public sites without login](#public-sites).** You have no auth id, but you *do* get an identity anchor at the conversion moment: a Stripe customer id, a donor-CRM constituent id, a commerce customer id. Stay anonymous, then `identify()` by that anchor when the visitor converts — the anonymous journey back-attributes to them.

- **Your app is truly anonymous** (a marketing landing page, public docs, a status page — no login *and* no conversion identity) → **you can skip identify.** `Crossdeck.init({ appId, publicKey, environment })` alone is enough — the SDK mints an `anonymousId`, page views and sessions auto-capture, and every event lands under that anonymous identity. There is simply no person to anchor yet.

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

- Install the Web SDK. `Crossdeck.init(...)` on every page — the anonymous journey (campaign viewed → story viewed → donation started) auto-captures from the first visit.

- 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.

- Track the conversion event.

- 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.

- 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](#carried-tag)).

```
// 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:

- Firebase Auth exposes `auth.currentUser.uid` — but only after `onAuthStateChanged` resolves, which can be 50–800ms post-init.

- Supabase exposes `session.user.id` via a separate listener.

- NextAuth exposes `session.user.email` by default and `session.user.id` only if you extended the type in your `auth.ts` callbacks.

- Auth0 exposes `user.sub` via the `useAuth0` hook — but the user object is `undefined` during the redirect-handoff between `/callback` and your app shell.

- Clerk exposes `user.id` via `useUser` — same async hydration semantics.

- A custom session-cookie + `/api/me` setup needs a network round-trip before you know anything.

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:

- **`anonymousId`** — minted on first install (`anon_ `), persisted to `localStorage` *and* a 1st-party cookie for redundancy. Survives reloads. Identifies the device, not the human.

- **`developerUserId`** — your auth provider's stable user ID. Empty until `identify()` is called. Once set, it's the canonical "this is which human".

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](#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 `POST`s to `/identity/alias` with both IDs. The server:

- Resolves or mints the `cdcust_…` for this identity graph entry.

- Links `anon_lr9k82xxyy` and `user_847` to the same customer.

- Back-attributes every event previously emitted under `anon_lr9k82xxyy` to `user_847`'s customer record. The `gclid=abc` from day 1 is now attached to your converted customer. The page views, the clicks, the LCP — all of them.

- Returns `{ object: "alias_result", crossdeckCustomerId, linked: [{ type: "developer", id: "user_847" }, { type: "anonymous", id: "anon_lr9k82xxyy" }], mergePending, env }`.

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:

- **Web:** the user subscribes via Stripe. You call `Crossdeck.identify("uid_abc")` → the subscription attaches to `uid_abc`.

- **iOS / Android:** the same user installs your app and signs in. You call `identify("uid_abc")` with the *same* id → the device joins `uid_abc`'s identity → `isEntitled("pro")` returns `true` on the phone, off the web subscription. No second purchase, no manual stitching.

Skip this in your mobile app and paying web customers see &ldquo;free&rdquo; 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](#reset)). The snippets below give you the exact code per platform; the native iOS / Swift versions are in [iOS / macOS — native Swift SDK](#ios-swift).

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](https://app.cross-deck.com/apps).

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 —

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.
// Latch a real user first — see "Guard reset()" below.
let hasSeenSignedInUser = false

onAuthStateChanged(auth, (user) => {
  if (user) {
    hasSeenSignedInUser = true
    Crossdeck.identify(user.uid, {
      email: user.email,
      traits: { name: user.displayName },
    })
  } else if (hasSeenSignedInUser) {
    hasSeenSignedInUser = false
    Crossdeck.reset() // real sign-out only
  }
  // else: signed-out boot callback — keep the anonymous identity.
})
```

**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. Subsequent fires reflect actual sign-in / sign-out clicks.

### Guard reset() — only on a real sign-out

**This applies to every provider on this page.** Auth listeners fire a boot callback with no user for every visitor who isn't signed in — Firebase's initial `null`, Supabase's `INITIAL_SESSION`, Auth0's `isLoading`, Clerk's `isLoaded`, and any custom `/api/me` fetch. Calling `reset()` there is the single most common way to break your own attribution.

`reset()` is *not* a no-op on an anonymous device. It deletes the `anonymousId` from every store — including the shared cross-subdomain cookie — and mints a new one. So an unguarded `reset()` re-anonymises the visitor on every page load.

**What that costs you.** Someone reads your marketing site as anonymous *A*, clicks through to your app, and the SDK correctly carries *A* across the subdomain. An unguarded `reset()` then wipes *A* and mints *B* before they sign up. The signup stitches to *B*, and the entire pre-signup story — true source, UTMs, what they read, where they hesitated — is orphaned under *A*. The journey shows a user who appeared from nowhere.

The fix is to latch that you have actually seen a signed-in user. Only then does a `null` mean a genuine sign-out — which is when `reset()` *is* correct, so the next person on a shared device doesn't inherit the previous user's identity.

```
let hasSeenSignedInUser = false

// …inside your provider's auth-state callback:
if (user) {
  hasSeenSignedInUser = true
  Crossdeck.identify(userId, { email, traits })
} else if (hasSeenSignedInUser) {
  hasSeenSignedInUser = false
  Crossdeck.reset()          // real sign-out only
}
// else: signed-out boot callback — keep the anonymous identity.
```

In React-style providers the same guard is expressed with a ref that starts `undefined` (see the framework snippets on the [Web SDK](https://cross-deck.com/docs/web-sdk/) page) — skip the effect entirely while auth is still loading.

### 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.
let hasSeenSignedInUser = false

supabase.auth.onAuthStateChange((event, session) => {
  if (session?.user?.id) {
    hasSeenSignedInUser = true
    Crossdeck.identify(session.user.id, {
      email: session.user.email,
      traits: { name: session.user.user_metadata?.full_name },
    })
  } else if (hasSeenSignedInUser) {
    hasSeenSignedInUser = false
    Crossdeck.reset() // real sign-out only
  }
  // else: INITIAL_SESSION with no user — keep the anonymous identity.
})
```

**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, useRef } from "react"
import { useSession } from "next-auth/react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { data: session, status } = useSession()
  const seenUser = useRef(false)
  useEffect(() => {
    // Skip hydration; and never reset a visitor who was never signed
    // in — that wipes the anonymous identity they arrived with.
    if (status === "loading") return
    if (session?.user?.id) {
      seenUser.current = true
      Crossdeck.identify(session.user.id, {
        email: session.user.email,
        traits: { name: session.user.name },
      })
    } else if (seenUser.current) {
      seenUser.current = false
      Crossdeck.reset() // real sign-out only
    }
  }, [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, useRef } from "react"
import { useAuth0 } from "@auth0/auth0-react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { user, isAuthenticated, isLoading } = useAuth0()
  const seenUser = useRef(false)
  useEffect(() => {
    if (isLoading) return
    if (isAuthenticated && user?.sub) {
      seenUser.current = true
      Crossdeck.identify(user.sub, {
        email: user.email,
        traits: { name: user.name },
      })
    } else if (seenUser.current) {
      seenUser.current = false
      Crossdeck.reset() // real sign-out only
    }
  }, [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, useRef } from "react"
import { useUser } from "@clerk/clerk-react"
import { Crossdeck } from "@cross-deck/web"

export function CrossdeckIdentityBridge() {
  const { isLoaded, isSignedIn, user } = useUser()
  const seenUser = useRef(false)
  useEffect(() => {
    if (!isLoaded) return
    if (isSignedIn && user) {
      seenUser.current = true
      Crossdeck.identify(user.id, {
        email: user.primaryEmailAddress?.emailAddress,
        traits: {
          name: user.fullName,
          username: user.username,
        },
      })
    } else if (seenUser.current) {
      seenUser.current = false
      Crossdeck.reset() // real sign-out only
    }
  }, [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 },
    })
  }
  // No else: a boot fetch that finds no session means "not signed in
  // yet" — NOT a sign-out. Calling reset() here would wipe the
  // anonymous identity the visitor arrived with. Call reset() from
  // your logout handler instead (see below).
}
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](https://cross-deck.com/docs/swift-sdk/)) 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](https://cross-deck.com/docs/react-native-sdk/)) 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

- **`email`** — a top-level field on the customer record. Indexed for People-page search. Used by the dashboard to render the customer's gravatar / initials and as the address for any product email the dashboard sends.

- **`traits.name`** / **`traits.displayName`** — shown as the customer's name on the People page, the Activity stream, and every dashboard table that renders a "who" column. If both are present, `displayName` wins.

- **`traits.plan`** — surfaced as a chip on the People row. Useful for filtering "show me all pro users" without hitting the entitlement table.

- **`traits.signedUpAt`** — parsed as an ISO date if it's a string in that shape. Drives the "joined N days ago" column.

- **Arbitrary keys** — anything else you pass (`role`, `company`, `tier`, `betaCohort`, `onboardingStep`) lands under `customer.traits. ` server-side and is available as a People-page filter. Bring whatever taxonomy your product uses.

### 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:

- Functions, symbols, and `undefined` values are dropped.

- `Date` objects are coerced to ISO strings.

- `BigInt` values are coerced to strings.

- `Error` objects become `{ name, message, stack }`.

- `Map` becomes a plain object, `Set` becomes an array.

- Strings longer than 1024 characters are truncated with an ellipsis.

- Circular references are replaced with `"[circular]"`.

- Objects nested deeper than five levels become `"[depth-exceeded]"`.

- The caller's input object is never mutated — sanitisation always produces a defensive copy.

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:

- **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.

- **Uninstalls the auto-tracker** and reinstalls a fresh one, so the new session belongs to the new identity (not the old one).

- **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.

- **Clears the entitlement cache** — `isEntitled("pro")` goes back to `false` until the next `getEntitlements()` resolves for the new (or anonymous) user.

- **Drops the event queue** — any in-flight events are discarded rather than sent under the wrong identity.

- **Clears super-properties, groups, breadcrumbs, error context, error tags** — anything identity-scoped. These belong to the old session; they don't carry forward.

- **Clears the developer user ID** — the SDK is now anonymous again, ready for the next identify.

### When to call

- Inside your auth provider's "signed out" branch — see each provider's snippet above. The pattern is universal: `if (signedIn) identify() else reset()`.

- Inside a manual sign-out handler if your auth setup doesn't expose a listener (the custom-backend case): right after `fetch("/api/logout")` resolves.

- In end-to-end tests, between cases, to give each test a clean identity-graph entry.

### What NOT to call instead

- **Don't call `localStorage.clear()`** to "log out the SDK". It works — but it also wipes the durable event queue mid-flight, may strand events the SDK was about to flush, and skips the `user.signed_out` event entirely. Use `reset()`.

- **Don't call `identify("")` or `identify(null)`** as a way to clear identity. The SDK throws `CrossdeckError({ code: "missing_user_id" })` on empty IDs — by design, to catch a wired-up-wrong listener that's sending falsy values. Use `reset()`.

- **Don't call `Crossdeck.forget()`** on every logout. `forget()` is the GDPR right-to-be-forgotten escape hatch — it does everything `reset()` does *plus* sends a server-side deletion request. Reserve it for actual deletion-request flows.

## 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:

- `anonymousId` on the wire — what the SDK currently knows.

- First-touch acquisition (`utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `referrer`) pinned at session start. Stays attached for the whole session even if the user navigates to URLs without UTMs.

- Paid-traffic click IDs from the landing URL (`gclid`, `fbclid`, `msclkid`, `ttclid`, `li_fat_id`, `twclid`) — captured once on landing, attached to every subsequent event.

- Standard device + viewport enrichment (`os`, `browser`, `locale`, `timezone`, `screenWidth`, `screenHeight`, `devicePixelRatio`).

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](https://cross-deck.com/docs/node-sdk/) — and [Server-side events](https://cross-deck.com/docs/server-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](https://cross-deck.com/docs/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](https://app.cross-deck.com/people):

- **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".

- **Developer ID column populates.** Initially empty for anonymous rows; populated with the string you passed to `identify()` after the alias lands.

- **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()`.

- **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.

- **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.14.2",
//   …
// }
```

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](#wire-it-up) 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.

## Related

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/) — full `@cross-deck/web` API surface: init, identify, track, entitlements, errors, consent.

- [Web SDK — Identity & users](https://cross-deck.com/docs/web-sdk/#identity) — the canonical reference for `identify()`, `reset()`, `register()`, and `group()` with full parameter detail.

- [Error codes](https://cross-deck.com/docs/web-sdk/error-codes/) — every `CrossdeckError` code the SDK can throw, including `missing_user_id` and `not_started`.

- [API keys & authentication](https://cross-deck.com/docs/api-keys/) — publishable vs secret keys, environment matching, origin allowlists.

- [Create a project](https://cross-deck.com/docs/create-a-project/) — the dashboard flow that produces the `appId` + publishable key the SDK uses to identify against the right project.

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.
