Crossdeck Docs
Dashboard

@cross-deck/react-native — React Native SDK reference

Reference Current version: 1.5.3 · ~18 min read · Updated May 25, 2026

@cross-deck/react-native is one SDK that handles all three Crossdeck pillars across React Native: verified subscriptions and entitlements, behavioural analytics, and error capture. Construct one Crossdeck client at app boot and every screen tap, paywall gate, and uncaught JS error flows through the same durable AsyncStorage-backed queue, the same idempotent network path, and the same dashboard your web and native users do.

TL;DR

Install

One npm install + one peer install. AsyncStorage is the canonical persistent KV store on React Native and the SDK requires it as a peer dependency rather than bundling it — that way your app's existing AsyncStorage version is respected and there's no chance of a transitive double-install.

# bare React Native
npm install @cross-deck/react-native @react-native-async-storage/async-storage

cd ios && pod install   # iOS pod install for AsyncStorage native module
# Expo
npx expo install @cross-deck/react-native @react-native-async-storage/async-storage
RuntimeMinimum supportedNotes
React Native0.71+The SDK uses ErrorUtils.setGlobalHandler + AppState + Platform — all stable since 0.60.
Expo SDK49+Use npx expo install to pin the Expo-blessed AsyncStorage version.
HermesDefaultStack-trace parsing supports Hermes, JSC, and V8 (for visionOS / debug builds).
iOS13.0+Matches RN 0.71 minimum.
AndroidAPI 21+ (Android 5.0)Matches RN 0.71 minimum.
AsyncStorage is a peer dependency, not bundled.

The SDK does not auto-install AsyncStorage. If your npm install errors with "AsyncStorage native module not found at runtime," you forgot the second install + pod install. The peer-dep model means your app version is authoritative.

Quickstart — five lines, full mobile pillar

The fastest path from zero to "events flowing, errors captured, entitlements gated" is one start call in your App.tsx or root component module:

import { Crossdeck } from "@cross-deck/react-native";

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_live_…",
  environment: "production",
});

// Anywhere in your components:
Crossdeck.track("paywall_seen", { variant: "annual" });

if (Crossdeck.isEntitled("pro")) {
  // gate logic — sync, cache-read fast (after hydration)
}

That single setup gives you, automatically and without writing any more code:

Initialise the SDK

Every React Native app constructs Crossdeck exactly once at module load — typically as a top-level export in App.tsx or a dedicated crossdeck.ts module:

// crossdeck.ts — imported once, instantiated once
import { Crossdeck } from "@cross-deck/react-native";

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_live_…",
  environment: "production",
});

Three required options. appId, publicKey, and environment. Same trio as the Web and Node SDKs — the backend correlates events against this triple and rejects mismatched env declarations loudly (env_mismatch). publicKey must start with cd_pub_; init throws invalid_secret_key on a bad prefix. The publishable key is safe to embed in a shipping .ipa / .apk; it can only POST events and read entitlements, never grant features or read other customers' data.

Publishable keys (cd_pub_) are safe in the binary — secret keys (cd_sk_) are not.

The publishable key (cd_pub_live_… / cd_pub_test_…) is the same shape Stripe uses for client keys — safe to ship in your app bundle. Never embed a cd_sk_… secret key on the device; secret keys belong in your backend's secret store only.

Verify your install

The contract: after Crossdeck.init({...}) returns and the next tick runs, the dashboard's React Native SDK row flips LIVE within ~30 seconds. The signal is the boot heartbeat — a GET /sdk/heartbeat that the SDK fires once after async hydration completes (suppressed if you pass autoHeartbeat: false). The backend reads appId + environment from the API key plus the Crossdeck-Sdk-Version header on every request, and writes a record to projects/{p}.sdkHeartbeats.react-native.{env} that the dashboard polls every few seconds.

