@sendoracloud/sdk-react-native
v1.16.0
Published
Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `np
Maintainers
Readme
@sendoracloud/sdk-react-native
Official React Native + Expo SDK for SendoraCloud.
📖 First time on React Native? Read the 5-minute quickstart — it covers the Hermes CSPRNG polyfill, anonymous-first auth, push token receipts, and the demo end-to-end push send.
Expo Go vs Dev Client — pick the right runner
| Feature | Expo Go (SDK 53+) | Dev Client / EAS Build | Bare RN |
| --- | --- | --- | --- |
| Analytics (track, screen, identify) | ✅ | ✅ | ✅ |
| Auth (signUp, signIn, MFA, passkeys) | ✅ | ✅ | ✅ |
| Deep links (warm path) | ✅ | ✅ | ✅ |
| Deferred deep links (fingerprint) | ✅ | ✅ | ✅ |
| Deferred deep links (Play Install Referrer) | ❌ — needs react-native-play-install-referrer | ✅ | ✅ |
| Push token registration (iOS APNs / FCM) | ❌ — Expo Go dropped remote push in SDK 53 | ✅ | ✅ |
If push notifications matter to you, you MUST move off Expo Go. Plain Expo Go cannot receive remote push tokens since SDK 53 (Expo deprecated the shared APNs cert). Run a Dev Client instead:
# One-time: build the dev client locally
npx expo prebuild --clean
npx expo run:ios # or run:android
# Day-to-day: just `npx expo start` — same DX as Expo GoOr use EAS Build for cloud builds. Auth, analytics, deep links all work fine in plain Expo Go — only push registration requires Dev Client.
💡 If
registerPushTokenreturnsnullor sign-up succeeds but the dashboard never shows your device token, you're almost certainly on Expo Go. Switch to a Dev Client.
Install
npx expo install @sendoracloud/sdk-react-native @react-native-async-storage/async-storageThat's it. Since 0.17.0 the SDK bundles react-native-get-random-values as a hard dependency and auto-applies the polyfill at SDK load — no manual import "react-native-get-random-values" in your entry file required.
@react-native-async-storage/async-storage is a required peer dependency used to persist the anonymous device id across app restarts.
Upgrading from 0.16.x? You can delete the
import "react-native-get-random-values";line from yourindex.js/index.tsxentry file — it's now a no-op (the SDK side-effect-loads the polyfill itself). Leaving it in does no harm.
Upgrading from 0.x to 1.0? Delete the
orgId(and any legacypublicKey) fields from yourSendoraCloud.init({...})call — the backend now resolves the org from the API key server-side. All public method signatures are unchanged.await SendoraCloud.init({ apiKey: "pk_prod_...", - orgId: "<ORG_ID>", });
Optional peers (deep links)
| Peer | When to install |
| --- | --- |
| expo-localization | Improves device-fingerprint locale accuracy. Most Expo apps already have it. |
| expo-crypto | Hardens the SHA-256 used by computeDeviceFingerprint(). Auto-used when installed; SDK falls back to web crypto and then a pure-JS impl if not. |
| react-native-play-install-referrer | Android-only. Enables 100%-accurate deferred deep-link matching via Play Install Referrer. SDK auto-probes — no-op on iOS or when uninstalled. |
Quick start
import SendoraCloud from "@sendoracloud/sdk-react-native";
// App.tsx or wherever you initialise app-level services
await SendoraCloud.init({
apiKey: "pk_prod_...", // from Settings → API Keys in the dashboard
iosBundleId: "com.yourapp", // bundle-id gate — required for SDK link mint
androidPackageName: "com.yourapp",
// linkDomain: "pulse.link", // optional — only if you use a custom share host
});
// Identify the signed-in user
SendoraCloud.identify("user-123", { email: "[email protected]" });
// Track product events + screen views
SendoraCloud.track("signup.completed", { plan: "growth" });
SendoraCloud.screen("Pricing");
// Push tokens
const receipt = await SendoraCloud.registerPushToken({
token: expoPushToken,
platform: Platform.OS as "ios" | "android",
bundleId: "com.yourapp",
});
// On logout
await SendoraCloud.reset();API surface
| Method | Description |
| --- | --- |
| init(config) | Initialize the SDK. Call once at app startup. |
| identify(userId, traits?) | Associate a user id (+ optional traits) with the device. |
| track(event, props?) | Fire a custom product event. |
| screen(name, props?) | Track a screen view. |
| registerPushToken(reg) | Register an APNs or FCM push token. |
| auth.* | Sign-in / sign-up / MFA / passkeys / OIDC / SSO + auth.getRefreshToken() + auth.merge(anonRefresh, targetRefresh?). See auth.ts. |
| links.create<T>(input, opts?) | Mint a Sendora deep link. Generic T for typed linkData. |
| links.prewarm<T>(input, opts?) | Background-mint + cache so share-tap is instant. |
| links.handleUniversalLink(url) | Resolve a warm-path Universal Link / App Link delivery. |
| links.matchDeferred(input?) | Cold-launch deferred match. Auto-probes Play Install Referrer + fingerprint. |
| links.attachLinkingApi({ onLegacyUrl }) | One-call replacement for Linking.getInitialURL + addEventListener + extract-pre-check. |
| links.onLinkOpened<T>(cb) | Register a callback fired for both warm + deferred opens. |
| links.revoke(shortcode) | Soft-delete a link (private-content unsend). Idempotent. |
| links.getStats(shortcode) | { totalClicks, uniqueClicks, deferredMatches, byOs, byCountry, byDevice }. |
| computeDeviceFingerprint() | Standalone — returns canonical hex SHA-256. Identical recipe across all 3 mobile SDKs. |
| reset() | Clear identity + rotate anonymous id. Call on logout. |
| getAnonymousId() | Stable device id (persisted across restarts). |
| getUserId() | Currently identified user id, or null. |
Deep links (Branch / Firebase Dynamic Links parity)
import SendoraCloud, { LinkError } from "@sendoracloud/sdk-react-native";
// 1. Typed linkData — define your app's deep-link shape once.
interface ArticleLink extends Record<string, unknown> {
type: "article";
articleId: string;
category: "tech" | "design" | "policy";
sharedBy: string;
}
// 2. Prewarm on row mount → share tap completes instantly.
SendoraCloud.links.prewarm<ArticleLink>(
{
title: `Share: ${article.title}`,
iosDeepLinkPath: `/articles/${article.id}`,
androidDeepLinkPath: `/articles/${article.id}`,
ogTitle: article.title,
linkData: { type: "article", articleId: article.id, category: article.category, sharedBy: currentUser.id },
// fallbackUrl optional — backend defaults from your project's apps registry.
},
{ key: `article:${article.id}` },
);
// 3. Mint on tap — returns from prewarm cache when keys match.
try {
const link = await SendoraCloud.links.create<ArticleLink>(input, { key: `article:${article.id}` });
await Share.share({ message: link.url });
} catch (err) {
if (err instanceof LinkError) {
switch (err.code) {
case "BUNDLE_MISMATCH": /* register bundle in Dashboard → Apps */ break;
case "PLAN_LIMIT": /* upgrade plan */ break;
case "RATE_LIMITED": /* back off + retry */ break;
case "FALLBACK_REQUIRED": /* configure App Store URL in apps registry */ break;
}
}
}
// 4. App-root router — one call replaces the Linking boilerplate.
useEffect(() => {
const offOpen = SendoraCloud.links.onLinkOpened<ArticleLink>((event) => {
if (event.linkData.type === "article") navigateToArticle(event.linkData.articleId);
});
let detach: (() => void) | undefined;
void SendoraCloud.links
.attachLinkingApi({ onLegacyUrl: (url) => myExistingRouter.handle(url) })
.then((d) => { detach = d; });
// Cold-launch deferred match — auto-picks installReferrer / fingerprint.
void SendoraCloud.links.matchDeferred();
return () => { detach?.(); offOpen(); };
}, []);
// 5. Revoke + stats — no dashboard scraping.
await SendoraCloud.links.revoke("ab3xk9p");
const stats = await SendoraCloud.links.getStats("ab3xk9p");
// → { totalClicks, uniqueClicks, deferredMatches, byOs, byCountry, byDevice }Full paste-ready recipes live in examples/links-share.tsx and examples/links-router.tsx.
Typed errors
LinkError is thrown for every backend rejection. Branch on err.code (a typed LinkErrorCode) rather than string-matching err.message:
"BUNDLE_MISMATCH" | "DATA_TOO_LARGE" | "EXPIRED" | "NETWORK" | "RATE_LIMITED"
| "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_INPUT" | "PLAN_LIMIT"
| "FALLBACK_REQUIRED" | "SERVER" | "UNKNOWN"Bundle-id gate
init() accepts iosBundleId / androidPackageName. The SDK auto-forwards them on every links.create() / links.matchDeferred() call. The backend cross-checks against your project's apps registry (Dashboard → Apps); a leaked public key plus a wrong bundle returns LinkError(code: "BUNDLE_MISMATCH", statusCode: 422). Fail-closed when zero apps are registered — register your iOS bundle id + Android package name + SHA-256 fingerprint before going to prod.
Auth
SendoraCloud.auth covers anonymous sign-in, email + password, magic link, email OTP, TOTP MFA, recovery codes, OIDC / SAML SSO, Sign in with Apple, Google, Microsoft, LinkedIn, Facebook, Discord. See auth quickstart.
Anonymous → identified lifecycle
The SDK creates an anonymous user on first init(). Every track() / identify() attaches to that user_id until the user signs up or signs in. Two transitions exist; both preserve analytics continuity, but the second one requires one extra line of code from your app:
Case 1 — fresh signup. auth.signUp(email, password) detects the anonymous refresh token and calls /auth-service/upgrade first, which flips is_anonymous=false + sets the password on the SAME row. The user_id is preserved — no funnel break. Falls back to a fresh /signup if the bound user is already identified (rare race).
const { user } = await SendoraCloud.auth.signUp(email, password);
// user.id === the same id the anon user had. Done.Case 2 — email already exists. signUp() throws EmailAlreadyTakenError without consuming the anon refresh. You fall back to signIn() — but signIn() wipes the local anon identity, so anon events stay orphaned on the backend unless you transfer them with auth.merge(anonRefresh). Capture the anon refresh BEFORE the signIn:
try {
const { user } = await SendoraCloud.auth.signUp(email, password);
} catch (err) {
if (!(err instanceof EmailAlreadyTakenError)) throw err;
const anonRefresh = await SendoraCloud.auth.getRefreshToken();
const { user } = await SendoraCloud.auth.signIn(email, password);
// Best-effort, silent-fail-tolerant — anon row may be already drained.
if (anonRefresh) {
SendoraCloud.auth.merge(anonRefresh).catch(() => undefined);
}
}Why merge isn't automatic on signIn. Two people sharing a device (browser kiosk, family iPad) would silently get their anon-session events combined with a third party's real account. Forcing the merge call into your app keeps the data-mixing decision explicit. Wrap signIn() with the snippet above if you want auto behaviour.
Full reference + edge-case writeup: /docs/quickstart-react-native#anon-existing-email.
Config
{
apiKey: string; // Required. pk_(prod|staging|dev)_<16+ alphanumerics>.
apiUrl?: string; // Defaults to https://api.sendoracloud.com
environment?: "prod" | "staging" | "dev";
projectId?: string;
consentedByDefault?: boolean; // Defaults to true.
autoTrack?: boolean | { appOpen?: boolean; sessionStart?: boolean; appBackground?: boolean };
sessionIdleMs?: number; // Idle threshold for session expiry. Default 30 min.
iosBundleId?: string; // Bundle-id gate input for links.create / matchDeferred.
androidPackageName?: string;
linkDomain?: string; // Custom share host (e.g. "pulse.link"). Defaults to Sendora's.
debug?: boolean;
}Stability
This SDK is on the 1.x line. Patch bumps (1.x.y → 1.x.z) are backwards-compatible; minor bumps may include opt-in feature additions. Breaking changes only on major bumps — always read the CHANGELOG before upgrading.
JWT verification (custom backend)
If your backend verifies Sendora-issued access tokens directly (rather than calling Sendora APIs that re-verify on every hit), use the OIDC-standard JWKS auto-discovery pattern — never hardcode the JWKS URL:
import { createRemoteJWKSet, jwtVerify, decodeJwt } from "jose";
const JWKS_BY_ISS = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function getJwks(iss: string) {
if (!JWKS_BY_ISS.has(iss)) {
JWKS_BY_ISS.set(iss, createRemoteJWKSet(new URL(`${iss}/.well-known/jwks.json`)));
}
return JWKS_BY_ISS.get(iss)!;
}
const claims = decodeJwt(token);
const { payload } = await jwtVerify(token, getJwks(claims.iss as string));The iss claim is a per-org Sendora URL (e.g. https://api.sendoracloud.com/api/v1/auth-service/<orgId>); appending /.well-known/jwks.json resolves on the same host. This means a customer can rotate signing keys without your code redeploying.
Security
- Secret keys refused on the client. Only
pk_*keys accepted;sk_*throws at init. - HTTPS enforced outside local dev. Non-
https:apiUrlvalues are rejected unlesslocalhost/127.0.0.1+environment != "prod". - AsyncStorage backs the anonymous id for cross-restart identity; never cross-device.
reset()awaits the storage writes. - Use opaque userIds, not emails. Persisted to AsyncStorage plaintext. Put email in
traitsinstead. - Push token + metadata capped at 4 KB each.
- Deep-link bundle-id gate — a leaked public key cannot mint links pointed at someone else's app.
License
MIT
