# React Native SDK reference

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

Reference
Current version: 1.7.1
·
~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

- **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 to `AsyncStorage` on every enqueue, and ships in batches with a stable per-batch `Idempotency-Key` reused 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 `AsyncStorage` on start. The `ready` promise 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-identified `developerUserId` — safe inside a tap handler. Switching users via `identify(...)` unconditionally clears the cache; a freshly-identified user never sees the prior user's entitlements.

- **React Native error capture done right.** `ErrorUtils.setGlobalHandler` chains into your existing handler (Sentry, Bugsnag, RN's redbox in dev). `global.fetch` wrapper 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 ` `, payment-card numbers with ` `, 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-native` and `@react-native-async-storage/async-storage` are `peerDependencies` — 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.

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:

- Durable batched event delivery — `track()` returns sync; the SDK flushes at `batchSize` (20) or `flushIntervalMs` (5 s), whichever fires first.

- `AppState` observers wired automatically — when the app backgrounds, the buffer persists to `AsyncStorage` and a best-effort flush runs.

- An `anonymousId` minted on first launch, persisted to AsyncStorage, used as the identity envelope on every event until `identify(...)` is called.

- PII scrubbing on by default — emails and card numbers replaced with ` ` / ` ` tokens before the event ever leaves the device.

- Per-batch `Idempotency-Key` guarantees 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.

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:

- **Within 200 ms of `Crossdeck.ready` resolving** — 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(...)` returns `false` during 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 ` that resolves with the canonical `crossdeckCustomerId` (cdcust_…). Throws `CrossdeckError` with code `missing_user_id` if `userId` is empty.

```
await Crossdeck.identify("user_847", {
  email: "wes@example.com",
  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:

- `NaN` and `Infinity` → `null` (no JSON representation); warning kind `non_serialisable`.

- Strings longer than 1 024 chars → truncated with a trailing `…`; warning kind `truncated_string`.

- Circular object graphs → replaced with `"[circular]"`; warning kind `circular_reference`. DAG-safe ancestor-stack walking — diamond shapes pass, true cycles are caught.

- Nesting deeper than 5 levels → replaced with `"[depth-exceeded]"`; warning kind `depth_exceeded`. Tune via `queueConfig.validation.maxDepth`.

- Functions, symbols, and `undefined` → dropped from the property bag; warning kinds `dropped_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 `pendingBatch` slot. New `enqueue` calls 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 `onPermanentFailure` callback 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`. Honours `Retry-After` headers even above the local `maxMs` cap, 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.

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:

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

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

- **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, { level: "error" });
}
```

### 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** → ` `

- **Payment card numbers** (13–19 digits, optional separators) → ` `

The tokens (` ` / ` `) 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-storage` v1.21+ ships its own bundled `PrivacyInfo.xcprivacy` declaring `NSPrivacyAccessedAPICategoryFileTimestamp` (reason `C617.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 via `mach_absolute_time` — counted as `NSPrivacyAccessedAPICategorySystemBootTime`. Add reason `35F9.1` to your *app target's* `PrivacyInfo.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](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/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 ` — drain. Resolves when the in-flight batch resolves.

- `Crossdeck.heartbeat(): Promise ` — manually GET `/sdk/heartbeat`. Auto-fires once on init unless `autoHeartbeat: 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 ` — POST `/identity/alias`; resolves with the canonical `crossdeckCustomerId`.

- `Crossdeck.forget(): Promise ` — GDPR right-to-be-forgotten. POSTs `/identity/forget`, then local `reset()`.

- `Crossdeck.reset(): void` — sign-out path. Regenerates anonymousId, clears developerUserId + entitlement cache + super-properties + breadcrumbs.

### Events

- `Crossdeck.track(name, properties?): void` — synchronous validate + enqueue. Throws `CrossdeckError(missing_event_name)` if `name` is empty; sanitises invalid property values in-place with debug warnings, never throws on a bad property.

- `Crossdeck.register(properties: Record ): Record ` — merge into super-properties; returns the merged bag.

- `Crossdeck.unregister(key): void`

- `Crossdeck.getSuperProperties(): Record `

- `Crossdeck.group(type, id, traits?): void` — attach B2B group context (`$groups. `) to every subsequent event. Pass `id: null` to 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](#parity).

### Entitlements

- `Crossdeck.getEntitlements(): Promise ` — GET `/entitlements`; hydrates the cache.

- `Crossdeck.isEntitled(key): boolean` — synchronous cache read; honours per-entitlement `validUntil`.

- `Crossdeck.listEntitlements(): PublicEntitlement[]` — full snapshot of the cached set.

- `Crossdeck.onEntitlementsChange(listener): () => void` — subscribe; returns an unsubscribe.

- `Crossdeck.syncPurchases(input): Promise ` — POST `/purchases/sync` with StoreKit / Play receipt evidence; hydrates the cache with the projected set.

### Errors

- `Crossdeck.captureError(error, options?): void`

- `Crossdeck.captureMessage(message, level?): void`

- `Crossdeck.addBreadcrumb(crumb): void`

- `Crossdeck.setTag(key, value): void`

- `Crossdeck.setTags(tags): void`

- `Crossdeck.setContext(name, data): void`

- `Crossdeck.setErrorBeforeSend(handler | null): void` — runtime-replaceable hook.

### Consent

- `Crossdeck.consent(state: Partial ): 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-native` but not the peer dep. Run `npm install @react-native-async-storage/async-storage && cd ios && pod install` (bare RN) or `npx 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 `publicKey` matches the project. Test keys (`cd_pub_test_…`) and live keys (`cd_pub_live_…`) route to separate event streams.

- Enable `debugLogger` and watch for `sdk.first_event_sent` entries.

- Confirm `Crossdeck.ready` has resolved before checking — pre-hydration `track()` calls are buffered, not lost.

### sdk.flush_permanent_failure in logs

- `401 unauthorized` — publishable key wrong or revoked, or `environment` doesn'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. The `message` on the `CrossdeckError` handed to `onPermanentFailure` tells you which field.

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

- The cache is async-hydrated. Either `await Crossdeck.ready` before the check, or treat the synchronous read as "still loading" UI (skeleton state).

## Versioning & changelog

The React Native SDK follows [SemVer](https://semver.org/). Breaking changes only ship in a major-version bump. Full release notes live in the public repo's [CHANGELOG.md](https://github.com/Crossdeckhq/crossdeck-react-native/blob/main/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 ` ` / ` ` 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.

## Related

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/)

- [Node SDK reference](https://cross-deck.com/docs/node-sdk/)

- [Swift SDK reference (iOS / macOS / tvOS / watchOS)](https://cross-deck.com/docs/swift-sdk/)

- [API keys & authentication](https://cross-deck.com/docs/api-keys/)