What you should see, in order:

  1. Within 200 ms of Crossdeck.ready resolving — the heartbeat request lands. projects/{p}.appHeartbeats.{appId} + projects/{p}.sdkHeartbeats.react-native.{env} both write.
  2. Within ~5 s of dashboard refresh — the "React Native SDK" row on Dashboard → SDKs flips green.
  3. Within 5 s of your first Crossdeck.track(...) call — the event appears in Dashboard → Activity → Realtime.

If the row stays dark for more than 60 seconds, check the troubleshooting section below for publicKey + environment alignment first — a misconfigured prefix is the most common cause. To force a heartbeat manually (e.g. from a settings-screen "Test connection" button) call await Crossdeck.heartbeat(); it returns the parsed HeartbeatResponse on success or rejects with CrossdeckError on failure.

Async hydration model

React Native's AsyncStorage is async-only — there is no synchronous read of persisted state. The SDK handles this with a deliberate two-phase init:

  1. Synchronous construction. Crossdeck.init({...}) returns immediately with a usable client. Identity, super-properties, and the entitlement cache all start with sensible empty defaults.
  2. Background hydration. The SDK kicks off an async read of all persisted state. While hydration is in flight, track() calls are deferred — the event is held until hydration completes, then enqueued in order. isEntitled(...) returns false during hydration (not "unknown" — the cache is genuinely cold).
  3. Post-hydration sync reads. Once hydrated, every read (isEntitled, identity.anonymousId, super-property snapshot) is synchronous.

If you need to await hydration explicitly (e.g. before checking entitlements on splash screen):

await Crossdeck.ready;
if (Crossdeck.isEntitled("pro")) {
  navigate("PaidHome");
} else {
  navigate("PaywallSheet");
}

Crossdeck.ready is a promise that resolves once async hydration completes. After it resolves, every public read is synchronous. The first network flush also waits on Crossdeck.ready — events tracked pre-hydration ship in deterministic order after.

Identity & users

Crossdeck.identify(userId, options?)

Sets the developerUserId for all subsequent events and POSTs the alias to /identity/alias so server-side merge can coalesce the anonymous pre-identify timeline. Returns a Promise<AliasResult> that resolves with the canonical crossdeckCustomerId (cdcust_…). Throws CrossdeckError with code missing_user_id if userId is empty.

await Crossdeck.identify("user_847", {
  email: "[email protected]",
  traits: {
    name: "Wes",
    plan: "pro",
    signedUpAt: "2026-05-15",
  },
});

Vocabulary: userId is your auth provider's stable user ID (Firebase Auth's uid, Auth0's sub, Supabase's id, etc.). NEVER pass a cdcust_… here — that's a separate server-side handle returned by this call. email is a first-class top-level option — the universal merge anchor across devices and auth-provider migrations.

Unconditional entitlement cache clear. Whenever the developerUserId changes — or when identify(...) is called after the SDK had no customer at all — the entitlement cache is cleared atomically with the identity swap. This is the defence that prevents a freshly-identified user from briefly seeing the prior user's entitlements during the cache rebuild.

Auth-provider examples

The shape is identical regardless of auth provider — pass the provider's stable user ID as the first argument and the email if you have it. Pick the snippet for your stack:

// Firebase Auth (@react-native-firebase/auth)
import auth from "@react-native-firebase/auth";

auth().onAuthStateChanged(async (user) => {
  if (!user) {
    Crossdeck.reset();
    return;
  }
  await Crossdeck.identify(user.uid, {
    email: user.email ?? undefined,
    traits: { displayName: user.displayName ?? "" },
  });
});
// Auth0 (react-native-auth0)
import { useAuth0 } from "react-native-auth0";

const { user } = useAuth0();

React.useEffect(() => {
  if (!user) {
    Crossdeck.reset();
    return;
  }
  Crossdeck.identify(user.sub, {
    email: user.email,
    traits: { name: user.name ?? "" },
  });
}, [user]);
// Clerk (Expo: @clerk/clerk-expo)
import { useUser } from "@clerk/clerk-expo";

