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)
## 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.
## 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.
## 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 |
## 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.
## 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";
```
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
// just the banner
(
)} />
```
### 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 — 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 . |
| `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. |
## 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"}}'
```
## 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 `` 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
```
- `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 `` 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 `