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 |
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:
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_…:
await retainza.identify("user_123", { plan: "pro" });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"}}'2. Write from your server
Profile attributes
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
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:
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:
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)
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. The rest of the surface:
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 writeEvery 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.
3. Read profiles
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.
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.
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:
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_orgotherwise).X-Engage-Environment: test | live— act on that environment of the project instead of the key's own. Any other value is a400; it is refused with409on an organization still on the legacy single-environment model.
Errors
{ "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 |
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'spk_traffic. Over it you get429 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_exceededand carries the meter that was hit inerror.meter(mtu,apiCalls,pushSent,emailSent).apiCallscounts onlyidentify,track,token,collectionsandsend; 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.unregisteron logout, if your backend owns the token- Retries are safe: collection
ids are merge keys,identifymerges