const { user, isSignedIn } = useUser();

React.useEffect(() => {
  if (!isSignedIn || !user) {
    Crossdeck.reset();
    return;
  }
  Crossdeck.identify(user.id, {
    email: user.primaryEmailAddress?.emailAddress,
    traits: { firstName: user.firstName ?? "", lastName: user.lastName ?? "" },
  });
}, [isSignedIn, user]);
// Supabase (@supabase/supabase-js)
import { supabase } from "./supabase";

supabase.auth.onAuthStateChange(async (_event, session) => {
  if (!session?.user) {
    Crossdeck.reset();
    return;
  }
  const { id, email, user_metadata } = session.user;
  await Crossdeck.identify(id, {
    email: email ?? undefined,
    traits: { name: user_metadata?.name ?? "" },
  });
});

Every example follows the same two rules: call identify(...) when the auth state has a user, and call Crossdeck.reset() when it doesn't. reset() regenerates the anonymousId and wipes the entitlement cache so the next anonymous session is fully unlinked from the prior signed-in user.

Crossdeck.reset()

Sign-out path. Clears the developerUserId, regenerates anonymousId, wipes super-properties, wipes breadcrumbs, wipes the entitlement cache. The next anonymous session is fully unlinked from the prior identified user.

Crossdeck.reset();

Super properties — per-install enrichment

Register key/value pairs once; every subsequent track() auto-includes them. Persisted across launches in AsyncStorage.

Crossdeck.register("theme", "dark");
Crossdeck.registerOnce("first_install_version", "2.3.1");
Crossdeck.unregister("theme");

Events & analytics

Crossdeck.track(name, properties?)

Synchronous from the caller's perspective. The event is validated, scrubbed, enriched, and enqueued; the network round-trip happens in batches. Throws CrossdeckError(code: "missing_event_name") if name is empty — that is the only case where track() throws. Invalid property values are sanitised in-place with debug warnings (see below); the platform-wide contract is that track() never fails on a single bad property.

Crossdeck.track("checkout_completed", {
  amount_usd: 29,
  plan: "pro",
});

Properties are sanitised at the SDK boundary before the event enters the queue. Every coercion emits a debug warning so you can see what was changed — none of them throw:

Enrichment order: super-properties → device info (platform, os_version, app_version, sdk_name, sdk_version) → caller-supplied properties. The caller's bag wins on key collision.

The durable queue

The event queue is the analytics workhorse — used by track() and by every error report:

Crossdeck.flush() — async drain

Returns when the in-flight batch resolves — success or failure. On failure, events stay queued for the next retry; the awaited call does not throw.

await Crossdeck.flush();

Permanent failure observability — onPermanentFailure

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_live_…",
  environment: "production",
  onPermanentFailure: (events, error) => {
    console.error("Crossdeck batch dropped:", error.code, events.length, "events");
  },
});

Entitlements

Crossdeck.isEntitled(key)

Synchronous, cache-only. Returns true if the cached set for the currently-identified developerUserId contains the entitlement key; false otherwise. Safe to call from a component render or a tap handler — never blocks on network.

{Crossdeck.isEntitled("pro") ? <ProFeature /> : <UpgradeSheet />}

Customer-scoped reads. The cache is keyed on (developerUserId, entitlements). If the cached snapshot was for a different customer, isEntitled(...) returns false — never silently leaks the prior user's entitlements.

Cache freshness model.

Both the read and write paths ship in v1.0. Call Crossdeck.syncPurchases({ rail: "apple", signedTransactionInfo }) immediately after a successful StoreKit 2 transaction — the server verifies the JWS, projects entitlements, and the call returns the new entitlement set (which is also written into the local cache). Use Crossdeck.getEntitlements() on app resume or after a deep-link return to re-hydrate from the authoritative server state. Google Play receipt verification is backend v1.1 — passing rail: "google" today succeeds at the SDK boundary but the backend returns google_not_supported until v1.1 lands; until then, gate Android features on your own server-side confirmation of the Play purchase.

