Retainza

Developer docs

Getting the SDK into your app

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:

  1. Install the SDK with your platform's package manager.
  2. Initialize it with baseUrl (your Retainza server) and a publicKey.
Find your project's public key and server URL under Connections / API Keys. The public key is safe to ship in a mobile or web app — it is write-only.
Building a React Native or Expo app? Follow the Getting started with the React Native SDK guide — install, initialize, track, push and in-app, in the order you actually do them. This page stays the reference for the whole surface.

Keys & base URL

Two kinds of keys

KeyPrefixUse it in
Public keypk_…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.

Think of the SDK as a phone and the base URL as the number you dial. You don't ship a phone with one number burned in — you dial it at call time. That's why 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.

PlatformLanguagePackage managerSDK
React Native / Expo, Web, NodeJavaScript / TypeScriptnpm (pnpm / yarn)Available
iOS (native)SwiftSPM (CocoaPods coming soon)Available
Android (native)KotlinGradle (Maven Central)Unpublished
FlutterDartpub (git dependency)Unpublished

Install & configure

Pick your platform. Every one is the same two steps — install, then initialize with your base URL and key.

1Install
# 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
2Initialize
// 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.

Images and background images must be https — iOS blocks plain http, so an http URL is a blank box on those devices. Video needs 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

MethodNotes
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 plain <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.
Personalization inside HTML is escaped, so a profile attribute containing markup renders as text. Campaign authoring is a privileged action — anyone who can author a campaign can run script inside this sandbox.

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 messages

Self-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()} />
The acks are yours to send. Retainza cannot see your view, so a self-handled campaign that never calls markShown reports zero deliveries no matter how many people saw it.
With no handler registered the message stays queued rather than being consumed — a half-finished integration looks like “not shown yet”, never like a campaign that vanished. Watch for the warning in 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.

AttributeSet byNotes
languageSDK (Intl)ISO-639-1, e.g. "en".
countrySDK (Intl)ISO-3166-1 alpha-2 from the device locale, e.g. "IN". Powers geo targeting. Locale, not GPS — no permission involved.
tzSDK (Intl)IANA, e.g. "Asia/Kolkata". Powers user-local scheduling.
sdk_versionSDKThe SDK build, for debugging an integration.
platformReact Native SDK"ios" | "android" | "web", from Platform.OS.
os_name / os_versionReact Native SDKFrom React Native itself.
device_model / device_manufacturerYour appOnly when you pass `device` at init — the SDK takes no dependency on a device library.
app_versionReact Native SDKWhatever you pass as appVersion to init.
install_dateReact Native SDKStamped once into your storage adapter on first run.
session_countSDK sessionsLifetime sessions. Also first_session_at, last_session_at and session_duration_total.
push_permissionYour appsetPushPermission(). The difference between having a token and being reachable.
first_name, last_name, email, phone, gender, birthdateYour appReserved identity. Only ever what you pass to identify — the SDK never collects PII on its own.
consent_marketing / consent_analyticsYour appsetConsent().
last_activeBackendRefreshed 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 storage adapter the queue survives an app restart. Without one it is memory-only: it survives a network blip, not a relaunch.
  • registerPush is never queued.

Reset on logout

Call 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 URL
Overrides are refused unless debug 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

Editing .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 release

Then 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

  1. In the dashboard, add an FCM (Android) or APNs (iOS) connection under Connections.
  2. In your app, get the device push token and call registerPush(token, platform).
  3. Send a push from Campaigns; watch delivery in Notifications.
Android release builds block plain 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.

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