Overview
Retainza is a customer engagement platform — Email, Push, In-App messages, deep-link navigation and conditional sends. You integrate it into your app with a small SDK (or by calling the HTTP API directly).
Every integration is the same two steps:
- Install the SDK with your platform's package manager.
- Initialize it with
baseUrl(your Retainza server) and apublicKey.
Keys & base URL
Two kinds of keys
| Key | Prefix | Use it in |
|---|---|---|
| Public key | pk_… | Client apps (mobile, web) — identify, track, register push. Safe to ship. |
Why you always pass a base URL
The SDK is just code — it knows how to talk to Retainza, but not where your server lives. Every company runs Retainza at a different address, so the address can never be baked into a shared package. You pass it in when you initialize the SDK.
baseUrl is always configuration, never part of the package.Platform support
The JavaScript/TypeScript SDK covers React Native/Expo, web and Node. Native Swift and Kotlin SDKs and a Flutter (Dart) SDK are built and tested but not yet published to their package registries — install them from the repository as their guides describe. Any platform can also call the HTTP API directly — an SDK is only a convenience wrapper.
| Platform | Language | Package manager | SDK |
|---|---|---|---|
| React Native / Expo, Web, Node | JavaScript / TypeScript | npm (pnpm / yarn) | Available |
| iOS (native) | Swift | SPM (CocoaPods coming soon) | Available |
| Android (native) | Kotlin | Gradle (Maven Central) | Unpublished |
| Flutter | Dart | pub (git dependency) | Unpublished |
Install & configure
Pick your platform. Every one is the same two steps — install, then initialize with your base URL and key.
# React Native / Expo
npm install @retainza/react-native
# Web: npm install @retainza/sdk-core
# Not publishing to a registry? Build local tarballs instead:
# cd sdk && ./scripts/pack-local.sh /path/to/your-app --install// App.tsx
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { Retainza } from "@retainza/react-native";
Retainza.init({
publicKey: process.env.EXPO_PUBLIC_ENGAGE_PUBLIC_KEY, // "pk_..."
userProvider: () => currentUser?.id, // optional
appVersion: Constants.expoConfig?.version, // → app_version attribute
storage: AsyncStorage, // offline queue + install_date
debug: __DEV__, // silent in production
});Using the SDK
Once initialized, the client exposes a small surface (shown here in JS/TS — native SDKs mirror it):
Identify a user
await Retainza.get().identify("user-123", {
plan: "pro",
signupSource: "web",
}, { email: "sam@acme.com", locale: "en" });Track an event
await Retainza.get().track("workout_completed", { minutes: 32 });Register for push
// obtain the device token from your push provider (e.g. FCM), then:
await Retainza.get().registerPush(fcmToken, "android"); // or "ios"Update a few attributes
setAttributes is a patch: it sends only the keys you pass and the server merges them, so unrelated attributes (country, tz, anything your app set earlier) are left alone.
await Retainza.get().setAttributes({ plan: "pro" });
// optionally for another user:
await Retainza.get().setAttributes({ plan: "pro" }, "user-123");In-app messages
Mount one component near your app root. That is the entire in-app integration — the SDK ships the renderer, so your app carries no layout, styling or ack code for it, and a template you design in the dashboard looks the same in every app that installs the SDK.
// app/_layout.tsx
import { RetainzaInApp } from "@retainza/react-native/ui";
import { usePathname } from "expo-router";
<RetainzaInApp
currentScreen={usePathname()} // optional "where to show" gate
blockedScreenPrefixes={["/(auth)", "/onboarding"]} // never interrupt these
/>It owns polling, the message queue (one at a time, the rest stay pending), the shown / clicked / dismissed acks that feed Analytics, and CTA deep links. A blocked screen leaves a message pending rather than consuming it, so nothing is lost.
Layouts
A native message is a list of slots — image/video, header, title, body and up to three buttons — arranged in whatever order the layout names. The catalog in the builder (“Image, Text & Button”, “Image only”, “Image & 2 Buttons”…) is just preset slot lists, so a new layout never needs an app release. Your app renders all of them with the one component above.
Each button carries its own action: give it a screen and it deep-links there and reports clicked; leave the screen blank and it closes the message and reports dismissed.
expo-video (or expo-av) in your app; without it the message still shows, minus the video.Overriding the look
Adopting the renderer is not all-or-nothing. Replace one presentation and keep the machinery, or take over rendering entirely:
<RetainzaInApp presentations={{ banner: MyBanner }} /> // just the banner
<RetainzaInApp render={(msg, { onCta, onDismiss }) => (
<MyOwnSheet message={msg} onPrimary={onCta} onClose={onDismiss} />
)} />Deep links from a CTA
Retainza.get().onNavigate((route) => router.push(route.screen));@retainza/react-native/ui is a separate entry point on purpose: importing the package root never pulls React in, so your FCM background handler stays headless.Full method list
| Method | Notes |
|---|---|
| identify(userId, attrs?, profile?) | Upsert a profile. The third argument is reserved identity — firstName, lastName, email, phone, gender, birthdate, locale — mapped to snake_case attributes for you. Default attributes ride along on every call. |
| setPushPermission(status, userId?) | "authorized" | "denied" | "provisional" | "undetermined". The SDK never prompts; report what your app already knows. A denied user keeps a valid token and receives nothing. |
| setDeviceInfo(info) | Device model/manufacturer from whichever device library you use. The React Native wrapper calls this from the `device` init option. |
| startSession() / endSession() | Foreground / background. Wired to AppState automatically unless autoSession is false. 30-minute timeout: a quick return resumes rather than counting again. |
| setConsent({ analytics, marketing }) | With requireConsent, nothing is sent until analytics is true. marketing does not gate transport — it is stored for campaign targeting. |
| optOut() / optIn() | Hard stop, clears the queue. Deliberately not persisted — your app owns the consent UI, so re-assert on each launch. |
| setAttributes(attrs, userId?) | Partial update — sends only what you pass; the server merges. |
| track(event, props?, userId?) | Stamps the event time client-side, so a queued event keeps its real timestamp. |
| addToCollection(collection, item, userId?) | One-to-many data — a user's challenges, badges, saved searches. Takes either a bare id string (`"lagos_10k"`) or an object with fields (`{ id, status, progress }`); the two interoperate, so a later write with fields patches the same item. `id` is yours and is the merge key: writing it twice updates in place, which is what makes a replayed offline write safe. Fields you omit are preserved. Items are flat — strings, numbers, booleans, null. |
| removeFromCollection(collection, id, userId?) | Remove one item. An id that is not there is a no-op, not an error. |
| setCollection(collection, items, userId?) | Replace the whole list; `[]` clears it. Accepts id strings or objects. Use it when your app already holds the full list; use addToCollection for incremental changes. |
| registerPush(token, platform, userId?) | Never queued — a stale token is worse than a missing one. |
| ackInApp(msgId, action, userId?) | "shown" | "clicked" | "dismissed". |
| ackPush(msgId, action, userId?) | "delivered" | "opened" — the only source of push delivery data. |
| reset() | Call on logout. Clears identity, session counters, the offline queue and in-app polling. |
| flushQueue() / queueSize() | Drain or inspect the offline queue. |
| getBaseUrl() / setBaseUrl(url) / clearBaseUrl() | Runtime backend switching in dev builds. |
| testConnection() | GET /health — for a “test connection” button on a dev screen. |
| onNavigate(h) | Register your router, for CTA deep links. |
| onSelfHandledInApp(h) | Receive selfhandled campaigns and render them yourself. The handle carries markShown/markClicked/markDismissed — send them or the campaign's funnel stays empty. |
| onInApp(h) | Low-level — <RetainzaInApp /> registers this for you. Only one handler exists, so setting it yourself takes delivery away from the component. |
| fetchPendingInApp(userId?) | One poll for queued in-app messages. Handled for you by <RetainzaInApp />. |
| startInAppPolling({ intervalMs, userId }) | Low-level polling loop; returns a stop function. |
| deliverInApp(msg) / resolveRoute(route) | Hand a message to the renderer / a route to the router. |
| parseRetainzaPush(data) | Standalone function: is this FCM payload ours? Returns null if not. |
Custom HTML messages
The four native templates cover most sends and should stay your default — they render as real native views, cost nothing to show, and match your app's look. When the layout itself is the point, pick the Custom HTML presentation instead and design the whole message in the dashboard.
Your document renders in a sandboxed WebView inside a native shell (popup, bottom sheet or full screen), so it keeps the backdrop, safe-area padding and dismiss behaviour of a normal message. No app code is needed — the same <RetainzaInApp /> renders it.
Talking to the app
A document gets exactly three verbs. They run the same code path as a native template's buttons, so the funnel in Analytics reads identically whichever presentation a campaign uses.
<button onclick="engage.cta({ screen: '/(tabs)/shop', kv: { id: '42' } })">Shop</button>
<button onclick="engage.dismiss()">Not now</button>
<button onclick="engage.track('variant_b_tapped')">Tell me more</button>engage.cta(route?)— reports clicked, then deep-links if you pass a route.engage.dismiss()— reports dismissed and closes.engage.track(event, props?)— a custom event, e.g. which variant was tapped.
<a href> does nothing: navigating the WebView away is blocked. Use engage.cta so the click is measured and the app routes properly.What a document may and may not do
- Allowed: inline
<style>and<script>, https images, data URIs,{{variables}}. - Blocked on the device: remote scripts,
fetch/XHR,<iframe>,<object>,<embed>,<base>and meta-refresh — a strict CSP and an opaque origin see to it. - Rejected on save: anything over 64 KB, and the tags above. The document is sent on every in-app poll, so size is bandwidth for every user.
Falling back
react-native-webview is an optional peer. If it is not installed, or a document fails to render, the SDK shows the native popup built from the campaign's title, body and CTA instead. That is why those fields stay required for an HTML message — without them the campaign would simply vanish on those devices.
npx expo install react-native-webview # enables HTML in-app messagesSelf-handled messages
The escape hatch: pick the Self-handled presentation and Retainza delivers the payload without drawing anything. Your app renders it with its own design system, and keeps the targeting, scheduling and analytics that every other campaign gets.
It is the last resort, not the first. A native layout needs no app code and no release; this needs both. Reach for it when the message has to be part of your own UI — a card inside a feed, a themed sheet — rather than an overlay on top of it.
Retainza.get().onSelfHandledInApp((msg) => {
setPromo(msg); // your own component draws it
});
// in that component:
useEffect(() => promo.markShown(), []); // counts as delivered
<Button onPress={() => promo.markClicked()} /> // acks clicked, then deep-links
<Close onPress={() => promo.markDismissed()} />markShown reports zero deliveries no matter how many people saw it.debug mode.Default attributes
The SDK attaches these to every identify, so they never go stale. Anything you pass yourself wins — if your app sets country from a billing address, the SDK will not overwrite it.
| Attribute | Set by | Notes |
|---|---|---|
| language | SDK (Intl) | ISO-639-1, e.g. "en". |
| country | SDK (Intl) | ISO-3166-1 alpha-2 from the device locale, e.g. "IN". Powers geo targeting. Locale, not GPS — no permission involved. |
| tz | SDK (Intl) | IANA, e.g. "Asia/Kolkata". Powers user-local scheduling. |
| sdk_version | SDK | The SDK build, for debugging an integration. |
| platform | React Native SDK | "ios" | "android" | "web", from Platform.OS. |
| os_name / os_version | React Native SDK | From React Native itself. |
| device_model / device_manufacturer | Your app | Only when you pass `device` at init — the SDK takes no dependency on a device library. |
| app_version | React Native SDK | Whatever you pass as appVersion to init. |
| install_date | React Native SDK | Stamped once into your storage adapter on first run. |
| session_count | SDK sessions | Lifetime sessions. Also first_session_at, last_session_at and session_duration_total. |
| push_permission | Your app | setPushPermission(). The difference between having a token and being reachable. |
| first_name, last_name, email, phone, gender, birthdate | Your app | Reserved identity. Only ever what you pass to identify — the SDK never collects PII on its own. |
| consent_marketing / consent_analytics | Your app | setConsent(). |
| last_active | Backend | Refreshed on every identify and track. |
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { Retainza } from "@retainza/react-native";
Retainza.init({
publicKey: process.env.EXPO_PUBLIC_ENGAGE_PUBLIC_KEY,
appVersion: Constants.expoConfig?.version, // → app_version
storage: AsyncStorage, // → install_date + offline queue
});
// platform and install_date resolve asynchronously; await this before the
// very first identify if you need them on that call.
await Retainza.ready();tz decides when a user-local campaign sends and country decides who a geo campaign reaches. A missing one silently changes the audience, which is why the SDK collects them rather than leaving it to each app.Offline & logout
The offline queue
track and identify are queued when a request fails with a network error or a 5xx, then replayed oldest-first. A 4xx is not queued — it would fail forever and block everything behind it.
- Capacity 100 (
maxQueuedEvents); the oldest events are dropped on overflow. - Order is preserved — a flush stops at the first failure rather than skipping ahead.
- Each event carries its original timestamp, so a delayed flush does not distort analytics or misfire a time-based condition.
- With a
storageadapter the queue survives an app restart. Without one it is memory-only: it survives a network blip, not a relaunch. registerPushis never queued.
Reset on logout
reset() when a user signs out. Without it the next person to use the device inherits the previous user's identity — their events, and their messages.export async function signOut() {
await auth().signOut();
await Retainza.get().reset(); // clears identity, queue and polling
}reset() is a client-side identity clear, not a data deletion — the server-side profile is untouched. For a GDPR erasure use DELETE /v1/users/:id. If you supplied a userProvider, remember it belongs to your app: the SDK will keep using whatever it returns.
Debug & troubleshooting
Debug mode
debug: true logs every request, poll, ack and swallowed error. With debug off the SDK writes nothing to the console. Values are never logged — only attribute and property key names, and only a prefix of the public key.
Retainza.init({
publicKey: "pk_…",
debug: __DEV__, // silent in production
logger: (level, msg, meta) => Sentry.addBreadcrumb({ level, message: msg, data: meta }),
});Changing the backend URL without a rebuild
The base URL is normally inlined into your bundle at build time, so a backend hostname change disconnects every installed build until you ship a new one. In dev builds the SDK can be pointed somewhere else at runtime, highest priority first: stored override → config.baseUrl → EXPO_PUBLIC_ENGAGE_BASE_URL.
const client = Retainza.get();
await client.setBaseUrl("https://your-tunnel.example.dev/v1");
const { ok, status } = await client.testConnection(); // GET /health
await client.clearBaseUrl(); // back to the built-in URLdebug or allowRuntimeBaseUrl is set — stored state must never be able to redirect a production app's telemetry to another host.Changing EXPO_PUBLIC_* does not invalidate a release build
.env and rebuilding is not enough. Gradle'screateBundleReleaseJsAndAssets sees unchanged JS sources and skips re-bundling, and Metro's transform cache still holds the previously inlined value — so the build succeeds, installs, and silently runs with the old URL. This applies to every EXPO_PUBLIC_* variable, not just the base URL.Clear both caches and rebuild:
rm -rf android/app/build/generated/assets/createBundleReleaseJsAndAssets \
android/app/build/intermediates/assets/release
rm -rf "$TMPDIR"/metro-*
npx expo run:android --variant releaseThen verify the value actually made it into the bundle before trusting the build:
grep -a -c 'api.retainza.com' \
android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle
# 0 means the old bundle is still in place — clear the caches again.Push notifications
- In the dashboard, add an FCM (Android) or APNs (iOS) connection under Connections.
- In your app, get the device push token and call
registerPush(token, platform). - Send a push from Campaigns; watch delivery in Notifications.
http://. For a release build your Retainza server must be reachable over HTTPS — put the API behind a reverse proxy (nginx/Caddy) with a TLS certificate, or a company domain, and use https://api.retainza.com/v1 as the base URL.Deep links & screens
A campaign's screen is an expo-router path in your app. Retainza does not know your navigation — it carries the path and any parameters, and your app resolves them.
What Retainza puts in the push
Alongside the usual notification block, every Retainza push carries a data block. Every key is prefixed engage_ so it cannot collide with another push SDK in the same app (a third-party SDK, your own notifications), and every value is a string — that is an FCM requirement.
{
"notification": { "title": "…", "body": "…" },
"data": {
"engage_msg_id": "<campaignId>:<runId>:push:<userId>",
"engage_campaign": "<campaignId>",
"engage_route": "/(tabs)/challenges", // optional
"engage_params": "{"id":"42"}" // optional, JSON string
}
}Handling it
Use parseRetainzaPush(data) from the SDK in every Firebase handler. It returns null for anything that is not an Retainza push, so other SDKs pass through untouched.
import messaging from "@react-native-firebase/messaging";
import { parseRetainzaPush } from "@retainza/sdk-core";
// Tapped while backgrounded → report the open, then navigate.
messaging().onNotificationOpenedApp(async (remote) => {
const push = parseRetainzaPush(remote?.data);
if (!push) return; // not ours — leave it alone
await engage.ackPush(push.msgId, "opened");
if (push.route) router.push({ pathname: push.route.screen, params: push.route.kv ?? {} });
});
// Foreground → report delivery, but do NOT navigate: nobody tapped anything.
messaging().onMessage(async (remote) => {
const push = parseRetainzaPush(remote?.data);
if (push) await engage.ackPush(push.msgId, "delivered");
});getInitialNotification() resolves before your router has mounted, so navigating immediately does nothing at all. Queue the route and flush it once navigation is ready — Retainza's reference integration keeps a pendingRoute and flushes it from the same effect that tells the rest of the app the router is up.Why acks matter
FCM gives no delivery receipt. delivered and opened for push exist only because the device reports them via POST /v1/push/ack. Without those calls the funnel stops at sent. Status is monotonic, so acks arriving out of order never move a delivery backwards, and an unknown message id is accepted silently — telemetry must never break the app.
The screen catalog
The campaign builder offers a dropdown of known screens instead of a free-text path. That list lives in one constant, dashboard/src/lib/screenCatalog.ts — it is your app's config, not platform truth. Add a screen there and it appears for every campaign; a Custom… option always remains for anything not yet listed.
export const SCREEN_CATALOG: ScreenTarget[] = [
{ label: "Home", path: "/(tabs)/home" },
{ label: "Challenge", path: "/challenge/[id]", params: ["id"] },
// …add your own
];HTTP API reference
No SDK required — the backend is plain HTTP + JSON. Authenticate the ingest routes with your public key from the app, or with your secret key from your own backend: they accept either, so your server and your app are co-equal write paths. Use that when the truth lives on your server — plan tier, entitlements, anything the app never sees. Management routes below are driven from this portal.
Never ship a secret key in an app. It reaches campaigns, segments and billing, and anyone who unzips your binary has it.
curl -X POST https://api.retainza.com/v1/identify \
-H "authorization: Bearer pk_..." \
-H "content-type: application/json" \
-d '{"userId":"user-123","attributes":{"plan":"pro"}}'Ingest routes (public key from the app, or secret key from your backend)
- POST
/v1/identifyidentify - POST
/v1/tracktrack - POST
/v1/collectionscollections - POST
/v1/tokentoken - DELETE
/v1/tokentokenDelete - POST
/v1/inapp/ackinappAck - GET
/v1/inapp/pendinginappPending - POST
/v1/push/ackpushAck
Management routes (this portal only)
- GET
/v1/projectsprojectsList - POST
/v1/projectsprojectsCreate - POST
/v1/campaignscampaignsUpsert - GET
/v1/campaignscampaignsList - POST
/v1/campaigns/:id/activatecampaignActivate - POST
/v1/campaigns/:id/pausecampaignPause - POST
/v1/sendsend - POST
/v1/segmentssegmentsUpsert - GET
/v1/segments/:id/countsegmentCount - POST
/v1/connectionsconnectionsUpsert - GET
/v1/connectionsconnectionsList - GET
/v1/usageusage - GET
/v1/billingbilling - POST
/v1/billing/checkoutbillingCheckout - POST
/v1/billing/upgrade-checkoutbillingUpgradeCheckout - POST
/v1/billing/previewbillingPreview - POST
/v1/billing/changebillingChange - POST
/v1/billing/portalbillingPortal - GET
/v1/analytics/campaigns/:idanalytics - POST
/v1/scheduler/tickschedulerTick - GET
/v1/users/:iduserView - GET
/v1/users/:id/exportuserExport - DELETE
/v1/users/:iduserErase