Error capture

Three roads into the SDK's error pipeline:

  1. Uncaught JS errors (opt-in via captureUncaughtErrors: true). The SDK installs ErrorUtils.setGlobalHandler, chains into any prior handler (Sentry, Bugsnag, RN redbox in dev), and ships the error before the prior handler runs.
  2. fetch failures (opt-in via captureFetchErrors: true). Wraps global.fetch to capture HTTP failures from outbound requests. Skips requests to the Crossdeck ingest endpoint itself to prevent feedback loops.
  3. Manual Crossdeck.captureError(error) — call from a try/catch to ship a handled error with breadcrumbs attached.

Uncaught error capture — chained handler

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_live_…",
  environment: "production",
  captureUncaughtErrors: true,
  captureFetchErrors: true,
});

ErrorUtils.setGlobalHandler is a process-wide React Native hook. On install we capture the prior handler (Sentry, Bugsnag, RN's dev redbox) and invoke them after our own snapshot. Existing crash reporters stay populated; we just snapshot one before they redbox the dev build or kill the process.

Manual capture

try {
  await performRiskyOperation();
} catch (err) {
  Crossdeck.captureError(err, { handled: true });
}

Breadcrumbs — context before the crash

50-entry ring buffer of timestamped events leading up to an error. Every track() call adds a breadcrumb automatically; you can add custom ones from your navigation hooks:

Crossdeck.addBreadcrumb({
  category: "ui",
  level: "info",
  message: "tapped subscribe button",
  data: { plan: "annual" },
});

Stack-trace parsing — Hermes, JSC, V8

The SDK parses native React Native engine stack traces and normalises them into a stable shape before fingerprinting. Supports:

Auto-detected at parse time; no configuration needed.

beforeSend — the redaction last line

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_live_…",
  environment: "production",
  captureUncaughtErrors: true,
  beforeSendError: (event) => {
    if (event.message.includes("Network request failed")) return null;  // drop
    return event;
  },
});

Self-request skip — no feedback loops

The fetch wrapper does NOT report HTTP failures against the Crossdeck ingest endpoint itself. Without this guard, a transient ingest failure would generate an $error event, which would itself fail to send, which would generate another error, ad infinitum. The check is a case-insensitive hostname compare against the configured endpoint's host.

App lifecycle

The SDK observes AppState automatically.

AppState changeSDK behaviour
active → backgroundPersist entire buffer + pending batch to AsyncStorage immediately. Best-effort flush() using the platform's background-execution budget.
background → activeSchedule a flush to ship anything buffered while we were away.
Force-quit while suspendedNothing happens at runtime — but the durable buffer persisted at the prior background transition rehydrates on next launch and re-sends with the original idempotency keys.

Privacy & consent

Default state for both analytics + errors: granted — matches the Web/Node/Swift platform contract. Wire an opt-out via Crossdeck.consent(...) for strict-consent flows (cookie banner, EU age gate). The method takes a partial state and returns the merged full state:

Crossdeck.consent({ analytics: false });
const current = Crossdeck.consentStatus();
// → { analytics: false, errors: true }

PII scrubber

On by default. Two patterns scrubbed before any event leaves the device, recursively across nested objects and arrays:

The tokens (<email> / <card>) are the platform-wide vocabulary — identical to the Web, Node, Swift SDKs and the backend allow-list. Scrub runs BEFORE the event is enqueued, so the persisted AsyncStorage queue never holds raw PII. To opt out, set scrubPii: false on Crossdeck.init(...) at boot — runtime toggling is not supported (preserves the contract that any persisted event was scrubbed under the configuration in effect at the time of enqueue).

iOS — App Store privacy manifest (PrivacyInfo.xcprivacy)

