@cross-deck/react-native — React Native SDK reference
@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
- One npm install, three pillars. One package —
@cross-deck/react-native— covers analytics, subscription/entitlement gating, and error capture on iOS and Android from a single JS codebase. No separate Sentry + Mixpanel + RevenueCat install. - Bank-grade durability.
track(...)returns sync; the event sits in an in-memory buffer, persists toAsyncStorageon every enqueue, and ships in batches with a stable per-batchIdempotency-Keyreused across retries. A force-quit mid-flush leaves the pending batch on disk; the next launch rehydrates and re-sends with the same key. - Async hydration, sync reads after. Identity + super-properties + entitlements load from
AsyncStorageon start. Thereadypromise resolves once hydration completes;track()calls before hydration are deferred (not lost) and ship in order after. Post-hydration, every read is synchronous. - Microsecond entitlement gates.
Crossdeck.isEntitled("pro")is a synchronous in-memory cache read scoped to the currently-identifieddeveloperUserId— safe inside a tap handler. Switching users viaidentify(...)unconditionally clears the cache; a freshly-identified user never sees the prior user's entitlements. - React Native error capture done right.
ErrorUtils.setGlobalHandlerchains into your existing handler (Sentry, Bugsnag, RN's redbox in dev).global.fetchwrapper catches outbound HTTP failures, skipping calls to the Crossdeck ingest endpoint itself to prevent feedback loops. - PII scrubbing on by default. Emails are replaced with
<email>, payment-card numbers with<card>, recursively across nested objects and arrays — same vocabulary as the Web, Node, and Swift SDKs. The persisted AsyncStorage queue never holds raw PII. - Peer dependencies only.
react-nativeand@react-native-async-storage/async-storagearepeerDependencies— no hidden version pin from us. Compatible with Expo SDK 49+ and bare React Native 0.71+.
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
| Runtime | Minimum supported | Notes |
|---|---|---|
| React Native | 0.71+ | The SDK uses ErrorUtils.setGlobalHandler + AppState + Platform — all stable since 0.60. |
| Expo SDK | 49+ | Use npx expo install to pin the Expo-blessed AsyncStorage version. |
| Hermes | Default | Stack-trace parsing supports Hermes, JSC, and V8 (for visionOS / debug builds). |
| iOS | 13.0+ | Matches RN 0.71 minimum. |
| Android | API 21+ (Android 5.0) | Matches RN 0.71 minimum. |
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:
- Durable batched event delivery —
track()returns sync; the SDK flushes atbatchSize(20) orflushIntervalMs(5 s), whichever fires first. AppStateobservers wired automatically — when the app backgrounds, the buffer persists toAsyncStorageand a best-effort flush runs.- An
anonymousIdminted on first launch, persisted to AsyncStorage, used as the identity envelope on every event untilidentify(...)is called. - PII scrubbing on by default — emails and card numbers replaced with
<email>/<card>tokens before the event ever leaves the device. - Per-batch
Idempotency-Keyguarantees the server dedups retries — the same payload never lands twice. - The entitlement cache is empty until you call
identify(...)+ warm it from the server. Once warmed,isEntitled(...)is a synchronous read.
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.
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:
- Within 200 ms of
Crossdeck.readyresolving — the heartbeat request lands.projects/{p}.appHeartbeats.{appId}+projects/{p}.sdkHeartbeats.react-native.{env}both write. - Within ~5 s of dashboard refresh — the "React Native SDK" row on Dashboard → SDKs flips green.
- 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:
- Synchronous construction.
Crossdeck.init({...})returns immediately with a usable client. Identity, super-properties, and the entitlement cache all start with sensible empty defaults. - 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(...)returnsfalseduring hydration (not "unknown" — the cache is genuinely cold). - 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:
NaNandInfinity→null(no JSON representation); warning kindnon_serialisable.- Strings longer than 1 024 chars → truncated with a trailing
…; warning kindtruncated_string. - Circular object graphs → replaced with
"[circular]"; warning kindcircular_reference. DAG-safe ancestor-stack walking — diamond shapes pass, true cycles are caught. - Nesting deeper than 5 levels → replaced with
"[depth-exceeded]"; warning kinddepth_exceeded. Tune viaqueueConfig.validation.maxDepth. - Functions, symbols, and
undefined→ dropped from the property bag; warning kindsdropped_function/dropped_symbol/dropped_undefined. - Empty event names are the only failure mode that throws —
CrossdeckError(code: "missing_event_name").
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:
- Buffer cap: 1000 events. Above that, the OLDEST events are evicted (the newest signal is most likely the most diagnostically useful).
- Batch size: default 20 events per HTTP POST. Configurable via
queueConfig.batchSize. - Per-batch
Idempotency-Key: minted once on first send, reused across every retry of the same batch. The server short-circuits duplicates by key. - Pending-batch slot: when a flush starts, the head-of-queue is MOVED into a dedicated
pendingBatchslot. Newenqueuecalls go into the fresh buffer behind it. The pending slot is only cleared when the server confirms success — a force-quit mid-flush leaves the pending batch persisted to AsyncStorage for the next launch to re-send with the same idempotency key. - 4xx hard stop: permanent client errors drain through the
onPermanentFailurecallback and are NOT retried. Retrying a payload the server has definitively rejected wastes battery and blocks newer events behind a dead batch. - Retry policy: exponential backoff with full jitter on
408/429/5xx. HonoursRetry-Afterheaders even above the localmaxMscap, clamped at 24 h as a sanity ceiling.
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.
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:
- Uncaught JS errors (opt-in via
captureUncaughtErrors: true). The SDK installsErrorUtils.setGlobalHandler, chains into any prior handler (Sentry, Bugsnag, RN redbox in dev), and ships the error before the prior handler runs. - fetch failures (opt-in via
captureFetchErrors: true). Wrapsglobal.fetchto capture HTTP failures from outbound requests. Skips requests to the Crossdeck ingest endpoint itself to prevent feedback loops. - Manual
Crossdeck.captureError(error)— call from atry/catchto 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:
- Hermes — the default RN engine since 0.70. Stack format includes byte-offset positions.
- JSC (JavaScriptCore) — pre-Hermes default. Stack format includes column numbers and is different from Hermes.
- V8 — used by some debug-build configurations (e.g. Chrome remote debugging).
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 change | SDK behaviour |
|---|---|
active → background | Persist entire buffer + pending batch to AsyncStorage immediately. Best-effort flush() using the platform's background-execution budget. |
background → active | Schedule a flush to ship anything buffered while we were away. |
| Force-quit while suspended | Nothing 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:
- Email addresses →
<email> - Payment card numbers (13–19 digits, optional separators) →
<card>
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:
@react-native-async-storage/async-storagev1.21+ ships its own bundledPrivacyInfo.xcprivacydeclaringNSPrivacyAccessedAPICategoryFileTimestamp(reasonC617.1) — automatically merged into your iOS build, no action required on your side.- The SDK timestamps every event with
Date.now(), which the JavaScript runtime resolves viamach_absolute_time— counted asNSPrivacyAccessedAPICategorySystemBootTime. Add reason35F9.1to your app target'sPrivacyInfo.xcprivacy:
<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 type | Collected? | Purpose | Encrypted in transit? | User can request deletion? |
|---|---|---|---|---|
| User IDs | Yes — developerUserId passed to identify() | Analytics, app functionality | Yes (HTTPS / TLS) | Yes — via forget() |
| Email addresses | Optional — only if passed to identify(userId, { email }) | Analytics | Yes | Yes — via forget() |
| App interactions (app activity) | Yes — track() events | Analytics | Yes | Yes — via forget() |
| Crash logs | Optional — only if captureUncaughtErrors: true | App functionality (diagnostics) | Yes | Yes — 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
| Option | Type | Default | Notes |
|---|---|---|---|
appId | string | — | Required. Crossdeck App ID from the dashboard (e.g. app_rn_xxx). |
publicKey | string | — | Required. 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. |
baseUrl | string | https://api.cross-deck.com/v1 | Override for self-hosted setups or the local emulator. |
persistIdentity | boolean | true | Persist anonymousId + crossdeckCustomerId across launches via AsyncStorage. Set false to defer writes until consent. |
storage | KeyValueStorage | AsyncStorage adapter | Custom storage adapter (SecureStore, MMKV, encrypted vault) for higher-security app shells. |
storagePrefix | string | "crossdeck:" | Prefix for SDK-persisted state keys. |
autoHeartbeat | boolean | true | GET /v1/sdk/heartbeat on init. Backend reads appId + environment from the API key — no body. Disable for high-frequency boot scenarios (CI, tests). |
eventFlushBatchSize | number | 20 | Events per HTTP POST. |
eventFlushIntervalMs | number | 5000 | Idle ms after last track() before flushing. |
appVersion | string | undefined | Auto-attached to every event as properties.appVersion for build slicing. |
debug | boolean | false | Enable structured signal emission to console + the DebugEmitter. |
API reference
Lifecycle
Crossdeck.init(options): void— boot the SDK. Synchronous; kicks off async hydration in the background. Idempotent — re-init with the same options is a no-op; new options tear down listeners + replace the configuration.Crossdeck.flush(): Promise<void>— drain. Resolves when the in-flight batch resolves.Crossdeck.heartbeat(): Promise<HeartbeatResponse>— manually GET/sdk/heartbeat. Auto-fires once on init unlessautoHeartbeat: false.Crossdeck.setDebugMode(enabled: boolean): void— toggle structured signal emission at runtime.Crossdeck.diagnostics(): Diagnostics— snapshot of started/anonymousId/crossdeckCustomerId/developerUserId/sdkVersion/baseUrl/queue stats.
Identity
Crossdeck.identify(userId, options?): Promise<AliasResult>— POST/identity/alias; resolves with the canonicalcrossdeckCustomerId.Crossdeck.forget(): Promise<void>— GDPR right-to-be-forgotten. POSTs/identity/forget, then localreset().Crossdeck.reset(): void— sign-out path. Regenerates anonymousId, clears developerUserId + entitlement cache + super-properties + breadcrumbs.
Events
Crossdeck.track(name, properties?): void— synchronous validate + enqueue. ThrowsCrossdeckError(missing_event_name)ifnameis empty; sanitises invalid property values in-place with debug warnings, never throws on a bad property.Crossdeck.register(properties: Record<string, unknown>): Record<string, unknown>— merge into super-properties; returns the merged bag.Crossdeck.unregister(key): voidCrossdeck.getSuperProperties(): Record<string, unknown>Crossdeck.group(type, id, traits?): void— attach B2B group context ($groups.<type>) to every subsequent event. Passid: nullto clear a membership. Platform parity: shipped in Web, Node, and React Native today; not yet exposed on the Swift or Android native SDKs (planned). See the feature parity matrix.
Entitlements
Crossdeck.getEntitlements(): Promise<PublicEntitlement[]>— GET/entitlements; hydrates the cache.Crossdeck.isEntitled(key): boolean— synchronous cache read; honours per-entitlementvalidUntil.Crossdeck.listEntitlements(): PublicEntitlement[]— full snapshot of the cached set.Crossdeck.onEntitlementsChange(listener): () => void— subscribe; returns an unsubscribe.Crossdeck.syncPurchases(input): Promise<PurchaseResult>— POST/purchases/syncwith StoreKit / Play receipt evidence; hydrates the cache with the projected set.
Errors
Crossdeck.captureError(error, options?): voidCrossdeck.captureMessage(message, level?): voidCrossdeck.addBreadcrumb(crumb): voidCrossdeck.setTag(key, value): voidCrossdeck.setTags(tags): voidCrossdeck.setContext(name, data): voidCrossdeck.setErrorBeforeSend(handler | null): void— runtime-replaceable hook.
Consent
Crossdeck.consent(state: Partial<ConsentState>): ConsentState— merge update; returns the merged full state.Crossdeck.consentStatus(): ConsentState— read the current consent state.
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
| Signal | Fires when |
|---|---|
sdk.start | Crossdeck client successfully started. |
sdk.stop | stop() called. |
sdk.configured | Client successfully started + ready to accept track/identify. |
sdk.first_event_sent | First event landed at the ingest endpoint. One-shot per process — dashboard onboarding checklist keys off this signal. |
sdk.queue_persisted | Buffer mutation persisted to AsyncStorage (per-enqueue + per-flush, including the pending-batch slot). |
sdk.queue_restored | Queue rehydrated from AsyncStorage on init. |
sdk.flush_retry_scheduled | Retryable failure scheduled. Payload: attempt, delay_ms. |
sdk.flush_permanent_failure | Permanent 4xx / retry-exhausted / buffer-overflow drop. Routed to onPermanentFailure when set. |
sdk.entitlement_cache_used | isEntitled answered from local cache. |
identity.reset | reset() applied. |
sdk.consent_denied | track() or $error dropped because the relevant consent channel is off. Payload: channel. |
Troubleshooting
"AsyncStorage native module not found" at runtime
- You installed
@cross-deck/react-nativebut not the peer dep. Runnpm install @react-native-async-storage/async-storage && cd ios && pod install(bare RN) ornpx expo install @react-native-async-storage/async-storage(Expo).
Events not appearing in the dashboard
- Check that
setConsent({ analytics: true })has been called — both channels default to GRANT — check that consent has not been opt-out via Crossdeck.consent({ analytics: false }). - Confirm
publicKeymatches the project. Test keys (cd_pub_test_…) and live keys (cd_pub_live_…) route to separate event streams. - Enable
debugLoggerand watch forsdk.first_event_sententries. - Confirm
Crossdeck.readyhas resolved before checking — pre-hydrationtrack()calls are buffered, not lost.
sdk.flush_permanent_failure in logs
401 unauthorized— publishable key wrong or revoked, orenvironmentdoesn't match the key prefix (cd_pub_live_→ must be"production";cd_pub_test_→ must be"sandbox"). Rotate via the dashboard's API Keys screen.422 unprocessable_entity— payload shape rejected. Themessageon theCrossdeckErrorhanded toonPermanentFailuretells you which field.
isEntitled() returns false for a paying user right after launch
- The cache is async-hydrated. Either
await Crossdeck.readybefore the check, or treat the synchronous read as "still loading" UI (skeleton state).
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.
| Capability | Web | Node | React Native | Swift | Android |
|---|---|---|---|---|---|
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 Billing | — | — | SDK ✓ · backend v1.1 | — | SDK ✓ · 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 capture | n/a | n/a | — | n/a | — |
PII scrubber (<email> / <card> tokens) | ✓ | ✓ | ✓ | ✓ | ✓ |
Validation: maxStringLength default | 1 024 | 1 024 | 1 024 | 1 024 | 1 024 |
Validation: maxDepth default | 5 | 5 | 5 | 32 | 32 |
| Default consent state | Granted | Granted | Granted | Granted | Granted |
| Durable queue storage | localStorage | Memory (server lifecycle) | AsyncStorage | UserDefaults | SharedPreferences |
| Privacy manifest bundled | n/a | n/a | Inherited from AsyncStorage; app target adds SystemBootTime | App target declares UserDefaults | n/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.