<!-- Source: https://developer.retainza.com/ · Markdown: https://developer.retainza.com/index.md -->

Developer docs

# Build with Retainza

Add push, in-app messages, email and deep links to your app with a small SDK or plain HTTP. Pick your platform's guide, or go straight to the reference.

[Choose your SDK](https://developer.retainza.com/#sdks) · [HTTP API reference](https://developer.retainza.com/#api)

## SDK guides

- [React Native & Expo Available — npm, from release tarballs](https://developer.retainza.com/react-native/)
- [iOS (Swift) Available — Swift Package Manager](https://developer.retainza.com/ios/)
- [Android (Kotlin) Unpublished — Gradle](https://developer.retainza.com/android/)
- [Flutter (Dart) Unpublished — pub](https://developer.retainza.com/flutter/)
- [Server (Node) Unpublished — HTTP API · Node SDK not distributed yet](https://developer.retainza.com/server/)
- [HTTP API — Any language, no SDK](https://developer.retainza.com/#api)

## How an integration goes

1. [Get your public key — Copy the **pk_…** key from **API keys** in the dashboard. It is safe to ship in an app.](https://developer.retainza.com/#concepts)
2. [Install and start the SDK — Open your platform's guide, add the package and start it once at launch.](https://developer.retainza.com/#sdks)
3. [Identify and track — Send who the user is and what they do. Users appear in the dashboard.](https://developer.retainza.com/#usage)
4. [Push and in-app — Register push tokens and let the SDK render in-app messages.](https://developer.retainza.com/#push)

Already integrating? [Signed identity](https://developer.retainza.com/#identity) [SDK method list](https://developer.retainza.com/#usage) [Troubleshooting](https://developer.retainza.com/react-native/#troubleshooting) [Integration checklist](https://developer.retainza.com/react-native/#checklist) [Use with AI tools](https://developer.retainza.com/ai/) [SDK downloads](https://dashboard.retainza.com/dashboard/sdk)

<!-- section: overview -->

## 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`.

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

> **Note:** Each platform has a step-by-step guide — install, start, track, push and in-app, in the order you actually do them: [React Native & Expo](https://developer.retainza.com/react-native/), [iOS (Swift)](https://developer.retainza.com/ios/), [Android (Kotlin)](https://developer.retainza.com/android/), [Flutter (Dart)](https://developer.retainza.com/flutter/) and [Server (Node)](https://developer.retainza.com/server/). This page stays the reference for the whole surface.

<!-- section: concepts -->

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

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

<!-- section: platforms -->

## 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](https://developer.retainza.com/#api) directly — an SDK is only a convenience wrapper.

| Platform | Language | Package manager | SDK |
| --- | --- | --- | --- |
| [React Native / Expo, Web, Node](https://developer.retainza.com/react-native/) | JavaScript / TypeScript | npm, from release tarballs (not on the npm registry yet) | Available |
| [iOS (native)](https://developer.retainza.com/ios/) | Swift | SPM (CocoaPods coming soon) | Available |
| [Android (native)](https://developer.retainza.com/android/) | Kotlin | Gradle (Maven Central) | Unpublished |
| [Flutter](https://developer.retainza.com/flutter/) | Dart | pub (git dependency) | Unpublished |

<!-- section: install -->

## Install & configure

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

#### npm

> **Warning:** The JavaScript packages are **not on the npm registry yet**, so `npm install @retainza/…` from the registry will not resolve. Install the release tarballs as the [React Native guide](https://developer.retainza.com/react-native/#install) describes, or use the [HTTP API](https://developer.retainza.com/#api).

**Step 1:** Install

```bash
# 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
```

**Step 2:** Initialize

```ts
// 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
});
```

Next: the [React Native guide](https://developer.retainza.com/react-native/#install) takes it from here — identify, track, push and in-app, in order.

#### Swift PM

**Step 1:** Install

```swift
// Xcode → File → Add Package Dependencies… → paste:
https://github.com/appunik-retainza/ios-sdk

// or Package.swift:
.package(url: "https://github.com/appunik-retainza/ios-sdk.git", from: "1.0.0")
```

**Step 2:** Initialize

```swift
// App / AppDelegate — the module is RetainzaSDK
import RetainzaSDK

try? Retainza.start(with: RetainzaConfig(
    publicKey: "pk_test_…",               // pk_live_… in release builds
    userProvider: { AuthStore.shared.userId }
))
```

Next: the [iOS guide](https://developer.retainza.com/ios/#install) takes it from here — identify, track, push and in-app, in order.

#### CocoaPods

> **Warning:** The CocoaPods SDK is **built but not yet published** to its package registry. The snippets are the real API; install it as the [iOS guide](https://developer.retainza.com/ios/#install) describes, or use the [HTTP API](https://developer.retainza.com/#api).

**Step 1:** Install

```ruby
# Podfile
pod 'RetainzaSDK', '~> 1.0'

# then
pod install
```

**Step 2:** Initialize

```swift
import RetainzaSDK

try? Retainza.start(with: RetainzaConfig(
    publicKey: "pk_test_…",
    userProvider: { AuthStore.shared.userId }
))
```

Next: the [iOS guide](https://developer.retainza.com/ios/#install) takes it from here — identify, track, push and in-app, in order.

#### Gradle (Kotlin)

> **Warning:** The Gradle (Kotlin) SDK is **built but not yet published** to its package registry. The snippets are the real API; install it as the [Android guide](https://developer.retainza.com/android/#install) describes, or use the [HTTP API](https://developer.retainza.com/#api).

**Step 1:** Install

```kotlin
// app/build.gradle.kts
dependencies {
    implementation("com.retainza:retainza-android:1.0.0")
}
// Until 1.0.0 is on Maven Central: ./scripts/release-sdk-android.sh --local
// and add mavenLocal() to your repositories.
```

**Step 2:** Initialize

```kotlin
// Application.onCreate()
import com.retainza.sdk.Retainza
import com.retainza.sdk.RetainzaConfig

Retainza.start(
    this,
    RetainzaConfig(
        publicKey = BuildConfig.RETAINZA_KEY, // pk_test_… / pk_live_… per build type
        userProvider = { AuthStore.userId },
    ),
)
```

Next: the [Android guide](https://developer.retainza.com/android/#install) takes it from here — identify, track, push and in-app, in order.

#### Flutter (Dart)

> **Warning:** The Flutter (Dart) SDK is **built but not yet published** to its package registry. The snippets are the real API; install it as the [Flutter guide](https://developer.retainza.com/flutter/#install) describes, or use the [HTTP API](https://developer.retainza.com/#api).

**Step 1:** Install

```yaml
# pubspec.yaml
dependencies:
  retainza_flutter: ^1.0.0

# then
flutter pub get
# Not on pub.dev yet — inside the monorepo, use
#   retainza_flutter: { path: ../sdk/flutter/retainza_flutter }
```

**Step 2:** Initialize

```dart
// main.dart
import 'package:flutter/widgets.dart';
import 'package:retainza_flutter/retainza_flutter.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Retainza.init(
    publicKey: const String.fromEnvironment('RETAINZA_PK'), // pk_test_… / pk_live_…
  );
  runApp(const MyApp());
}
// then Retainza.instance.identify(...), .track(...)
```

Next: the [Flutter guide](https://developer.retainza.com/flutter/#install) takes it from here — identify, track, push and in-app, in order.

<!-- section: usage -->

## Using the SDK

Once initialized, the client exposes a small surface (shown here in JS/TS — native SDKs mirror it):

### Identify a user

```ts
await Retainza.get().identify("user-123", {
  plan: "pro",
  signupSource: "web",
}, { email: "sam@acme.com", locale: "en" });
```

### Track an event

```ts
await Retainza.get().track("workout_completed", { minutes: 32 });
```

### Register for push

```ts
// 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.

```ts
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.

```ts
// 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`.

> **Note:** 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:

```ts
<RetainzaInApp presentations={{ banner: MyBanner }} />   // just the banner

<RetainzaInApp render={(msg, { onCta, onDismiss }) => (
  <MyOwnSheet message={msg} onPrimary={onCta} onClose={onDismiss} />
)} />
```

### Deep links from a CTA

```ts
Retainza.get().onNavigate((route) => router.push(route.screen));
```

> **Note:** `@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. |

<!-- section: identity -->

## Signed identity

Your public key is compiled into your app, so anyone who unpacks the app has it. By itself that is enough to act as **any** of your users: overwrite their profile and the email your campaigns go to, register a device for their pushes, or read their pending in-app messages. Signed identity closes that. Your server vouches for each user id, and Retainza refuses calls that claim an id without that proof.

> **Note:** Optional and off by default. Until you turn it on for an environment, nothing here changes how the SDK or the API behaves. It needs an SDK version that supports it: check the changelog of the SDK you use.

### How it works

1. In the dashboard, open **API keys** and create an **identity secret** for the environment. It is shown once. Keep it with your other server secrets.
2. When a user signs in, your server computes a signature of their user id with that secret and returns it to the app with the rest of the session.
3. The app passes it to `identify`. The SDK sends it with every call it makes for that user.

### 1. Sign the user id on your server

The signature is `hex(HMAC-SHA256(identitySecret, userId))`: 64 lowercase hex characters. With the Node SDK:

```ts
import { signUserId } from "@retainza/sdk-node";

const userHash = signUserId(process.env.RETAINZA_IDENTITY_SECRET, user.id);
// return userHash to the app with the rest of the session
```

Any language with an HMAC function produces the same value:

**Python**

```python
import hmac, hashlib
user_hash = hmac.new(secret.encode(), user_id.encode(), hashlib.sha256).hexdigest()
```

**PHP**

```php
$userHash = hash_hmac('sha256', $userId, $secret);
```

**Go**

```go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(userID))
userHash := hex.EncodeToString(mac.Sum(nil))
```

> **Warning:** The identity secret never goes in the app. An app that holds the secret can sign any user id, which is exactly what this is meant to prevent. Sign on your server, send the result.

### 2. Pass it to the SDK

On every launch, alongside the user id:

**React Native / JS**

```ts
await Retainza.get().identify(user.id, attributes, profile, { userHash });
```

**iOS**

```swift
try await Retainza.shared?.identify(user.id, userHash: userHash)
```

**Android**

```kotlin
Retainza.client?.identify(user.id, userHash = userHash)
```

**Flutter**

```dart
await Retainza.instance.identify(user.id, userHash: userHash);
```

If you do not call `identify` at that point (for example you use a `userProvider`), set it directly with `setUserHash(userHash)`.

- The signature is sent with every call for that user id, and never with any other id.
- It is kept in memory, not written to device storage on its own, so pass it again on each launch. Events queued while offline keep the signature they were made with and still verify when they are sent later.
- `reset()` clears it. Identifying a different user without a signature clears it too.
- An app that passes no signature behaves exactly as before.

### 3. Turn it on in stages

| Mode | What the API does |
| --- | --- |
| Off | Takes the user id on trust. The default. |
| Monitor | Checks signatures and counts signed, unsigned and wrongly signed calls. Refuses nothing. |
| Enforce | Answers `401` to a call made with the public key that has no valid signature for the user id it names. |

Ship an app version that sends the signature, switch the environment to **Monitor**, and watch the unsigned count fall as your users update. Switch to **Enforce** when it is at or near zero. Going back to Monitor is immediate if you need to.

> **Warning:** Enforce stops app versions that do not send a signature from identifying users, tracking events and receiving in-app messages. Old versions stay installed for a long time: decide what share of your users you are willing to cut off before you enforce.

### What is checked, and what is not

- Checked: calls made with a **public key** to identify, track, collections, push token registration and removal, in-app pending and both ack routes.
- Not checked: calls made with a **secret key**. That is your server, which is the party doing the vouching.
- Each environment has its own secret and its own mode. Sandbox and Production are independent.
- A signature is tied to one user id. Someone who obtains a user's signature can act as that user only, until you replace the secret.
- It does not cover people who are not signed in (there is no server to vouch for them), and it does not stop a signed-in user sending made-up events about themselves.

### Replacing the secret

Replace it from the same screen. The previous secret keeps working for 7 days, so signatures your apps already hold stay valid while your server moves to the new one. If a secret has leaked, replace it with no grace period: every signature made with the old one stops working at once, and users get a new one at their next sign-in.

### Without the SDK

Send the signature in the `X-Engage-User-Hash` header. A refused call answers `401 identity_required` (no header) or `401 identity_invalid` (wrong signature for that user id).

```bash
curl -X POST https://api.retainza.com/v1/identify \
  -H "authorization: Bearer pk_..." \
  -H "x-engage-user-hash: 5ef80dc2a802d7fc..." \
  -H "content-type: application/json" \
  -d '{"userId":"user-123","attributes":{"plan":"pro"}}'
```

<!-- section: htmlinapp -->

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

```html
<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.

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

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

```bash
npx expo install react-native-webview   # enables HTML in-app messages
```

<!-- section: selfhandled -->

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

```ts
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()} />
```

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

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

<!-- section: attributes -->

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

```ts
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();
```

> **Note:** `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.

<!-- section: offline -->

## 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

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

```ts
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. It also clears the user's [signed identity](https://developer.retainza.com/#identity). 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.

<!-- section: debugging -->

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

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

```ts
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
```

> **Note:** 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

> **Warning:** Editing `.env` and rebuilding is **not enough**. Gradle's `createBundleReleaseJsAndAssets` 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:

```bash
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:

```bash
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.
```

<!-- section: push -->

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

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

<!-- section: deeplinks -->

## Deep links & screens

A campaign's **screen** is an [expo-router](https://docs.expo.dev/router/introduction/) 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.

```json
{
  "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.

```ts
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");
});
```

> **Warning:** **Cold start ordering.** `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.

```ts
export const SCREEN_CATALOG: ScreenTarget[] = [
  { label: "Home",      path: "/(tabs)/home" },
  { label: "Challenge", path: "/challenge/[id]", params: ["id"] },
  // …add your own
];
```

<!-- section: api -->

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

```bash
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"}}'
```

If an environment enforces [signed identity](https://developer.retainza.com/#identity), calls made with the public key must also carry `X-Engage-User-Hash`. Calls made with a secret key never need it.

### Ingest routes (public key from the app, or secret key from your backend)

- POST · `/v1/identify` · identify
- POST · `/v1/track` · track
- POST · `/v1/collections` · collections
- POST · `/v1/token` · token
- DELETE · `/v1/token` · tokenDelete
- POST · `/v1/inapp/ack` · inappAck
- GET · `/v1/inapp/pending` · inappPending
- POST · `/v1/push/ack` · pushAck

### Management routes (this portal only)

- GET · `/v1/projects` · projectsList
- POST · `/v1/projects` · projectsCreate
- POST · `/v1/campaigns` · campaignsUpsert
- GET · `/v1/campaigns` · campaignsList
- POST · `/v1/campaigns/:id/activate` · campaignActivate
- POST · `/v1/campaigns/:id/pause` · campaignPause
- POST · `/v1/send` · send
- POST · `/v1/segments` · segmentsUpsert
- GET · `/v1/segments/:id/count` · segmentCount
- POST · `/v1/connections` · connectionsUpsert
- GET · `/v1/connections` · connectionsList
- GET · `/v1/usage` · usage
- GET · `/v1/billing` · billing
- POST · `/v1/billing/checkout` · billingCheckout
- POST · `/v1/billing/upgrade-checkout` · billingUpgradeCheckout
- POST · `/v1/billing/preview` · billingPreview
- POST · `/v1/billing/change` · billingChange
- POST · `/v1/billing/portal` · billingPortal
- GET · `/v1/analytics/campaigns/:id` · analytics
- POST · `/v1/scheduler/tick` · schedulerTick
- GET · `/v1/users/:id` · userView
- GET · `/v1/users/:id/export` · userExport
- DELETE · `/v1/users/:id` · userErase