iOS 17+ App Store review rejects any binary that calls a required-reason API without a privacy manifest declaration. @cross-deck/react-native is pure JS — it has no native iOS code of its own and therefore does not bundle a PrivacyInfo.xcprivacy. Its required-reason API usage is inherited from the JavaScript runtime + the AsyncStorage peer dependency:

<key>NSPrivacyAccessedAPITypes</key>
<array>
    <dict>
        <key>NSPrivacyAccessedAPIType</key>
        <string>NSPrivacyAccessedAPICategorySystemBootTime</string>
        <key>NSPrivacyAccessedAPITypeReasons</key>
        <array>
            <string>35F9.1</string>
        </array>
    </dict>
</array>

If your app target already has a PrivacyInfo.xcprivacy, add the entry to your existing NSPrivacyAccessedAPITypes array. Apple's full reason-code catalogue: developer.apple.com → Describing use of required reason API.

Android — Google Play Data Safety

Every app on Google Play must complete the Data Safety form in Play Console. Below is the mapping for data collected by the React Native SDK on the Android side. Add these rows to your declaration, merged with anything your own app code collects:

Data typeCollected?PurposeEncrypted in transit?User can request deletion?
User IDsYes — developerUserId passed to identify()Analytics, app functionalityYes (HTTPS / TLS)Yes — via forget()
Email addressesOptional — only if passed to identify(userId, { email })AnalyticsYesYes — via forget()
App interactions (app activity)Yes — track() eventsAnalyticsYesYes — via forget()
Crash logsOptional — only if captureUncaughtErrors: trueApp functionality (diagnostics)YesYes — via forget()

The SDK does not collect: precise or approximate location, contacts, photos or videos, audio files, files and docs, calendar events, SMS or MMS, browsing history, search history, installed apps, advertising IDs, IMEI, Android ID, or GAID. The anonymousId is a randomly-minted first-party identifier stored in AsyncStorage — it is not a device identifier and is not shared with advertising networks.

Configuration reference

OptionTypeDefaultNotes
appIdstringRequired. Crossdeck App ID from the dashboard (e.g. app_rn_xxx).
publicKeystringRequired. Publishable key starting with cd_pub_ (cd_pub_live_… or cd_pub_test_…).
environment"production" | "sandbox"Required. Must match the key prefix — cd_pub_live_"production", cd_pub_test_"sandbox". Mismatch is rejected at init.
baseUrlstringhttps://api.cross-deck.com/v1Override for self-hosted setups or the local emulator.
persistIdentitybooleantruePersist anonymousId + crossdeckCustomerId across launches via AsyncStorage. Set false to defer writes until consent.
storageKeyValueStorageAsyncStorage adapterCustom storage adapter (SecureStore, MMKV, encrypted vault) for higher-security app shells.
storagePrefixstring"crossdeck:"Prefix for SDK-persisted state keys.
autoHeartbeatbooleantrueGET /v1/sdk/heartbeat on init. Backend reads appId + environment from the API key — no body. Disable for high-frequency boot scenarios (CI, tests).
eventFlushBatchSizenumber20Events per HTTP POST.
eventFlushIntervalMsnumber5000Idle ms after last track() before flushing.
appVersionstringundefinedAuto-attached to every event as properties.appVersion for build slicing.
debugbooleanfalseEnable structured signal emission to console + the DebugEmitter.

API reference

Lifecycle

Identity

Events

Entitlements

Errors

Consent

Diagnostics & debugging

Pass a debug logger to route structured SDK signals to your dev console or your own observability stack:

import { Crossdeck } from "@cross-deck/react-native";

Crossdeck.init({
  appId: "app_rn_xxx",
  publicKey: "cd_pub_test_…",
  environment: "sandbox",
  debug: true,
});

Signal vocabulary

