Skip to main content

React Native SDK Integration

React Native wrapper that bridges the native iOS and Android SDKs, giving you full platform capabilities with a single TypeScript API.

iOS 14.0 minimum

The native SDK requires iOS 14.0, while React Native 0.75 still defaults to 13.4. CocoaPods rejects the mismatch with a misleading error - "Specs satisfying the dependency were found, but they required a higher minimum deployment target" - which reads like a version-resolution problem rather than a platform one.

Raise it in your ios/Podfile:

platform :ios, [min_ios_version_supported, '14.0'].max

Taking the higher of the two keeps working when a future React Native raises its own floor past ours.

Then raise the app's own deployment target to 14.0 or higher as well: in Xcode, select the app target, then General → Minimum Deployments → iOS (the IPHONEOS_DEPLOYMENT_TARGET build setting in ios/<YourApp>.xcodeproj). A fresh React Native 0.75 app stays at 13.4 there after the Podfile change, and the linker then warns that the Joryio objects were built for a newer iOS version than the app.

Features​

  • Native Bridge - Wraps native iOS (Swift) and Android (Kotlin) SDKs
  • Event Tracking - Track custom events and screen views
  • User Identity - Identify, add labelled aliases, and manage user attributes
  • Push Notifications - Register tokens, track clicks (FCM + APNs)
  • In-App Messaging - drawn by the native SDK with no code, or rendered yourself in React
  • E-Commerce - Track purchases, cart events, checkout
  • Offline Support - Events queued locally, synced when online
  • Auto-Retry - Exponential backoff on network failures

Requirements​

  • React Native 0.75+. On iOS the native SDK is linked as a Swift package through React Native's spm_dependency, which 0.75 introduced. With Expo, SDK 52 or newer.
  • Old and New Architecture. On the New Architecture the module runs through React Native's interop layer for legacy modules, which is on by default.
  • iOS 14.0+ / Android API 23+ (Android 6.0)
  • TypeScript 5.0+ (recommended)

Installation​

npm install @joryio/react-native-sdk

iOS Setup​

cd ios && pod install

