Skip to content

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.

View as Markdownllms.txt

Build this with AI

Send Retainza the data only this backend knows: set user attributes, track server-side events and maintain collections with the secret key, using the REST API as the guide describes. Keep the secret key in server configuration only.

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.

Text
   ┌─────────────────┐  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 knowsYour backend knows
Screen views, taps, session countsPlan tier, billing state, entitlements
Device model, OS, push token, timezoneAnything computed from your own database
What the user did just nowWhat happened in a webhook or a nightly job

The two keys

KeyWhere it may liveWhat it can do
pk_…Inside the app binaryidentify, track, collections, token (register/unregister), in-app fetch/ack, push ack
sk_…Only on your serverAll of the above, plus campaigns, segments, audience, user read and delete

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), which the Node SDK never sends.

1. Install

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

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
});
OptionRequiredNotes
secretKeyyesMust start with sk_. The constructor throws on anything else — the mirror of the device SDKs refusing anything but pk_.
baseUrlnoDefaults to https://api.retainza.com/v1. Must include the /v1 prefix — every path the SDK calls is relative to it.
fetchnoInject 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_…:

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

2. Write from your server

Profile attributes

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

Events

TypeScript
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:

TypeScript
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:

TypeScript
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)

TypeScript
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:

LimitValue
Items per collection100
Collections per profile20
Fields per item25
Size per item2 KB
Collections per profile, total size64 KB
Ops per request50

Item 101 is rejected with a 400, never silently truncated — see When a collection is the wrong tool. The rest of the surface:

TypeScript
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 inWhat goes thereWhy
Events (track)Every occurrence: challenge_completed, order_placedUnbounded, timestamped, queryable by count and recency
Attributes (identify)The rollups: challenges_done, orders_90d, last_order_totalCheap to filter, never hits a cap
CollectionsThe handful of live items a campaign has to look insideField-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.

3. Read profiles

TypeScript
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
NeedCall
One person, with their last 50 events and deliveriesusers.get(userId) — 404 if unknown
The GDPR subject-access payloadusers.export(userId)
Erase someone, irreversibly (profile, events, deliveries and devices)users.delete(userId)
A page of the audienceaudience({ limit, cursor }) — keyset; pass nextCursor back. limit defaults to 50, max 200
How big a segment issegments.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.

4. Devices from your server

Most apps register the push token from the device, and should — see the React Native, iOS, Android and Flutter guides. Register from the server only when your backend is the thing that holds the token.

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

On logout, unregister. This one matters:

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

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.

  • POST/v1/identifyCreate or merge a profileidentify
  • POST/v1/trackRecord an event, fire matching triggerstrack
  • POST/v1/collectionsApply collection ops (up to 50 per request)collections.*
  • POST/v1/tokenRegister a devicedevices.register
  • DELETE/v1/token?userId=&installId=Unregister a device (token= works instead of installId)devices.unregister
  • GET/v1/users/:idOne profile, with recent events and deliveriesusers.get
  • GET/v1/users/:id/exportGDPR exportusers.export
  • DELETE/v1/users/:idEraseusers.delete
  • GET/v1/audience?limit=&cursor=A page of the audience (keyset)audience
  • GET/v1/attributesEverything this project can be segmented on
  • POST/v1/segmentsCreate a segment from codesegments.upsert
  • GET/v1/segments/:id/countAudience size of a segmentsegments.count
  • GET/v1/campaignsList campaignscampaigns.list
  • POST/v1/campaignsCreate a campaign from codecampaigns.upsert
  • POST/v1/campaigns/:id/activateStart a campaigncampaigns.activate
  • POST/v1/campaigns/:id/pauseStop a campaigncampaigns.pause
  • POST/v1/sendSend now, by campaign id or inlinesend
  • GET/v1/usageCurrent metered usageusage

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": "..." } }
StatusCodesMeaning
401no_key, malformed_key, unknown_key, revoked_key, key_revoked, insufficient_scopeAuth failure. insufficient_scope is a pk_ key on a secret-only route.
400bad_requestValidation failure, including a collection cap
429rate_limitedOver the rate limit
429quota_exceededOver a plan quota; error.meter names the meter

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.

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 ids are merge keys, identify merges
Developer docsDashboard

Popular