SignalFires when
sdk.startCrossdeck client successfully started.
sdk.stopstop() called.
sdk.configuredClient successfully started + ready to accept track/identify.
sdk.first_event_sentFirst event landed at the ingest endpoint. One-shot per process — dashboard onboarding checklist keys off this signal.
sdk.queue_persistedBuffer mutation persisted to AsyncStorage (per-enqueue + per-flush, including the pending-batch slot).
sdk.queue_restoredQueue rehydrated from AsyncStorage on init.
sdk.flush_retry_scheduledRetryable failure scheduled. Payload: attempt, delay_ms.
sdk.flush_permanent_failurePermanent 4xx / retry-exhausted / buffer-overflow drop. Routed to onPermanentFailure when set.
sdk.entitlement_cache_usedisEntitled answered from local cache.
identity.resetreset() applied.
sdk.consent_deniedtrack() or $error dropped because the relevant consent channel is off. Payload: channel.

Troubleshooting

"AsyncStorage native module not found" at runtime

Events not appearing in the dashboard

sdk.flush_permanent_failure in logs

isEntitled() returns false for a paying user right after launch

Versioning & changelog

The React Native SDK follows SemVer. Breaking changes only ship in a major-version bump. Full release notes live in the public repo's CHANGELOG.md; each released version is tagged on the public repo so npm consumers can pin to a specific version.

Latest: v1.0.0 — 2026-05-25

Initial release. Bank-grade event ingestion (pending-batch slot, 4xx hard stop, Retry-After honoured, durable AsyncStorage rehydration). Async hydration with deferred pre-hydration track(), sync reads after. Identity persistence with reset regeneration and unconditional entitlement-cache clear on identify. Customer-scoped entitlement reads. PII scrubber with <email> / <card> tokens, recursive walk, DAG-safe cycle detection. ErrorUtils.setGlobalHandler chained capture, manual captureError, global.fetch wrapper with self-request skip. Hermes / JSC / V8 stack-trace normalisation.

Feature parity across SDKs

Every Crossdeck SDK ships the same wire shapes and the same bank-grade contracts (4xx hard stop, durable queue, Idempotency-Key reuse, unconditional cache clear on identify). Method surface, defaults, and rail support differ in a small, deliberate set of places — captured here so an "iOS native vs. Expo" or "Android native vs. React Native" decision doesn't require diffing four references.

CapabilityWebNodeReact NativeSwiftAndroid
track(...) / identify(...) / reset()
flush() async drain
forget() — GDPR /identity/forget
group(type, id, traits?) — B2B context
getEntitlements() + sync isEntitled(...)
syncPurchases(...) — Apple StoreKit 2
syncPurchases(...) — Google Play BillingSDK ✓ · backend v1.1SDK ✓ · backend v1.1
syncPurchases(...) — Stripe Checkout
onEntitlementsChange(listener)
Manual heartbeat() trigger
Uncaught error capture (chained)✓ (window.onerror)✓ (uncaughtException)✓ (ErrorUtils)✓ (NSSetUncaughtExceptionHandler)✓ (JVM Thread.UncaughtExceptionHandler)
Native (NDK / C++) crash capturen/an/an/a
PII scrubber (<email> / <card> tokens)
Validation: maxStringLength default1 0241 0241 0241 0241 024
Validation: maxDepth default5553232
Default consent stateGrantedGrantedGrantedGrantedGranted
Durable queue storagelocalStorageMemory (server lifecycle)AsyncStorageUserDefaultsSharedPreferences
Privacy manifest bundledn/an/aInherited from AsyncStorage; app target adds SystemBootTimeApp target declares UserDefaultsn/a — Play Data Safety in Console

Cells marked are not present in that SDK today. Cells marked SDK ✓ · backend v1.1 accept the call at the SDK boundary on a stable wire shape but the backend route returns google_not_supported until v1.1 ships full Google Play receipt verification. The platform-wide bank-grade contracts (4xx hard stop, Idempotency-Key reuse, unconditional cache clear on identify, never throw on a bad property value) apply uniformly across every row above.