pod install adds the Joryio iOS SDK to the Pods project as a Swift package (https://github.com/joryio/joryio-ios-sdk, products Joryio and JoryioUI, at exactly this package's version), and Xcode downloads it the first time it builds the app - so that build needs network access to github.com. Nothing comes from CocoaPods trunk, which stops accepting new versions on 2 December 2026.

React Native prints two [SPM] WARNING lines during pod install for every library that uses Swift packages: one asks you to reopen the workspace if you use Xcode 15 or earlier, the other notes that the package is linked statically. If your app then fails to link, install with dynamic frameworks: USE_FRAMEWORKS=dynamic pod install.

Upgrading from an earlier version

Earlier versions took the iOS SDK from CocoaPods (the Joryio/UI pod). If you added a pod 'Joryio...' line to your Podfile yourself - for example a :path pod pointing at a checkout - delete it, then run pod install again.

Android Setup​

Nothing to add. The package is autolinked (it ships a react-native.config.js), so PackageList already registers JoryioPackage - do not also add it to MainApplication by hand, which registers the module twice. The bridge depends on io.joryio:joryio-android-ui from Maven Central, at the same version as the package.

Expo​

The SDK is a native module, so it does not run in Expo Go - use a development build (npx expo prebuild or EAS Build) on Expo SDK 52 or newer. Expo's default iOS deployment target can be lower than the 14.0 the SDK needs; raise it with expo-build-properties in app.json:

{
"expo": {
"plugins": [["expo-build-properties", { "ios": { "deploymentTarget": "14.0" } }]]
}
}

Quick Start​

import { Platform } from 'react-native';
import Joryio from '@joryio/react-native-sdk';

// Initialize once in App.tsx
await Joryio.initialize(
Platform.select({
ios: 'jry_sdk_ios_YOUR_IOS_KEY', // iOS key from the dashboard
android: 'jry_sdk_android_YOUR_ANDROID_KEY', // Android key from the dashboard
})!,
'api-eu1.joryio.com', // API host (bare hostname, no scheme)
{
enableDebug: __DEV__,
trackSessionStart: true,
}
);

Each platform has its own SDK key: iOS keys start with jry_sdk_ios_, Android keys with jry_sdk_android_. A key the platform refuses makes initialize() log a warning and leaves the SDK off until you call it again with the right key.

Event Tracking​

Track Custom Events​

// Basic event
Joryio.track('Button Clicked');

// Event with properties
Joryio.track('Product Added', {
productId: 'SKU-123',
productName: 'Blue T-Shirt',
price: 29.99,
currency: 'USD',
});

// E-commerce: use Joryio.ecommerce (see E-Commerce below) - it sends the names and keys revenue reporting reads

Track Screen Views​

// In your screen components
Joryio.trackScreen('ProductDetail', { productId: 'SKU-123' });
Joryio.trackScreen('Cart');
Joryio.trackScreen('Checkout');

React Navigation Integration​

import { useRef } from 'react';
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';

function App() {
const navigationRef = useNavigationContainerRef();
const routeNameRef = useRef<string>();

return (
<NavigationContainer
ref={navigationRef}
onStateChange={() => {
const currentRouteName = navigationRef.current?.getCurrentRoute()?.name;
if (currentRouteName && currentRouteName !== routeNameRef.current) {
Joryio.trackScreen(currentRouteName);
routeNameRef.current = currentRouteName;
}
}}
>
{/* ... */}
</NavigationContainer>
);
}

User Identity​

Identify Users​

Send phone numbers with + and the country code

Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled

Call identify after login or when you know who the user is:

// After login
Joryio.identify('user-123');

// With attributes
Joryio.identify('user-123');
Joryio.setAttributes({
firstName: 'John',
plan: 'premium',
});

Aliases​

Attach an id from another system - a CRM id, a loyalty number - to the current person. It works for anonymous visitors too: the alias is stored against the anonymous id and stays with the person once they are identified.

Joryio.addAlias('crm_id', 'C-1042');

There is no unlabelled alias(userId); call identify(userId) to name the current person.

Reset (Logout)​

Clear user identity and start a new anonymous session:

Joryio.reset();

User Attributes​

// Set multiple attributes
Joryio.setAttributes({
firstName: 'John',
lastName: 'Doe',
plan: 'premium',
age: 28,
isVIP: true,
});

// Set a single attribute
Joryio.setAttribute('favoriteColor', 'blue');

// Increment a numeric attribute
Joryio.incrementAttribute('loginCount', 1);
Joryio.incrementAttribute('totalSpent', 29.99);

// Remove an attribute
Joryio.unsetAttribute('temporaryFlag');

A null value unsets the attribute, on both platforms: setAttribute('nickname', null) and setAttributes({ nickname: null }) do the same as unsetAttribute('nickname').

E-Commerce​

Track purchases and the steps before them with Joryio.ecommerce. It sends the standard event names and property keys Joryio's revenue reporting reads, so you type neither by hand:

Joryio.ecommerce.viewProduct({ productId: 'SKU-123', name: 'Blue T-Shirt', price: 29.99 });
Joryio.ecommerce.addToCart({ productId: 'SKU-123', name: 'Blue T-Shirt', price: 29.99, quantity: 2 });
Joryio.ecommerce.startCheckout(
[{ productId: 'SKU-123', name: 'Blue T-Shirt', price: 29.99, quantity: 2 }],
59.98, // cart value
);

// sent as "Order Completed" with orderId and total - the keys revenue reporting reads
Joryio.ecommerce.purchase({
orderId: 'ORD-789',
items: [{ productId: 'SKU-123', name: 'Blue T-Shirt', price: 29.99, quantity: 2 }],
value: 59.98,
currency: 'USD',
});

purchase sends an Order Completed event carrying orderId and total (the order's value) - the two keys revenue and attribution read. A hand-typed track('Order Completed', { order_id, value }) is stored as written and counts as a $0 order with no order id: there is deliberately no alias layer mapping other spellings. If you track a purchase yourself, use orderId and total.

The other calls: viewCategory(categoryId, categoryName), search(query, resultCount?), removeFromCart(product, quantity?), cartViewed(items, cartValue), updateCart(items, cartValue), addPaymentInfo(paymentMethod), orderFulfilled(orderId, trackingNumber?, carrier?), orderDelivered(orderId), orderCancelled(orderId, reason?), orderRefunded(orderId, refundAmount?, reason?), addToWishlist(product), removeFromWishlist(product), shareProduct(product, shareMethod), applyCoupon(couponCode, discountAmount?, discountType?), removeCoupon(couponCode), submitReview(productId, rating, reviewText?). ecommerce is also a named export: import { ecommerce } from '@joryio/react-native-sdk'.

A call missing a required argument - a purchase without orderId, items or a numeric value, a product without productId, a cart without items - logs a warning and sends nothing, so it never records a $0 order.

Push Notifications​

When to ask

Ask for the permission when the user has a reason to say yes. A prompt in the first seconds of a session is the one most often denied, and a denial is far harder to reverse than a delay - the system prompt is one-shot, so a "no" there is effectively permanent, while a later ask costs you nothing.

This applies whichever stack requests it. If Firebase Messaging owns the prompt, as below, the timing is yours; if you let the Joryio SDK own it, it never prompts on your behalf unless you set requestPushPermissionAtLaunch, which is off by default rather than unavailable.

Setup with Firebase (React Native Firebase)​

import { Platform } from 'react-native';
import messaging from '@react-native-firebase/messaging';

// Request permission
const authStatus = await messaging().requestPermission();

// Get and register token. Joryio sends iOS push through APNs, so on iOS
// register the APNs device token, not the FCM one.
const token = Platform.OS === 'ios'
? await messaging().getAPNSToken()
: await messaging().getToken();
if (token) {
const result = await Joryio.registerPushToken(token);
if (!result.success && result.isRetryable) {
// transient (offline, DNS, 5xx): register again when the network is back
}
}

// Listen for token refresh
messaging().onTokenRefresh((newToken) => {
// onTokenRefresh delivers the FCM token - on iOS too, at every app start.
// Register it on Android only; on iOS the APNs token above is the one.
if (Platform.OS === 'android') Joryio.registerPushToken(newToken);
});

On iOS register only the APNs device token. React Native Firebase's onTokenRefresh hands over the FCM token on iOS as well, at every app start, and registered it would replace the device's working APNs token with one APNs refuses - so register it on Android only, as above. On iOS registerPushToken refuses a token that is not an APNs token (hex) with success: false and isRetryable: false.

If the user has turned push off and you called unregisterPush(), do not register tokens from automatic callbacks (onTokenRefresh, a getToken() at launch) until they turn it back on: registerPushToken() is the explicit "push on" and lifts the unregister. Check your own push setting before registering.

registerPushToken resolves with the outcome (success, message, isUnauthorized, isRetryable). It never rejects, so ignoring the promise keeps the old fire-and-forget behaviour - but a transient failure then leaves the device unregistered until the next launch, so check isRetryable and register again. getDiagnostics().lastError carries the same isRetryable flag.

Handle Push Notification Clicks​

import messaging from '@react-native-firebase/messaging';

// When app is in background and notification is tapped
messaging().onNotificationOpenedApp((remoteMessage) => {
const trackingId = remoteMessage.data?.trackingId;
if (trackingId) {
Joryio.trackPushClick(trackingId);
}
});

// When app was killed and opened via notification
messaging()
.getInitialNotification()
.then((remoteMessage) => {
if (remoteMessage?.data?.trackingId) {
Joryio.trackPushClick(remoteMessage.data.trackingId);
}
});

Delivery receipts​

When your own messaging library receives the push (React Native Firebase, Expo notifications), the native SDK never sees it, so report the delivery yourself with the trackingId from the data payload:

messaging().onMessage(async (msg) => {
if (msg.data?.trackingId) Joryio.reportPushDelivered(String(msg.data.trackingId));
});
messaging().setBackgroundMessageHandler(async (msg) => {
if (msg.data?.trackingId) Joryio.reportPushDelivered(String(msg.data.trackingId));
});

Unregister​

Joryio.unregisterPush();

unregisterPush() stops pushes to this device. It stays in effect across restarts until you call registerPushToken() again.

Check Push Status​

const enabled = await Joryio.isPushEnabled();
console.log('Push enabled:', enabled);

Ask for permission​

if ((await Joryio.getPushPermissionStatus()) === 'notDetermined') {
// show your own primer first, then:
const status = await Joryio.requestPushPermission(); // 'granted' | 'denied' | 'notDetermined' | 'unavailable'
}

requestPushPermission() behaves the same on iOS and Android:

  • It always settles. On Android, if the Activity is recreated while the prompt is showing (a rotation, a theme change), it resolves with the status as it stands once the app is back in front.
  • When the user has opted out of tracking, the prompt is still shown and the result returned - whether the app may notify is the user's choice in the OS - but nothing is registered with or reported to Joryio while they stay opted out.

In-App Messaging​

Messages display on their own. Install the SDK, send a campaign, and it appears - drawn by the native views, using your app's fonts, colours and dark mode. There is nothing to wire up.

You only need the rest of this section if you want to render messages in React instead - see Taking over rendering.

Delivery tokens​

When the backend serves an eligible campaign it also issues a short-lived signed delivery token for it. The SDK echoes that token back when it reports an impression, click or dismissal, and the server verifies the signature before recording anything.

You do not have to do anything - the SDK handles this for you. It is documented because the server can be configured to require it. By default a report that carries no token is still accepted - the server logs it as a pre-token client - and only when the backend runs with IN_APP_REQUIRE_DELIVERY_TOKEN=true is that same report rejected:

POST /v1/in-app/track (no deliveryToken)
{ "success": false, "error": "A delivery token is required" }

A token that is present but does not verify - forged, tampered with or past its lifetime - is rejected whatever the setting.

The token is what makes an impression trustworthy: without it, anyone holding the SDK key - which ships inside every app and page - could report impressions and clicks for a campaign that was never shown, and your reporting would count them.

If in-app impressions stop being recorded on a backend that enforces tokens, check that the app is built against a current version of the SDK: a build made before delivery tokens existed sends no token.

Two kinds of message content​

Every message carries a kind that tells you which shape it is. Switch on it:

kindWhat it carriesHow to render it
'native'title, body, imageUrl, buttonsReact Native components - <Text>, <Image>, <Pressable>
'html'html, cssA WebView

Taking over rendering​

Subscribing with onInAppMessage stops the SDK drawing and hands each message to your code instead, so you can render it with React Native components. You will not get two copies.

Declare what your renderer can draw:

Joryio.onInAppMessage(handleMessage, {
capabilities: ['content.native'], // add 'content.html' only with a web view
});

Campaigns are targeted on what the device reports, and an undeclared capability is read as "cannot", not "unknown". Omitting the option keeps the previous behaviour, where the value came from a native config flag describing the native SDK's own views - which never run once you subscribe.

Do this when you want in-app messages to match the rest of your UI. The bridge itself depends on the native UI artifacts (io.joryio:joryio-android-ui on Android, the JoryioUI product of the Swift package on iOS), so they stay linked; subscribing only stops them drawing.

Removing your last subscription hands rendering back to the native SDK, so messages that arrive afterwards are drawn natively instead of being lost. An empty capabilities list means the same as leaving it out (not declared) on both platforms.

Button actions are dismiss, url, deep_link and request_push_permission - the "yes" of a push primer: close the message and call Joryio.requestPushPermission().

When to subscribe​

Since 1.3.2. Subscribe before initialize() has completed - at module scope, or in an effect that runs at start-up - and no message is ever drawn natively, not even the first one: the SDK holds in-app messages until initialize() has completed and your subscription has reached the native SDK, hands the held message to your listener, and gives rendering to the native SDK only if nobody subscribed.

If you subscribe later - on a screen that mounts after initialize() has completed - pass holdInAppMessagesForHandler: true to initialize(). Messages that are ready before you subscribe are then held (not drawn, not reported, nothing counted against a frequency cap) and handed to your listener when it subscribes. Calling disableInAppMessages() instead ends the hold and the native SDK draws the held message.

On Android the bridge gives the native SDK the current Activity at initialize(), so in-app messages can display in the very first session.

Listen for Messages​

import { useEffect, useState } from 'react';
import Joryio, { type InAppMessage } from '@joryio/react-native-sdk';

function App() {
const [message, setMessage] = useState<InAppMessage | null>(null);

useEffect(() => Joryio.onInAppMessage(setMessage), []);

return <>{message && <InAppMessageHost message={message} onClose={() => setMessage(null)} />}</>;
}

Render a native message​

Native content is text, not markup. Put it in a <Text> - passing it to a WebView or dangerouslySetInnerHTML would reintroduce exactly the injection risk native avoids.

function InAppMessageHost({ message, onClose }: { message: InAppMessage; onClose: () => void }) {
// Report 'displayed' once, when the message appears.
useEffect(() => {
Joryio.trackInAppImpression(message.id, 'displayed');
}, [message.id]);

// Report 'dismissed' whenever the message leaves the screen - after a click too.
const close = () => {
Joryio.trackInAppImpression(message.id, 'dismissed');
onClose();
};

if (message.kind === 'native') {
return (
<View>
{message.imageUrl && <Image source={{ uri: message.imageUrl }} />}
{message.title && <Text style={styles.title}>{message.title}</Text>}
<Text>{message.body}</Text>

{message.buttons.map((button) => (
<Pressable
key={button.id}
onPress={() => {
Joryio.trackInAppImpression(message.id, 'clicked', button.id);
if (button.action === 'url' && button.url) Linking.openURL(button.url);
if (button.action === 'request_push_permission') void Joryio.requestPushPermission();
close();
}}
>
<Text>{button.text}</Text>
</Pressable>
))}
</View>
);
}

// Escape `</` so the campaign's CSS cannot close the <style> tag.
const css = message.css.replace(/<\//g, '<\\/');
return <WebView source={{ html: `<style>${css}</style>${message.html}` }} />;
}

Apply the campaign's style​

message.style carries the overrides the campaign author set. Every field is optional, and an absent one means inherit your app's own look - so apply only what is present rather than substituting defaults of your own:

const st = message.style;
const text = {
// 'auto' aligns by the MESSAGE's language, not the device locale -
// I18nManager.isRTL is the wrong test when one workspace sends Hebrew and
// English to the same app.
writingDirection: 'auto' as const,
textAlign: st?.textAlign === 'center' ? 'center' : 'auto',
...(st?.fontFamily ? { fontFamily: st.fontFamily } : {}),
};

<View style={[styles.card,
st?.backgroundColor ? { backgroundColor: st.backgroundColor } : null,
st?.cornerRadius != null ? { borderRadius: st.cornerRadius } : null]}>
<Text style={[styles.title, text,
st?.fontSize != null ? { fontSize: st.fontSize * 1.3 } : null]}>{message.title}</Text>
<Text style={[styles.body, text,
st?.fontSize != null ? { fontSize: st.fontSize } : null]}>{message.body}</Text>
</View>

Apply fontFamily only if your app bundles that font; keeping your own typeface is better than substituting an arbitrary one. If the campaign sets a button colour but no label colour, pick black or white by contrast against the fill - defaulting to white makes a pale brand colour invisible.

Allowing HTML messages​

HTML messages are turned off by default. The native SDK shows an HTML message in a web view inside your app. Scripts written into the message do not run there - only the SDK's own bridge does, and it handles buttons and forms - but turning on a web view is still a decision your app team makes - not something switched on from a marketing dashboard:

await Joryio.initialize(sdkKey, apiHost, {
allowHtmlJsInAppMessages: true, // default: false
});

Leaving it off does not disable in-app messaging - native messages still arrive. Only HTML campaigns are skipped.

That applies while the SDK is the thing drawing. Once you call enableInAppMessages(['content.native', 'content.html']), your JS renders every message and this setting no longer gates HTML - what you declare in that call is the opt-in.

Track Impressions​

// When message is displayed
Joryio.trackInAppImpression(message.id, 'displayed');

// When user clicks
Joryio.trackInAppImpression(message.id, 'clicked', button.id);

// When user dismisses
Joryio.trackInAppImpression(message.id, 'dismissed');

Report 'displayed' once, when the message appears. That one report records the impression and is what counts toward the campaign's frequency caps - handing a message to your code does neither, and the SDK does not report it for you. For a click, pass the id of the button or link as the third argument, elementId, so reports can say which button was tapped; leave it out for displayed and dismissed.

Report 'dismissed' whenever the message leaves the screen, including after a click. Until then the message holds the display slot: on iOS no other in-app message is shown, and on Android the same campaign is not shown again. If 'dismissed' never comes, the SDK frees the slot after 5 minutes.

Forms in HTML messages​

When you render an HTML message yourself, submit a <form data-jry-form> it contains through the SDK, so the values are sent with the current identity and validated against the form the campaign carries:

const result = await Joryio.submitInAppForm(message.id, { email: '[email protected]' });
// { ok, message?, redirect?, errors? } - never rejects; a failure is ok: false

Syncing and testing​

Joryio.syncInAppCampaigns(); // fetch campaigns now and display any that are eligible
Joryio.resetDisplayedCampaigns(); // testing only: forget what this device has shown

The SDK already syncs at session start and when the app comes to the foreground; call syncInAppCampaigns() for the moments in between. resetDisplayedCampaigns() runs only when enableDebug is on.

enableInAppMessages(capabilities?) and disableInAppMessages() are the lower-level pair onInAppMessage calls for you; use them only if you listen for the JoryioInAppMessage event another way. When JavaScript reloads, the bridge hands rendering back to the native SDK until your code subscribes again.

When a message counts as displayed​

A message counts as displayed - in_app.displayed is reported and the campaign's frequency cap is spent - only once the person could have seen it. The time it must be on screen is IN_APP_DISPLAY_DWELL_MS (1000 ms), the same constant on every Joryio SDK.

  • Messages the native SDK draws follow the iOS and Android rule: shown only on a settled screen in the foreground, and counted after 1 second on screen with the app in the foreground. A tap or a close before then counts as seen; a message that leaves the screen sooner is not counted and stays eligible.
  • If you draw messages yourself (onInAppMessage), report 'displayed' by the same rule - IN_APP_DISPLAY_DWELL_MS is exported: once the message has been visible that long, or right before a click or dismissal that comes sooner. Do not report it for a message that left the screen first.

Two separate switches decide whether the SDK collects anything, and any "no" wins:

  • Consent - the answer from your consent banner: 'granted', 'denied' or 'pending'. Set with trackingConsent at initialize() or with updateConsent().
  • Opt-out - the person's own choice. Set with optOut() and lifted only by optIn().

The SDK collects only while consent is granted and the person has not opted out. Granting consent never cancels an opt-out, and opting out never changes the stored consent.

Stop and resume collection. This is tracking consent, not marketing subscription - optOut() does not unsubscribe anyone from email or SMS. Those live on the contact's subscription state and change through the API or the preference centre.

// The user declined tracking
Joryio.optOut();

// ...and later accepted
Joryio.optIn();

const optedOut = await Joryio.isUserOptedOut(); // the opt-out switch only
const consent = await Joryio.getTrackingConsent(); // 'granted' | 'denied' | 'pending'

wipeData() erases everything the device holds for the current person - identity, queued events, pending writes, in-app state, session, device id and push token - and unregisters the token on the server. The opt-out is kept, so someone who opted out and asked for erasure stays opted out. Push stays off until registerPushToken() or a permission grant.

optOut() records the decision on the profile as tracking_opted_out before it stops sending, so an audience can exclude people who declined. optIn() clears it. The record is a signal for targeting, not enforcement - server-side suppression is a separate decision.

Events already queued but not yet sent are discarded by optOut(), not held for later. An opt-out that still uploads what was queued a moment earlier is not an opt-out.

To decide before anything is sent, pass consent to initialize() and update it from your consent prompt:

await Joryio.initialize(sdkKey, apiHost, { trackingConsent: 'pending' });

// later
Joryio.updateConsent('granted'); // 'granted' | 'denied' | 'pending'
  • Leave trackingConsent unset unless your banner has an answer for this launch. Unset, with nothing stored, behaves as granted; a denied stored by an earlier launch stays in force.
  • denied is stored on the device and survives restarts. Only updateConsent('granted') or an explicit trackingConsent: 'granted' lifts it.
  • pending applies to this launch only and never replaces a stored denied. While consent is pending nothing is sent: events and attribute changes wait on the device (up to maxQueueSize), and there is no session start, in-app sync or push registration. updateConsent('granted') sends what waited; updateConsent('denied') discards it.
  • Pass optOut: true to start opted out. An opt-out is stored on the device and survives restarts; optOut: false does not undo an earlier optOut() - call optIn().
  • If your app already knows the answer when it starts - a banner choice it stored itself, for example - pass it as trackingConsent to initialize() instead of calling updateConsent() after it, so the SDK starts with that answer in force. Letter case does not matter ('Denied' is read as 'denied'); an unknown value is ignored with a warning and consent stays unset.

optOut(), optIn(), updateConsent() and wipeData() can be called before initialize(), and they are applied first: no session start, in-app sync or push registration goes out before them. Calls made earlier that collect data - events, attributes, aliases, subscription changes - are dropped by an optOut(), updateConsent('denied') or wipeData() that follows them, instead of being sent once the SDK starts. Calls like these made while opted out or denied - before initialize() too, whether the decision was made in this launch or stored by an earlier one - are dropped as well, and a later optIn() or updateConsent('granted') does not send them. identify is dropped only by wipeData(). An earlier setSdkAuthenticationToken() is never carried over to the next visitor: on iOS and Android alike the wipe keeps it only for the erased person's own withdrawals, or drops it. After an optOut() or a denial the user id is kept on the device, unsent, only so that an unsubscribe reaches the right person. Withdrawals are the exception: setSubscription(channel, 'unsubscribed'), removeFromSubscriptionGroup and unregisterPush() made earlier are kept and sent.

A decision made while an initialize() is running that then fails is applied at that failure, ahead of the next initialize(): the retry starts with it in force.

isUserOptedOut() and getTrackingConsent() can be called before initialize() as well. While initialize() is running they wait for it; otherwise - before initialize() and after one that failed - they answer at once from what is stored on the device, so an opt-out or a denied from an earlier launch reads as such straight away. With nothing decided and nothing stored, getTrackingConsent() answers 'granted', the state the SDK runs in, on both platforms.

Marketing Subscription​

Whether this person may be messaged on a channel. A different consent from optOut() above, which is whether they may be observed - neither implies the other, and someone can decline tracking and still want the newsletter.

This is what an in-app preference centre writes.

Joryio.setSubscription('email', 'unsubscribed');

Channels: email, sms, whatsapp, push, viber. Statuses: optedIn, subscribed, unsubscribed.

Subscription groups are lists a person can join or leave per channel:

Joryio.addToSubscriptionGroup('list_abc123', 'email');
Joryio.removeFromSubscriptionGroup('list_abc123', 'email');

An identifier is required - the call is skipped, with a logged error, if there is neither a user id nor an anonymous id.

Configuration Options​

OptionTypeDefaultDescription
enableDebugbooleanfalseEnable debug logging
logLevelstringderived from enableDebugLog level: debug, info, warn, error. Left unset, both native bridges use 'debug' when enableDebug is true and 'error' otherwise - there is no fixed default. Set it only to override that.
batchSizenumber50Events per batch before auto-flush (native default)
flushIntervalnumber5000Auto-flush interval in ms (native default)
sendImmediatelybooleanfalseSend each event as soon as it is tracked, without batching (not recommended for production)
maxQueueSizenumber1000Most events the local queue holds while flushes fail; beyond it the oldest are dropped (native default)
requestTimeoutnumber10000Per-request HTTP timeout in ms (native default)
sessionTimeoutnumber1800000Session timeout in ms (native default: 30 min)
trackSessionStartbooleantrueAuto-track session start events
userIdstringnullPre-set user ID at init time
anonymousIdstringgeneratedYour own anonymous id for this device. Used for this run and stored only when nothing is stored yet, so an id set once survives the app dropping the option later
requestPushPermissionAtLaunchbooleanfalseAsk for the notification permission during initialize. Prefer asking at a moment the user understands
allowHtmlJsInAppMessagesbooleanfalseLet the native SDK draw HTML in-app messages (see Allowing HTML messages)
holdInAppMessagesForHandlerbooleanfalseSince 1.3.2. Your app draws in-app messages and subscribes after initialize() has completed: hold messages until it does (see When to subscribe)
enableSdkAuthenticationbooleanfalseAttach a customer-minted JWT to every ingest request
sdkAuthenticationTokenstring-The initial JWT when enableSdkAuthentication is on
trackingConsent'granted' | 'denied' | 'pending'unsetTracking consent at startup. Leave it unset unless your consent banner has an answer - see Consent at startup
optOutbooleanfalseStart opted out of tracking
notificationChannelNamestring"Notifications"Android only: the notification channel name people see in system settings (Android 8+). Pass it localized. Ignored on iOS

Utilities​

// Flush events immediately (before app close, logout, etc.)
Joryio.flush();

// Get IDs
const anonymousId = await Joryio.getAnonymousId();
const userId = await Joryio.getUserId(); // null if not identified
const sessionId = await Joryio.getSessionId();

TypeScript Support​

The SDK is fully typed. Import types as needed:

import Joryio, {
type JoryioConfig,
type UserAttributes,
type InAppMessage,
} from '@joryio/react-native-sdk';

SDK Authentication​

Optional. When it is on, a request that carries a user id must also carry a JWT your backend minted for that user; calls for anonymous visitors are accepted without one.

await Joryio.initialize(sdkKey, apiHost, {
enableSdkAuthentication: true,
sdkAuthenticationToken: initialToken,
});

const stop = Joryio.onSdkAuthenticationError(async (error) => {
// error.reason: 'missing' | 'invalid' | 'expired' | 'sub_mismatch' | 'unknown'
// error.refreshExhausted: true once the SDK has stopped retrying this request
Joryio.setSdkAuthenticationToken(await fetchFreshToken());
});

Call setSdkAuthenticationToken(token) after login and whenever your handler has a fresh token; it is attached to every later request. onSdkAuthenticationError returns an unsubscribe function.

Diagnostics​

const { apiEndpoint, lastError } = await Joryio.getDiagnostics();
// lastError: { message, isUnauthorized, isRetryable, at } - null again once a call succeeds

Reach for it when an integration misbehaves: a wrong SDK key looks initialised while every request fails with isUnauthorized: true.

Safe with null and early calls​

  • Every call is safe before initialize() resolves: up to 1,000 calls are held and replayed in order, and the getters wait up to 5 seconds before resolving a default. Past 1,000 the oldest are dropped, data calls first: an unsubscribe, unregisterPush() or a privacy decision goes only when nothing else is left.
  • null or undefined never crashes your app. A null configuration value means the default; a call missing a required argument (an event name, a user id, a token) logs a warning and does nothing; a null attribute value unsets the attribute.
  • If initialize() fails, calls are held the same way and sent once a later initialize() succeeds - you can call it again with corrected values. Listeners you subscribed (onInAppMessage, onSdkAuthenticationError) and unsubscribes made meanwhile are kept, not lost. A privacy decision made while that initialize() was still running is applied at the failure, so the retry starts with it.
  • Two overlapping initialize() calls - React StrictMode runs effects twice - share one start. A later call is ignored and the SDK keeps running; changed settings need an app restart.
  • NaN and Infinity are never sent: an attribute value or event property holding one is left out with a warning, and the rest of the call goes ahead. setAttribute(key, NaN) is ignored rather than unsetting the attribute. Values inside your own class instances - a cart line built from a class, for example - are checked too.
  • A callback you pass to onInAppMessage or onSdkAuthenticationError that throws (or returns a promise that rejects) is caught and logged. It never crashes your app and never stops other listeners.

API reference​

AreaMethods
Setupinitialize(sdkKey, apiHost, config?)
Eventstrack(eventName, properties?), trackScreen(screenName, properties?), flush()
Identityidentify(userId), addAlias(aliasLabel, aliasName), reset(), getUserId(), getAnonymousId(), getSessionId()
AttributessetAttributes(attributes), setAttribute(key, value), incrementAttribute(key, by?), unsetAttribute(key)
PushregisterPushToken(token), unregisterPush(), reportPushDelivered(trackingId), trackPushClick(trackingId), getPushPermissionStatus(), requestPushPermission(), isPushEnabled()
In-apponInAppMessage(callback, options?), trackInAppImpression(campaignId, action, elementId?), submitInAppForm(campaignId, values), syncInAppCampaigns(), resetDisplayedCampaigns(), enableInAppMessages(capabilities?), disableInAppMessages()
ConsentoptOut(), optIn(), isUserOptedOut(), getTrackingConsent(), updateConsent(consent), wipeData()
SubscriptionssetSubscription(channel, status), addToSubscriptionGroup(groupId, channel), removeFromSubscriptionGroup(groupId, channel)
SDK authenticationsetSdkAuthenticationToken(token), onSdkAuthenticationError(callback)
E-commerceJoryio.ecommerce.* - see E-Commerce
DiagnosticsgetDiagnostics()

Testing with Jest​

The package's main is compiled JavaScript, so your Jest setup needs no transformIgnorePatterns entry for it. Without a mocked NativeModules.JoryioModule every call is a no-op and the getters resolve their defaults; no call throws.