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

Built, not yet published · HTTP API · Node SDK not distributed yet

# Retainza from your backend

Write what only your server knows — plan, billing, entitlements — into the same profiles your app writes, with the secret key.

<!-- section: overview -->

## Who writes what

This guide is for a backend developer whose app already has Retainza in the mobile client, and whose server now needs to read and write the same data. That is possible, and always was: the ingest routes accept a secret key as well as a public one, by design.

```
   ┌─────────────────┐  pk_...  ┌──────────────┐  sk_...  ┌──────────────────┐
   │  MOBILE APP     │ ───────► │  RETAINZA    │ ◄─────── │  YOUR BACKEND    │
   │  identify/track │          │  API         │          │  identify/track  │
   │  device tokens  │          │              │          │  collections     │
   └─────────────────┘          └──────────────┘          │  read / delete   │
                                                          │  campaigns, send │
                                                          └──────────────────┘
```

The app and your backend are **co-equal write paths**. They write to the same profile, the same collections, the same event stream. Neither is a proxy for the other, and you do not have to choose — the usual split is:

| The app knows | Your backend knows |
| --- | --- |
| Screen views, taps, session counts | Plan tier, billing state, entitlements |
| Device model, OS, push token, timezone | Anything computed from your own database |
| What the user did just now | What happened in a webhook or a nightly job |

### The two keys

| Key | Where it may live | What it can do |
| --- | --- | --- |
| `pk_…` | Inside the app binary | `identify`, `track`, `collections`, `token` (register/unregister), in-app fetch/ack, push ack |
| `sk_…` | **Only on your server** | All of the above, plus campaigns, segments, audience, user read and delete |

> **Warning:** A secret key reaches billing and key rotation. Put it in an environment variable, never in a repository, never in an app, and never behind a route your frontend can call directly.

Each project has **Sandbox** and **Production**, with separate keys and completely separate data. The key decides which one you are writing to: `sk_test_…` writes Sandbox, `sk_live_…` writes Production. The only override is the raw `X-Engage-Environment` header (see [the REST API](https://developer.retainza.com/server/#api)), which the Node SDK never sends.

> **Note:** Your server also holds a third secret if you turn on identity verification: the environment's identity secret, which signs user ids for the client SDKs. `@retainza/sdk-node` exports `signUserId(identitySecret, userId)` for that — see [Signed identity](https://developer.retainza.com/#identity).

<!-- section: install -->

## 1. Install

> **Note:** `@retainza/sdk-node` is not on the public npm registry yet, and it is not part of the dashboard's SDK download (that bundle carries the React Native packages). Until it is published, every method on this page is a single HTTP call you can make yourself — the shapes are in [5. The REST API underneath](https://developer.retainza.com/server/#api).

Once the package is in your project, create one client and share it:

```ts title="retainza.ts"
import { createRetainzaServer } from "@retainza/sdk-node";

export const retainza = createRetainzaServer({
  secretKey: process.env.RETAINZA_SECRET_KEY!,  // sk_live_... or sk_test_...
  baseUrl: process.env.RETAINZA_API_URL,        // defaults to https://api.retainza.com/v1
});
```

| Option | Required | Notes |
| --- | --- | --- |
| `secretKey` | yes | Must start with `sk_`. The constructor throws on anything else — the mirror of the device SDKs refusing anything but `pk_`. |
| `baseUrl` | no | Defaults to `https://api.retainza.com/v1`. Must include the `/v1` prefix — every path the SDK calls is relative to it. |
| `fetch` | no | Inject your own implementation (tests, proxies, older Node). Defaults to the global `fetch`, so Node 18+. |

No SDK for your language? Every method below is one HTTP call with `Authorization: Bearer sk_…`:

**Node SDK**

```ts
await retainza.identify("user_123", { plan: "pro" });
```

**curl**

```bash
curl -X POST https://api.retainza.com/v1/identify \
  -H "Authorization: Bearer $RETAINZA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"userId":"user_123","attributes":{"plan":"pro"}}'
```

<!-- section: tracking -->

## 2. Write from your server

### Profile attributes

```ts
await retainza.identify("user_123", {
  plan: "pro",
  lifetime_value: 4820,
  trial_ends_at: 1767139200000,
}, { email: "ada@example.com" });
```

The third argument takes `email` and `locale`. Attributes **merge**: fields you omit keep their previous value, so a nightly sync that only computes `lifetime_value` cannot wipe what the app wrote.

> **Warning:** Keep names `lower_snake_case` and keep a value's **type** stable. Nothing pins an attribute to a type, and the segment engine compares strictly — a field sent as `"2"` by your backend and `2` by the app produces a segment that matches half your audience with no error anywhere.

### Events

```ts
await retainza.track("user_123", "subscription_renewed", { months: 12 });
```

This is the same endpoint the app calls, which means it also **fires event-triggered campaigns**. The response says how many triggered:

```ts
const { triggered } = await retainza.track("user_123", "challenge_completed", {
  challenge_id: "lagos_10k",
});
```

Replaying something historical? Pass the real timestamp (epoch milliseconds) as the fourth argument; it defaults to the server's clock:

```ts
await retainza.track("user_123", "order_placed", { total: 89.9 }, order.createdAt);
```

A backfill stamped “now” lands in the wrong reporting window, and can fire a campaign for something that happened last week.

### One-to-many data (challenges, badges, orders)

```ts
await retainza.collections.upsert("user_123", "challenges", {
  id: "lagos_10k",
  status: "done",
  progress: 100,
});
```

`id` is the merge key, so a retried call is safe. Fields you omit on an existing item are preserved, and a bare string is shorthand for `{ id }`. The caps are real and enforced:

| Limit | Value |
| --- | --- |
| Items per collection | 100 |
| Collections per profile | 20 |
| Fields per item | 25 |
| Size per item | 2 KB |
| Collections per profile, total size | 64 KB |
| Ops per request | 50 |

Item 101 is **rejected with a 400**, never silently truncated — see [When a collection is the wrong tool](https://developer.retainza.com/server/#collection-wrong-tool). The rest of the surface:

```ts
await retainza.collections.remove("user_123", "challenges", "lagos_10k"); // missing id is a no-op
await retainza.collections.set("user_123", "challenges", ["a", "b"]);     // replace; [] clears
await retainza.collections.apply("user_123", ops);                         // raw op list, one atomic write
```

Every call returns the profile's collections after the write. A request carries at most **50 ops**, and `set` spends one on the clear — so `set` takes at most 49 items per call; build a larger collection with `apply` over several calls.

### When a collection is the wrong tool

Collections are for **the things you segment on**, and the 100-item cap is a deliberate part of that. If a user can accumulate hundreds of anything — orders, sessions, completed lessons, a full challenge catalogue — do not put the list on the profile:

| Put this in | What goes there | Why |
| --- | --- | --- |
| **Events** (`track`) | Every occurrence: `challenge_completed`, `order_placed` | Unbounded, timestamped, queryable by count and recency |
| **Attributes** (`identify`) | The rollups: `challenges_done`, `orders_90d`, `last_order_total` | Cheap to filter, never hits a cap |
| **Collections** | The handful of *live* items a campaign has to look inside | Field-level filtering — “any challenge where status is done” |

A 400-challenge catalogue with 6 active ones is 6 items in a collection, plus events, plus two aggregate attributes. That shape has no cap problem and segments better than the full list ever would.

<!-- section: usage -->

## 3. Read profiles

```ts
const { profile, events } = await retainza.users.get("user_123");
profile.attributes.plan;         // what any source last wrote
profile.collections?.challenges; // the same items the dashboard shows
profile.pushTokens;              // the devices, newest registration last
```

| Need | Call |
| --- | --- |
| One person, with their last 50 events and deliveries | `users.get(userId)` — 404 if unknown |
| The GDPR subject-access payload | `users.export(userId)` |
| Erase someone, irreversibly (profile, events, deliveries and devices) | `users.delete(userId)` |
| A page of the audience | `audience({ limit, cursor })` — keyset; pass `nextCursor` back. `limit` defaults to 50, max 200 |
| How big a segment is | `segments.count(id)` |

`audience` returns everyone, newest first; its `total`, `reachable` and `byPlatform` describe the whole project, not the page, and `nextCursor` is absent on the last page.

> **Note:** There are no outbound webhooks today: your backend can push into Retainza and read from it, but it cannot yet be *notified* by it. Poll `users.get` or the analytics endpoints if you need to react to delivery state.

<!-- section: push -->

## 4. Devices from your server

Most apps register the push token from the device, and should — see the [React Native](https://developer.retainza.com/react-native/), [iOS](https://developer.retainza.com/ios/), [Android](https://developer.retainza.com/android/) and [Flutter](https://developer.retainza.com/flutter/) guides. Register from the server only when your backend is the thing that holds the token.

```ts
await retainza.devices.register("user_123", {
  token: fcmToken,
  platform: "android",
  installId: "inst_abc123",   // stable per install — see below
  appVersion: "4.2.1",
});
```

Also accepted: `deviceModel`, `osName`, `osVersion` and `tz`.

> **Tip:** **Always send `installId` if you have one.** It is what tells Retainza that a changed token is the same device rather than a new one. FCM and APNs rotate tokens on their own schedule; without an install id every rotation looks like another phone, the device list only grows, and a campaign fans out to tokens that have been dead for months. With one, a rotation replaces the entry in place and the dashboard shows the rotation count.

**On logout, unregister.** This one matters:

```ts
await retainza.devices.unregister("user_123", { installId: "inst_abc123" });
// or match by token: { token: fcmToken }
```

Without it the token stays on the profile the user just left, and the next person to sign in on that handset receives the previous user's campaigns. The server also takes a device off any other profile when it registers against a new user, so this is belt and braces — but that claim only runs when the next user registers, which may be days later.

<!-- section: api -->

## 5. The REST API underneath

Every method above is one call to `https://api.retainza.com/v1`. Authenticate with `Authorization: Bearer sk_…`. The full route reference is on the [API reference](https://developer.retainza.com/#api).

- POST · `/v1/identify` · Create or merge a profile identify
- POST · `/v1/track` · Record an event, fire matching triggers track
- POST · `/v1/collections` · Apply collection ops (up to 50 per request) collections.*
- POST · `/v1/token` · Register a device devices.register
- DELETE · `/v1/token?userId=&installId=` · Unregister a device (token= works instead of installId) devices.unregister
- GET · `/v1/users/:id` · One profile, with recent events and deliveries users.get
- GET · `/v1/users/:id/export` · GDPR export users.export
- DELETE · `/v1/users/:id` · Erase users.delete
- GET · `/v1/audience?limit=&cursor=` · A page of the audience (keyset) audience
- GET · `/v1/attributes` · Everything this project can be segmented on
- POST · `/v1/segments` · Create a segment from code segments.upsert
- GET · `/v1/segments/:id/count` · Audience size of a segment segments.count
- GET · `/v1/campaigns` · List campaigns campaigns.list
- POST · `/v1/campaigns` · Create a campaign from code campaigns.upsert
- POST · `/v1/campaigns/:id/activate` · Start a campaign campaigns.activate
- POST · `/v1/campaigns/:id/pause` · Stop a campaign campaigns.pause
- POST · `/v1/send` · Send now, by campaign id or inline send
- GET · `/v1/usage` · Current metered usage usage

### Optional headers (secret key only)

Both are ignored on a `pk_` key. The Node SDK sends neither.

- `X-Engage-Project: <projectId>` — act on another project in the same organization (`403 cross_org` otherwise).
- `X-Engage-Environment: test | live` — act on that environment of the project instead of the key's own. Any other value is a `400`; it is refused with `409` on an organization still on the legacy single-environment model.

### Errors

```json
{ "error": { "code": "...", "message": "..." } }
```

| Status | Codes | Meaning |
| --- | --- | --- |
| `401` | `no_key`, `malformed_key`, `unknown_key`, `revoked_key`, `key_revoked`, `insufficient_scope` | Auth failure. `insufficient_scope` is a `pk_` key on a secret-only route. |
| `400` | `bad_request` | Validation failure, including a collection cap |
| `429` | `rate_limited` | Over the rate limit |
| `429` | `quota_exceeded` | Over a plan quota; `error.meter` names the meter |

> **Note:** The Node SDK does not parse this body: any non-2xx throws an `Error` whose message is `Retainza <METHOD> <path>: <status> <body>`, and it never retries.

### Notes that save an afternoon

- **No batch endpoint yet.** A bulk sync is N calls; keep them sequential-ish and respect 429s. The rate limit is a token bucket of 50 requests/second sustained, bursting to 100, per project per key type — your `sk_` traffic shares a bucket with the dashboard, not with the app's `pk_` traffic. Over it you get `429 rate_limited`.
- **No idempotency key on `/v1/track`.** A retried job double-counts the event and can re-fire a trigger. Deduplicate on your side for now.
- **Quotas are per plan.** A quota 429 is `quota_exceeded` and carries the meter that was hit in `error.meter` (`mtu`, `apiCalls`, `pushSent`, `emailSent`). `apiCalls` counts only `identify`, `track`, `token`, `collections` and `send`; reads are free. Monthly tracked users (MTU) counts a user the first time they are written in a calendar month (UTC), from either the app or your backend, and is counted per environment. Sandbox traffic is recorded but not billed.

<!-- section: checklist -->

## Checklist before you ship

- `sk_` key in an environment variable, not in the repo — and never in an app
- Sandbox key in staging, Production key in production
- Attribute names `lower_snake_case`, types stable across app and backend
- Historical events pass their real `ts`
- Collections bounded, long lists modelled as events plus rollups
- `devices.unregister` on logout, if your backend owns the token
- Retries are safe: collection `id`s are merge keys, `identify` merges
