Integration Guide¶
This guide walks you through integrating an external system — a point-of-sale, a mobile app, a website, an ESP, or a back-office batch job — with ReactorCX (RCX).
It is task-oriented and assumes you already have:
- The HTTPS base URL of your RCX tenant (for example,
https://your-tenant.reactorcx.com). - A user account with API permissions on that tenant. Throughout this guide we use
demo/adminwith passwordasj4hjshd3js2&as the example credentials. Replace these with the credentials issued to you for your tenant. - A tool to make HTTPS requests. The examples are written in
curl, Node.js, and Python, but the API is plain HTTP/JSON, so anything works.
If you are looking for the full field-by-field reference of every endpoint, see the REST and Event API Reference. This guide is the narrative companion to that reference — it shows how the endpoints fit together to accomplish real integration tasks.
How the pieces fit together¶
sequenceDiagram
participant Client as Your system
participant RCX as ReactorCX API
participant Engine as Rule Engine
Client->>RCX: POST /api/v1/login (username, password)
RCX-->>Client: { token: "<JWT>" }
Note over Client: Reuse the token for ~8 hours
Client->>RCX: POST /api/v1/activity (Bearer token)
RCX->>Engine: Run the activity through the program's rules
Engine-->>RCX: data, log, errors
RCX-->>Client: 200 (or 4xx on engine error) { data, log, errors }
RCX->>RCX: Emit Level 1 + Level 2 events
The two operations you will perform most often are:
- Log in once, hold onto the JWT, and present it as
Authorization: Bearer <token>on every subsequent call. - Post activities to
/api/v1/activity. Every customer-facing thing your business does — a purchase, a return, a click, a check-in — becomes an Activity. RCX runs it through the program's rules and returns the result.
Everything else in this guide — enrolling, looking up profiles, listing offers — supports those two operations.
A note on environments¶
The examples use the placeholder host https://your-tenant.reactorcx.com. Replace it with the actual HTTPS hostname of your RCX tenant. RCX is always accessed over HTTPS; JWTs are bearer credentials and must not traverse plain HTTP.
When you copy code from this guide, the only things you typically have to change between environments are:
- The hostname
- The login credentials (and therefore the
orgprefix of the username) - The program
_idyou are working against
The shape of every request and response stays the same.
Concepts¶
Before you write integration code, it helps to know what the nouns in the RCX API mean. This section is a glossary of the entities you will see on the wire.
Org¶
The top-level container for everything you own in RCX. A login carries an org context (the part before the / in your username, e.g. demo/admin → org demo). Every API call you make is implicitly scoped to your org; you cannot read or write into another org's data.
Most integrations only ever interact with one org and never need to handle it explicitly. You will mostly see it as the org ObjectId on returned documents.
Program¶
A Program is one loyalty program inside an org. It owns the tier policies, purse policies, reward catalogue, and rule flows. Members belong to exactly one program at a time (they can hop between programs, but at any moment they are in one).
Most integrations only need to know the program's _id — you look this up once and then bake it into your configuration.
Member¶
A Member is one customer's membership in one program. It carries identity (name, email, address), state (status, enroll date, last activity date), and the running balances on tiers, badges, purses, and streaks.
A member is the entity rules operate on. Almost every operation in RCX either reads a member, writes a member, or mutates one through a side effect.
Loyalty ID¶
A LoyaltyID is a string identifier you use to look up a member from the outside. A member can have many loyalty IDs (loyalty card number, mobile phone number, external CRM id, etc.) and each has a name indicating its type (for example PlayerId, MobileNumber, LoyaltyCard).
In practice, the loyalty ID is the one piece of identity that flows between your system and RCX on every transaction. Your POS may not know the RCX _id of a member; it knows the loyalty card number the customer presented. RCX takes the loyalty ID, finds the corresponding member, and proceeds.
When you post an activity you pass either loyaltyID (the string) or memberID (the ObjectId). Use loyaltyID whenever you can — it is the human-facing identifier.
Activity¶
An Activity is one customer action submitted to RCX for processing. Activities have a type — Accrual, Redemption, Cancellation, Register, Use Reward, custom types you have defined — and optional lineItems describing what was purchased and tenderItems describing how it was paid for (each conforming to the Line Item and Tender Item schemas), plus an ext block for any program-defined extension fields.
When you POST an activity, RCX runs it through the rule engine for that member's program. The rules may add points to a purse, change a tier, award a reward, fire side effects, post events. The response includes a data section (what changed), a log section (which rules ran and what they did), and an errors section (what failed, if anything).
Rule and Rule Flow¶
A Rule is one unit of program logic — for example, "if this is a hotel stay over $200, add 100 tier credits". Rules are organised into Flows, and flows attach to activity types. When an activity comes in, the engine picks the right flow, walks through its rules in order, and emits results.
Most integrations don't manipulate rules over the API — they are authored in the program admin UI. But the activity response shows you which rules ran (ruleMatch), and that is useful for debugging integrations.
Purse, Tier, Badge, Streak¶
These are the four kinds of running state a member carries:
- Purse — a counter, typically points. Programs usually have several purses (Rewards Points, Tier Credits, etc.).
- Tier — a categorical level the member is in (Sapphire, Pearl, Gold). Tiers are governed by a
TierPolicyand recompute based on activity. - Badge — a discrete, named achievement.
- Streak — a goal-tracking object with a target and an accrued value, used for time-bound campaigns.
When you fetch a member, you get arrays of all four. When you post an activity, the response's data section tells you how the activity moved each one (prev and new).
Offer and Reward¶
Catalogue and wallet are two different things:
- The catalogue of everything a program can award — both rewards and offers — lives in
/api/v1/rewardpolicies. ARewardPolicyis the template: name, value, point cost, eligibility window, limits, the kind of wallet entry it produces when issued. There is one catalogue route for both rewards and offers. - The wallets hold instances actually issued to members.
/api/v1/rewardsis the Rewards wallet;/api/v1/offersis the Offers wallet. Each entry references theRewardPolicythat minted it.
The split between the two wallets is mostly a usage convention:
- The Rewards wallet usually holds things a member earned or purchased with points — "$50 off voucher purchased for 5,000 points", "Free Night Certificate earned at tier-up".
- The Offers wallet usually holds granted benefits — bounce-back coupons, periodic offer drops, appeasement coupons, discount codes.
There is no hard-and-fast rule for which wallet a given benefit belongs in. The practical difference today is that rewards do not support per-day / per-week / per-offer redemption limits, while offers do. If your benefit needs frequency caps, it goes in the Offers wallet; otherwise either is fine.
Both wallets share the same redemption pattern from an integration's point of view: the member presents a unique code, and you post an activity that includes that code in couponCode. A rule wired with "Use Offer by Code" or "Use Reward by Code" recognises it and consumes the entry.
Event¶
An Event is RCX telling you something happened. There are two levels:
- Level 1 — per-entity insert/update/delete, idempotent. Useful for driving search indexes, pushing attribute updates to ESP/push-notification vendors, computing live aggregates, and feeding real-time analytics dashboards.
- Level 2 — semantic events (tier up, points added, member enrolled), useful for triggering communications and downstream automation.
You typically subscribe to events via a webhook URL configured on your tenant; the API endpoints exist mostly for replay and inspection.
How identifiers look¶
| What you'll see | Example | Notes |
|---|---|---|
| ObjectId | 6967e24271ec0d3de9cf9885 |
24-character hex; primary key for every entity |
| Loyalty ID | test-1775799600099 |
Free-form string; the lookup key from outside |
| JWT token | eyJhbGciOiJI... |
What you get from /login and present as Bearer |
| Date | 2026-05-14T03:13:43.379Z |
ISO 8601 UTC by default; see Date Formats for variants |
Getting started¶
In this section you will, in about ten minutes:
- Authenticate against the API and capture a token.
- List a program and pick its
_id. - Look up a member by loyalty ID.
- Post a simple accrual activity and inspect the response.
This is the smallest end-to-end "loop" any RCX integration performs. Once you have done it once, every other endpoint in the API is a variation on the same pattern.
Set your environment¶
All commands assume two shell variables:
export BASE="https://your-tenant.reactorcx.com" # your RCX tenant URL
export CREDS='{"username":"demo/admin","password":"asj4hjshd3js2&"}'
The username is org/login — demo is the org code, admin is the user. Replace both halves with the credentials issued for your tenant.
Log in¶
TOKEN=$(curl -sS -X POST "$BASE/api/v1/login" \
-H 'Content-Type: application/json' \
-d "$CREDS" | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')
echo "$TOKEN" | head -c 40
The response body looks like:
token is a JWT you will send on every subsequent request as Authorization: Bearer <token>. Tokens are good for several hours; see Authentication for the full lifecycle.
Always use HTTPS
The JWT is a bearer credential — anyone who captures it on the wire can impersonate the user until it expires. RCX only accepts HTTPS traffic; never bypass it.
Look at your account¶
Confirm the token works:
{
"id": "69670030577c429a3dae1bd8",
"login": "admin",
"email": "admin@demo.example.com",
"empName": "Demo Administrator",
"possibleDivisions": [],
"division": null,
"divisionCheckEnabled": false
}
If you get 401 Unauthorized here, either the token is wrong or the header is malformed — make sure you sent Authorization: Bearer <token> (with a space, with Bearer).
Find a program¶
You need a program _id for almost everything else. Most integrations only ever target one program, so look it up once and persist the id.
curl -sS "$BASE/api/v1/programs?limit=5" -H "Authorization: Bearer $TOKEN" \
| python3 -c 'import json,sys;[print(p["_id"], p["name"]) for p in json.load(sys.stdin)]'
Store that id:
Look up a member by loyalty ID¶
The fastest way to find a member from the outside is by their loyalty ID. There are two endpoints; use whichever fits your call site:
| Endpoint | Use when |
|---|---|
GET /api/v1/members/:lid/profile |
You want the full profile and you know the loyalty ID is unique in your org. |
GET /api/v1/members/findByLoyaltyId?query={...} |
You want richer query semantics or to combine the loyalty ID with extra filters. |
export LID="test-1775799600099"
curl -sS "$BASE/api/v1/members/$LID/profile" -H "Authorization: Bearer $TOKEN" \
| python3 -m json.tool | head -25
{
"member": {
"enrollDate": "2026-04-10T05:40:00.099Z",
"enrollChannel": "POS",
"status": "Anonymous",
"program": "6967e24271ec0d3de9cf9885",
"firstName": "Unknown",
"lastName": "Unknown",
"tiers": [
{ "level": { "name": "Sapphire" }, "primary": true }
],
"purses": [
{ "name": "Rewards Points", "balance": 0, "availBalance": 0 },
{ "name": "Tier Credits", "balance": 0 }
]
}
}
The member object is the full record; tiers[].level.name is the current tier and purses[] are all the running balances. See Members for details on every field.
Post an accrual¶
This is the operation your point-of-sale (or website, or app) will perform on every customer-attributed transaction. The shape is the same regardless of the action — only the type and the auxiliary fields change.
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "Accrual",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType": "Web",
"srcChannelID": "Corporate",
"loyaltyID": "test-1775799600099",
"value": 10,
"currencyCode": "USD",
"lineItems": [
{ "lineNo": 1, "itemPrice": "10", "itemSKU": "DEMO-SKU", "quantity": 1, "itemAmount": 10, "itemUOM": "each" }
]
}' | python3 -m json.tool
The response includes three things you will use:
{
"data": {
"purses": [
{ "name": "Rewards Points", "prev": 0, "new": 10, "prevAvail": 0, "newAvail": 10 },
{ "name": "Tier Credits", "prev": 0, "new": 10 }
],
"prevTier": "Sapphire",
"newTier": "Sapphire",
"activityId": "6a053db7e58462caded04bc2",
"status": "Processed"
},
"log": [
{ "action": "populateMember", "rule": "...", "ruleName": "Load Data" },
{ "action": "populateLocation", "rule": "...", "ruleName": "Load Data" },
{ "action": "addPoints", "rule": "...", "ruleName": "Base Earn" }
],
"errors": []
}
datais the canonical "what changed" object.pursesshows balance deltas,prevTier/newTierflags tier transitions, andactivityIdis the persisted activity record you can refer back to (for example, when issuing a cancellation).logis the rule trace — every rule action that fired, in order. Invaluable when you are debugging "why didn't this transaction award points".errorsis non-empty only when the engine refused part or all of the activity. The transaction may still be persisted (withstatus: "Error") so you can investigate — see Error Handling.
Authentication¶
Every endpoint under /api/v1/, except login, send-userid, and reset-password, requires a valid bearer token. This section covers everything you need to know to obtain, use, refresh, and revoke that token.
The model¶
Every API caller signs in with an RCX user account and receives a JWT in return. The token is then presented as a bearer credential on every subsequent request.
A token has three pieces:
| Piece | Purpose |
|---|---|
| Header | Algorithm (HS256) and type (JWT). |
| Payload | Encrypted user identity (id, org, email, iat) plus an exp claim. |
| Signature | HMAC over the header and payload, verified on every request. |
The signing key is held only by the API. The encrypted payload means the token is opaque from the outside — you should not attempt to introspect its contents in your client code. Treat it as a bearer credential, like a session cookie.
Logging in¶
Username format¶
The username field uses the <org>/<login> form. The org is the slug of your tenant; the login is the user name within that org.
If you send a username without a slash, login fails — RCX has no way to know which org you mean.
Request¶
curl -sS -X POST "$BASE/api/v1/login" \
-H 'Content-Type: application/json' \
-d '{
"username": "demo/admin",
"password": "asj4hjshd3js2&",
"locale": "en"
}'
locale is optional and defaults to en. The response on success:
version and build identify the deployed API. Capture them in your logs so that, if you need to contact RCX support, the build you were running against is unambiguous.
Failure modes¶
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 401 | 1010 | Token missing or invalid on a protected route. | Log in again. |
| 401 | 1110 | Login credentials incorrect. | Check both halves of the username and the password; the missing org/ prefix is the most common mistake. |
| 400 | — | Body validation failed. | Check field types — username, password, and locale are strings. |
| 500 | — | Server-side error. | Retry once with backoff; if it persists, report to RCX support. |
RCX intentionally returns the same vague message for "user not found" and "wrong password" so attackers can't enumerate accounts. If you cannot log in, verify the credentials out-of-band rather than parsing the error.
Presenting the token¶
Set the standard Authorization header on every request:
Other forms (e.g. a Token header, or the JWT in the query string) are not supported on this API.
Token lifetime¶
Expiration is driven by the User record, not by a global tenant setting. Two fields on the user control it:
| Field | Default | Effect |
|---|---|---|
tokenExpirationTime |
480 | How long a session lives, in minutes. |
sessMgmtFlag |
true |
When true, the session is treated as idle-timeout: tokenExpirationTime resets on every authenticated call, and a token expires only after that many minutes of inactivity. When false, the token has a hard expiry at the exp claim baked in at login time. |
The default of 480 minutes (8 hours) with idle-timeout management is what UI sessions use. For long-running integration users, an administrator may raise tokenExpirationTime or disable sessMgmtFlag for hard fixed-lifetime tokens — talk to your RCX administrator if the defaults are wrong for your use case.
There is no "refresh token" flow. When a token expires, log in again.
For long-running integrations, a robust pattern is:
- Log in at startup and cache the token (in memory only).
- Use it for every call.
- On receiving
401 Unauthorizedfrom any endpoint, log in again and retry the call once. - Never persist tokens to disk or version control.
A reference Node.js implementation of that pattern is in Recipes.
Logging out¶
Logout invalidates the token on the server side (when sessMgmtFlag is on) and is the right thing to do at the end of an interactive UI session. For long-lived machine-to-machine integrations, logout is rarely needed — just drop the token.
Password reset¶
The forgotten-password flow is a human-driven, email-based process — it cannot be automated through the API. Two endpoints implement it and both always return the same generic message regardless of whether the user exists, to prevent enumeration:
POST /api/v1/reset-passwordwith{ "userID": "<org>/<login>" }— triggers the email with a reset link.POST /api/v1/reset-passwordwith{ "token": "<from-email>", "password": "..." }— sets the new password after the user clicks the link.POST /api/v1/send-useridwith{ "email": "..." }— for the "forgot your username" case.
Surface these in your end-user UIs; do not wire them into integration code.
Permissions¶
A successful login does not by itself grant you access to every endpoint — the user behind the token still has to have the right permissions on the resources you call. Permissions are managed in the Security Admin UI; from the integration's perspective they manifest as 403 Forbidden on a call you expected to succeed.
If you are building an integration user, give it the minimum permissions it actually needs (typically Activity.create, Member.read, LoyaltyID.read, and the offer/reward reads). See the Security Administration Guide for the model.
Divisions¶
In multi-brand tenants, users can be scoped to a division. When that is the case:
GETcalls automatically filter to the user's division.- You can bypass the filter on a single call by appending
?ignoreDivisionCheck=trueto aGET. This is intended for support tooling, not normal traffic.
Most single-tenant integrations are not divisioned and can ignore this.
Members¶
A Member is one customer's membership in one program. Members are the entity you create at enrolment time and reference on every activity afterwards.
This section covers the four things you almost always need to do with members:
- Enrol a new member.
- Look one up by loyalty ID.
- Read the full profile, balances and tiers.
- Update CRM fields.
For exhaustive field-level reference, see Members in the API Reference.
The shape of a member¶
A member document looks like this (truncated):
{
"_id": "69d88d30a7ef554b121cf584",
"program": "6967e24271ec0d3de9cf9885",
"status": "Active",
"type": "Individual",
"enrollDate": "2026-04-10T05:40:00.099Z",
"enrollChannel": "POS",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"cellPhone": "+13105551212",
"tiers": [
{ "name": "Base", "primary": true,
"level": { "name": "Sapphire", "number": 1 },
"achievedOn": "2026-04-10T05:40:00.099Z",
"requalsOn": "3000-01-01T00:00:00.000Z" }
],
"purses": [
{ "name": "Rewards Points", "balance": 0, "availBalance": 0, "primary": false },
{ "name": "Tier Credits", "balance": 0, "primary": true }
],
"badges": [],
"streaks": [],
"ext": {}
}
The interesting parts for an integration:
| Field | Notes |
|---|---|
_id |
RCX's primary key. Useful when posting activities by memberID instead of loyaltyID. |
program |
The program this membership belongs to. Members move between programs via the hop endpoint. |
status |
Active, Anonymous, PreEnrolled, Pending, Cancelled, Merged. |
tiers[] |
All the tiers this member holds. primary: true is the one most UIs show. |
purses[] |
All running balances. balance is the total; availBalance excludes escrow. |
ext |
Free-form extension fields you have configured at the schema level. |
Enrolling a new member¶
If your integration is the system of record for sign-ups (a website, a mobile app, a kiosk), you call this on every new customer.
curl -sS -X POST "$BASE/api/v1/members/enroll" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"program": "6967e24271ec0d3de9cf9885",
"member": {
"firstName": "Demo",
"lastName": "Integration",
"email": "demo-1778728423@example.com",
"enrollChannel": "Web",
"acquisitionChannel": "Web"
},
"loyaltyIds": [
{ "loyaltyId": "demo-1778728423", "name": "PlayerId", "primary": true }
]
}'
The response is the freshly created member, including the auto-generated _id, the tier they started in, and the empty purses ready to accrue against:
{
"member": {
"_id": "6a053de7e58462caded04d0b",
"status": "PreEnrolled",
"program": "6967e24271ec0d3de9cf9885",
"firstName": "Demo",
"lastName": "Integration",
"email": "demo-1778728423@example.com",
"enrollDate": "2026-05-14T03:13:43.379Z",
"tiers": [{ "level": { "name": "Sapphire", "number": 1 }, "primary": true, "_id": "..." }],
"purses": [{ "name": "Rewards Points", "balance": 0, "_id": "..." }, ...]
}
}
Top-level fields¶
| Field | Required | Notes |
|---|---|---|
program |
Yes | ObjectId of the program. Must be in your org. |
member |
Yes | The CRM payload. firstName, lastName, email, enrollChannel, acquisitionChannel are typically required by the program's enrol settings. |
loyaltyIds |
Recommended | Array of loyalty IDs to attach. At least one is usually needed so the member is reachable from outside. |
preferences |
Optional | Initial communication preferences. |
segments |
Optional | Pre-assigned segments. |
Query parameters¶
| Param | Default | Effect |
|---|---|---|
useTransaction |
false |
Run the enrolment as an atomic transaction for stricter rollback guarantees. Use when you have an external follow-up (e.g. push to ESP) and need a single rollback point. |
suppressDuplicate |
false |
If a loyalty ID collides with an existing member, succeed silently instead of returning 409. |
Common failure modes¶
| Status / code | Meaning |
|---|---|
400 / 4006 Invalid program ID. |
program is not a valid ObjectId, or is missing from the top-level body. |
400 / Program not found |
The program ObjectId is well-formed but doesn't exist in your org. |
409 / Duplicate loyalty ID |
One of the loyalty IDs you supplied is already taken. |
400 / Duplicate within request |
Two entries in loyaltyIds have the same loyaltyId string. |
PreEnrolled vs Active
A member created without enough information is PreEnrolled. As activities come in (or as you PATCH the CRM record with the missing fields), the program's enrol logic flips them to Active. Whether you want to start PreEnrolled or skip that stage depends on the program's settings.
Looking up by loyalty ID¶
This is the single most common operation in any integration: you have a loyalty ID off the wire, you want the member behind it.
The fast path¶
Returns { "member": { ... } } (or 404 if the loyalty ID is not known in your org).
The query path¶
When you need more than a single equality on loyaltyId:
curl -sS --get "$BASE/api/v1/members/findByLoyaltyId" \
--data-urlencode 'query={"loyaltyId":"test-1775799600099"}' \
-H "Authorization: Bearer $TOKEN"
The query parameter is a JSON object; keys are matched against the loyalty ID record (loyaltyId, name, status, ...). Prefix a string with ~ to do a case-insensitive regex match:
findByLoyaltyId always returns an array (zero or one element). Use findByLoyaltyId/count to ask just for the count:
curl -sS --get "$BASE/api/v1/members/findByLoyaltyId/count" \
--data-urlencode 'query={"loyaltyId":"test-1775799600099"}' \
-H "Authorization: Bearer $TOKEN"
Picking which endpoint to call¶
- Use
/members/:lid/profilewhen you have a single loyalty ID and want the canonical "render this member" payload. - Use
/members/findByLoyaltyIdwhen you need to query, when you want to project specific fields withselect, or when you want to use the structured query/regex form.
Reading the full profile¶
/members/:lid/profile already gives you the full member document, but a few related sub-resources are exposed as their own endpoints because they grow independently of the member record.
Aggregates¶
Rolled-up activity statistics over a time window:
curl -sS --get "$BASE/api/v1/members/$MEMBER_ID/aggregates" \
--data-urlencode 'aggregatePolicy=Lifetime' \
-H "Authorization: Bearer $TOKEN"
The query parameters select which AggregatePolicy to evaluate. See Aggregates for the policy model.
Rules visible to a member¶
What rules the engine would currently apply for this member, in their current state:
Useful for a "what's running for this customer right now" support view.
Streaks¶
Available streaks (by loyalty ID)¶
Updating CRM fields¶
Use the crmprofiles endpoint when you only want to change the customer-data attributes (name, email, marketing preferences, extension fields) without touching the program-managed parts (tiers, purses).
curl -sS -X PATCH "$BASE/api/v1/crmprofiles/$MEMBER_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "cellPhone": "+13105550199", "city": "Las Vegas" }'
PATCH does a partial update; PUT replaces the writable fields. Tier and purse fields are immutable through this endpoint — modify them by posting an activity instead.
Changing programs¶
Move a member from one program to another:
curl -sS -X POST "$BASE/api/v1/members/$MEMBER_ID/hop/$NEW_PROGRAM_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
The hop endpoint also re-runs the new program's enrolment rules. Use sparingly — it is a heavyweight operation.
Setting preferences¶
curl -sS -X POST "$BASE/api/v1/members/$MEMBER_ID/setpreferences" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"preferences": [
{ "name": "Email", "value": true, "category": "Marketing" },
{ "name": "SMS", "value": false, "category": "Marketing" }
]
}'
Setting a member's tier¶
For support cases where a tier needs to be set explicitly (a goodwill upgrade, a manual downgrade, a status match from another program), post a Set Tier activity and put the target tier level name in couponCode:
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "Set Tier",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType":"Staff",
"srcChannelID": "Corporate",
"loyaltyID": "abc123",
"couponCode": "Pearl",
"reasonCode": "StatusMatch"
}'
The program's Set Tier flow reads couponCode, validates the level exists in the tier policy, and applies the change — including any pre/post side effects the program has wired in (welcome notifications, badge grants, expiration recompute). Because it goes through the same rule pipeline as any other activity, the result is auditable in ActivityHistory and downstream events fire normally.
Linked accounts¶
An Account in RCX is a grouping of distinct members. Members on their own represent individual memberships; an Account ties two or more of them together so that program rules can reason about them as a unit. There are two common patterns:
- Households and family groupings. Multiple members (parents, kids, partners) share a single account so that rules can pool points, apply household-level offers, or enforce per-household limits. Every member still keeps their own record, tier, and history.
-
Multi-regional / multi-program programs. When a single loyalty identity needs to be honoured across program boundaries — for example, a global brand running separate
US Rewards,Canada Rewards, andEU Rewardsprograms — the linked Account holds one member per program under that identity. TheLoyaltyIDis attached to whichever of those members is currently active; as the customer's active program changes, the LoyaltyID follows them.Example: a US-resident member travels to Canada and transacts at a Canadian property. The account flips its
activeMemberto the Canadian member, the LoyaltyID is reassigned to point at the Canadian member, and subsequent activities posted with that loyalty ID resolve to the Canadian membership in the Canadian program. When they return home, the active member switches back. From the integration's point of view this is transparent — you keep posting activities with the same loyalty ID, and RCX routes each one to the right membership.
Primary (active) member¶
activeMember on the Account is the primary member at any given time — head of household for the household case, current-region membership for the multi-region case. Rules can branch on whether the member processing the current activity is the primary or not (e.g. "only the primary member can redeem the household reward").
Account-aware rules¶
The engine has account-walking rule actions you compose program logic with:
- Lookup Account Members — populate context with the other members in the account.
- Lookup Peer Member — pick a single peer (by program, by role) for cross-member operations.
Account management endpoints¶
| Endpoint | Purpose |
|---|---|
GET /api/v1/accounts/member/:memberId |
Find the account a member belongs to. |
GET /api/v1/accounts/totalpoints/:accountId/:purseId |
Sum a purse balance across every member in the account. |
GET /api/v1/accounts/maxtier/:accountId/:tierId |
Find the highest tier level held by any member in the account. |
POST /api/v1/accounts/unlink |
Remove a member from an account. |
GET/POST/PATCH/DELETE /api/v1/accounts |
Standard CRUD on the account itself — create new accounts, add members, change activeMember. |
The day-to-day integration usually just posts activities and lets the engine resolve the account context. Account composition is administered explicitly through the endpoints above when you need to add or remove members from an account, or change which one is active.
Merging members¶
A merge consolidates two member records that turn out to be the same physical customer enrolled twice — for example, the customer enrolled on the web, then again in-store, and you end up with two separate memberships. The merge picks a survivor (the record that lives on) and victims (records absorbed into the survivor). The survivor inherits the victims' activity history, balances, loyalty IDs, and rewards; the victim records are marked Merged and stop accruing new state of their own.
Merging is purely about deduplicating member records. It is not how you compose linked accounts — see Linked accounts for that pattern.
Performing the merge¶
curl -sS -X POST "$BASE/api/v1/members/$SURVIVOR_ID/merge/$VICTIM_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
The survivor and victim are identified by RCX _id. The body can carry optional merge parameters; the default is a full merge with all balances and history transferred.
For the operational story (when to merge, what to check first, how to handle conflicts), see Member Merge in the Member Management Guide.
Following a merged loyalty ID¶
When a loyalty ID was attached to a victim, it is transferred to the survivor as part of the merge. If an external system still has the old memberId cached and posts a request against it, use:
to walk from the loyalty ID to the current survivor. (Activities posted with a loyaltyID resolve transparently — this endpoint is for client code that holds member ids directly.)
You can also opt-in to automatic merge resolution on activity processing by passing ?resolveMerge=true on the POST.
Merge audit trail¶
Every merge is recorded in the merge trail for traceability and unmerge. To inspect the trail for a member:
This returns the merge history affecting the member — survivors, victims, timestamps, the user who performed the merge, and which fields/balances/history moved. Use it when investigating "where did this customer's points go?" or building a UI that exposes a member's lineage.
Trail entries can also be amended via PATCH /api/v1/mergetrail/:id — typically to annotate a merge that needs to be revisited.
Unmerging members merged in error¶
When a merge was a mistake (two customers who looked like duplicates turned out to be distinct), you can roll it back:
curl -sS -X POST "$BASE/api/v1/members/unmerge/$SURVIVOR_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
The merge trail is the input — RCX walks the trail to figure out what to extract back to the victim. The activity history, balances, and loyalty IDs that came in via the merge get split back out to the resurrected victim record; activities that landed on the survivor after the merge stay on the survivor unless your program has rules to handle them differently.
Unmerge is a heavyweight operation — it touches every record that the merge touched. It's an admin/support tool, not something to wire into routine flows.
Activities¶
If members are the state of your loyalty program, activities are the events that change that state. Every meaningful customer action — buying something, returning it, redeeming a reward, checking in at a venue, completing a form — becomes an Activity and goes through POST /api/v1/activity.
This section is the deep dive on that one endpoint. Get it right, and the rest of the integration is almost mechanical.
Anatomy of an activity¶
A minimal accrual:
{
"type": "Accrual",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType": "Web",
"srcChannelID": "Corporate",
"loyaltyID": "test-1775799600099",
"value": 10,
"currencyCode": "USD",
"lineItems": [
{ "lineNo": 1, "itemPrice": "10", "itemSKU": "DEMO-SKU", "quantity": 1, "itemAmount": 10, "itemUOM": "each" }
]
}
Required identification¶
You must identify two things on every activity: who is acting and where they are acting.
| Who (pick one) | What you send |
|---|---|
| You know the loyalty ID | "loyaltyID": "abc123" |
| You know the RCX member id | "memberID": "6967..." |
| It's an anonymous transaction | Omit both; RCX will auto-enrol an anonymous member if autoEnroll is on. |
| Where | What you send |
|---|---|
| Channel kind | "srcChannelType": "POS" / "Web" / "Mobile" / ... |
| Specific channel/store | "srcChannelID": "<location name>" |
srcChannelID is the name of a Location document. If you send a value RCX doesn't know, the activity is rejected with a "location is not known" error. Pre-seed locations in the admin UI before you go live.
Required when applicable¶
| Field | When you need it |
|---|---|
date |
Always recommended; defaults to "now" if omitted, but explicit times survive retries cleanly. See Date formats. |
value |
The monetary or unit total. Defaults to the sum of itemPrice * quantity across lineItems. |
currencyCode |
When the program is multi-currency. ISO 4217 (USD, EUR, JPY). |
externalTxnId |
Your own transaction id. Required for safe retries — see Idempotency. |
partnerCode |
When the activity comes through a coalition partner. |
couponCode |
A reward, offer, or promo code being applied. |
targetProgram |
Optional override for multi-program linked accounts. By default the loyalty ID resolves to the account's currently active member; pass targetProgram only when you need to force routing to a specific program's membership in the account instead. |
Structured detail¶
| Field | Use for |
|---|---|
lineItems[] |
What was purchased. Each entry conforms to the Line Item schema (lineNo, itemSKU, itemPrice, quantity, itemAmount, itemUOM, …). See Line items below for the field list. |
tenderItems[] |
How it was paid for. Each entry conforms to the Tender Item schema (lineNo, type, value, itemNo, …). See Tender items below. |
ext |
Extension fields the tenant has configured (managed via ExtensionSchema). |
Activity types¶
type selects the rule flow that processes the activity. Standard built-in types include:
| Type | Use for |
|---|---|
Accrual |
A purchase that earns points / tier credits. |
Cancellation |
A return or void of a prior accrual. |
Redemption |
A points spend at the till. |
Use Reward |
Consumption of a previously granted reward. |
Register |
An engagement event (form submit, profile completion). |
Enrollment Bonus |
An initial bonus on sign-up. |
Referral Bonus |
An award triggered by a referral. |
Adjustment |
A manual goodwill adjustment. |
Set Tier |
An explicit tier override. |
Migration Adjustment |
A backfill from a legacy system. |
The complete, per-tenant list is in the ActivityType enum (GET /api/v1/enums?type=ActivityType). You can add custom types in the admin UI; they show up here automatically.
Posting an accrual¶
This is the bread-and-butter integration call.
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "Accrual",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType": "POS",
"srcChannelID": "Store-12",
"loyaltyID": "abc123",
"value": 83.45,
"currencyCode": "USD",
"externalTxnId": "POS-2026-05-14-000182",
"lineItems": [
{ "lineNo": 1, "itemSKU": "SHIRT-RED-M", "itemPrice": "25.00", "quantity": 2, "itemAmount": 50.00, "itemUOM": "each" },
{ "lineNo": 2, "itemSKU": "SOCKS-WHITE", "itemPrice": "11.15", "quantity": 3, "itemAmount": 33.45, "itemUOM": "each" }
],
"tenderItems": [
{ "lineNo": 1, "type": "VISA", "value": "83.45" }
]
}'
Anatomy of the response¶
{
"data": {
"activityId": "6a053db7e58462caded04bc2",
"status": "Processed",
"purses": [
{ "name": "Rewards Points", "prev": 1240, "new": 1323, "prevAvail": 1240, "newAvail": 1323 },
{ "name": "Tier Credits", "prev": 4250, "new": 4333 }
],
"prevTier": "Sapphire",
"newTier": "Pearl"
},
"log": [
{ "action": "populateMember", "time": "...", "rule": "...", "ruleName": "Load Data" },
{ "action": "addPointsToPurse", "time": "...", "rule": "...", "ruleName": "Accrue Rewards", "params": ["Rewards Points", 83] },
{ "action": "checkTierUpgrade", "time": "...", "rule": "...", "ruleName": "Tier Manager" }
],
"errors": []
}
data¶
activityId— the persisted_id. Save it. You will need it to issue a cancellation or look the activity back up.status—Processed(everything ran cleanly) orError(one or more rules failed; seeerrors).purses[]— every purse the activity touched, withprevandnewtotals.prevAvail/newAvailexcludes escrow.prevTier/newTier— when a tier transition happened, this is your hook for downstream notification.- Custom fields — rules can write anything into
data. If your program has rules that publish, say,data.couponEarned, that field appears here.
log¶
An ordered trace of every action the engine performed. The action names map to the Rule Action Reference. When debugging "why didn't the customer get points", the log is the first place to look.
errors¶
An array of structured error objects:
{ "message": "Cannot find purse in member with _id ...", "status": 404, "code": 2040, "context": { "ruleName": "Base Earn" } }
When errors is non-empty, the HTTP response is non-2xx — typically 400, with the status copied from the first error. The body still includes the full data/log/errors envelope, so your client should parse the body regardless of HTTP status. data.status (Processed vs Error) is the overall verdict.
Cancelling an activity¶
Two patterns, depending on whether you need the cancellation to go through the rule pipeline.
Reverse one specific transaction¶
When you know the activity id and you want to reverse exactly that one (a return):
This creates a balancing activity of type Cancellation that points at the original via originalTxnId. The engine runs the standard reversal logic — no pre/post hooks beyond what the program's Cancellation flow already does.
Cancel through the activity endpoint (more control)¶
When you need rule logic to run before or after the cancellation — for example: validate that the cancellation is allowed, recompute a streak, fire a notification, write to ext, or chain a follow-on adjustment — post a Cancellation-type activity through /api/v1/activity. Pass the cancellation's own externalTxnId for idempotency, and identify the original transaction being cancelled by putting its external id in couponCode:
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "Cancellation",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType":"POS",
"srcChannelID": "Store-12",
"loyaltyID": "abc123",
"externalTxnId": "POS-2026-05-14-CANCEL-000037",
"couponCode": "POS-2026-05-12-000182",
"reasonCode": "CustomerReturn"
}'
The program's Cancellation flow reads couponCode to find the original transaction by its external id, runs any pre/post hooks you've wired in, and persists the reversal. This is the right form when the cancellation is a business event in its own right — not just an inverse of the original — and needs to go through the same rule pipeline as any other activity.
Idempotency and retries¶
RCX dedupes activities by externalTxnId. When a program has duplicate-transaction checking enabled (enrollSettings.checkDuplicateTxn on the program — on by default) and your activity carries an externalTxnId:
- The first POST runs normally. The activity is persisted with its
result(thedata/log/errorsobject). - A second POST with the same
externalTxnIdis recognized as a retry. RCX does not re-process it. Instead, it returns the stored result from the original run, sets theX-RCX-DUPLICATE: trueresponse header, and (when you asked for it withdupIndicator=true) flagsdata.duplicateTxn: truein the body.
That means a client that retries on network failure with the same externalTxnId will get the same response semantics on the retry as on the original — no double-accrual, no need to "check before re-sending". The integration pattern reduces to:
- Pick a unique
externalTxnIdper logical transaction (a UUID, or<channel>-<your-txn-id>). Persist it locally before you call RCX. - Send it on every POST. On a network error, just retry the same POST. RCX returns the prior result if it had already processed it.
- Look for
X-RCX-DUPLICATE: truein the response if you want to log retries distinctly from first-time posts.
Without externalTxnId, retries are not safe
If you omit externalTxnId and POST the same activity twice, RCX has no way to know the second is a retry. You will get a duplicate accrual. Make externalTxnId a required field in your client's outbound activity model.
Per-program toggle
Duplicate detection is governed by the program's enrollSettings.checkDuplicateTxn flag. If you are seeing duplicates accepted even with externalTxnId set, verify the flag is on for the program you're posting to.
Query parameters¶
The activity endpoint accepts several control parameters on the query string:
| Parameter | Default | Effect |
|---|---|---|
persist |
true |
When false, run the activity through the engine and return the result, but do not persist it. Useful for "what would this transaction do?" simulations. |
autoEnroll |
true |
When false, require the member to already exist; do not auto-enrol on first sighting of a loyalty ID. |
dupIndicator |
false |
When a duplicate is detected (by externalTxnId), flag it in the response body with data.duplicateTxn: true. The retry already returns the prior result regardless; this just makes it easy to spot in client logs. |
resolveMerge |
false |
When the activity targets a loyalty ID that has been merged away, follow the merge to the survivor and process it there. |
filter |
unset | Selects which sections of the response body to return. Comma-separated list of data, log, errors. Omitting it returns all three. Use this to shrink response payloads on high-volume calls — for example, ?filter=data,errors to drop the per-rule execution log when you don't need to inspect it. |
Examples:
# Dry run — don't persist
curl -sS -X POST "$BASE/api/v1/activity?persist=false" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"type":"Accrual","date":"...","srcChannelID":"Corporate","loyaltyID":"abc","value":10,"currencyCode":"USD"}'
# Skip the rule execution log to shrink the response
curl -sS -X POST "$BASE/api/v1/activity?filter=data,errors" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{...}'
Dry-run mode (persist=false)¶
The activity endpoint with ?persist=false is one of the most powerful tools in the API. It runs the activity through the rule engine — every rule fires, every action computes its outcome, every piece of data and every entry in the log is populated — but nothing is saved: no ActivityHistory record, no purse mutation, no wallet write, no event emission.
That gives you two distinct integration patterns:
Dry-run any transaction before committing it. Show the customer what their loyalty result would be before the underlying business transaction completes:
- A checkout preview: "If you place this order, you'll earn 240 points and unlock Pearl tier" — compute it by posting an
Accrualwith the proposed basket andpersist=false, then renderdata.pursesanddata.newTier. - A redemption preview: "Use this code and you'll save $15" — post a
Use Rewardstyle activity withpersist=false, then read the discount from the result. - A best-offer preview: post the prospective accrual and inspect
bestOffersindatawithout consuming anything.
The user confirms, you POST the same activity without persist=false, and the actual mutation happens.
Use the engine as a personalization decision service. Because persist=false is essentially "let me ask the rules a question and get a structured answer", you can build custom read endpoints by adding rule flows that compute the answer you need and writing it into data. From the integration's perspective, it's a single POST that returns a personalized payload, but server-side it's a full rule evaluation against the member's current state.
Patterns this enables:
- A "get personalized homepage offers" call: build a rule flow for an activity type like
Get Personalized Offers, write the chosen offer set intodata.recommendedOffers, and have your web/mobile client POST that activity withpersist=falsekeyed on the member's loyalty ID. - A market- or channel-specific decision: include
srcChannelType,srcChannelID, andextfields with the channel/market context; the rules branch on them and produce a tailored response. - An eligibility check: post an activity that runs your "can this member do X" rule chain and surfaces the answer in
dataorerrors.
Because the engine runs against live member state, the answer is always current — no stale cache, no precomputation pipeline to maintain. And because nothing is persisted, you can call it as often as you want without polluting ActivityHistory.
The trade-off is that every call still walks the rule engine, so latency is comparable to a real activity. For very-high-RPS personalization, cache the response client-side or in a CDN; for ordinary request rates, persist=false is a clean way to expose program logic as a read API without building a new endpoint for each use case.
Reading activity history¶
Activities are persisted in the ActivityHistory collection. You can query it like any other resource:
curl -sS --get "$BASE/api/v1/activityhistories" \
--data-urlencode 'query={"loyaltyID":"abc123","type":"Accrual"}' \
--data-urlencode 'sort={"date":-1}' \
--data-urlencode 'limit=20' \
-H "Authorization: Bearer $TOKEN"
See ActivityHistories in the API Reference for the full schema.
Line items, tender items, and extensions¶
Line items¶
{
"lineItems": [
{
"lineNo": 1,
"itemSKU": "SHIRT-RED-M",
"itemPrice": "25.00",
"quantity": 2,
"itemAmount": 50.00,
"itemUOM": "each",
"ext": { "category": "Apparel" }
}
]
}
Line items are matched against Product records by itemSKU. Rules can target specific products (or categories of products) for differential earn rates. If itemSKU isn't in your Product collection, the line item is processed but the rule engine won't be able to apply product-specific logic.
Tender items¶
{
"tenderItems": [
{ "lineNo": 1, "type": "VISA", "value": "75.00" },
{ "lineNo": 2, "type": "GIFT_CARD", "itemNo": "GC-09812", "value": "8.45" }
]
}
Used when your program differentiates accrual based on payment method (e.g. "no points on gift-card purchases").
Extension fields¶
If your tenant has defined extension fields for Activity (see ExtensionSchema), put them in ext:
{
"type": "Accrual",
"srcChannelID": "Corporate",
"loyaltyID": "abc123",
"value": 10,
"currencyCode": "USD",
"ext": {
"campaignCode": "SPRING2026",
"channelRef": "ad-group-7783"
}
}
Rules can read from act.ext.campaignCode to drive promotions.
Activity headers¶
Two headers commonly accompany activity calls:
| Header | Purpose |
|---|---|
Authorization: Bearer <token> |
Required on every call. |
Content-Type: application/json |
Required when posting JSON bodies (which is essentially always). |
If your tenant is configured to require audit headers (X-Audit-User, X-Audit-Reason), RCX will reject the request with a 400 until you supply them. Check with your RCX administrator.
Performance notes¶
- Latency. A typical accrual completes in 80–250 ms in production. The biggest variable is the program's rule depth.
- Throughput. The API is designed to handle thousands of activities per second per tenant. Submit them in parallel; do not serialise.
- Batch. The activity endpoint takes one activity per call. For large backfills, fan out POSTs from a parallel worker pool.
- Side effects. Activities produce events. If your downstream event consumer is slow, posting activities in a tight loop can build up event lag. Watch the event queue depth, not just the API response time.
Common mistakes¶
| Symptom | Cause | Fix |
|---|---|---|
2050 The date format is invalid |
date not in one of the accepted date formats. |
Use one of the accepted date formats, e.g. YYYY-MM-DDTHH:mm:ss.SSSZ. |
| Activity rejected with "location is not known" | srcChannelID doesn't match any Location.name. |
Pre-seed locations; do not let your POS make them up. |
| Member's balance doesn't move | Rules didn't fire (no flow matched the activity type), or fired but the program's enrollSettings.activityBasedExpFilter excludes this type. |
Check log for which flow ran; check enum/activity-type configuration. |
| Duplicate accruals | externalTxnId not set on the activity. |
Make every activity carry an externalTxnId. RCX will return the prior result on retry. |
Offers and rewards¶
Offers and rewards are how a program gives value to members. Both are wallet entries attached to a member, and both are redeemed by passing a code into the couponCode field on an activity. They live in two different wallets (/api/v1/offers and /api/v1/rewards); see Offer and Reward in the Concepts section for which to use when.
This section covers the API touch points around them: listing what a member has, granting new ones, redeeming them, and previewing all of the above without persisting.
Listing the catalogue¶
Every reward and offer a program can award lives in one catalogue at /api/v1/rewardpolicies. Each entry is a RewardPolicy — name, value, point cost, eligibility window, limits, the kind of wallet entry it produces when issued. This is the resource that drives a "store" UI showing what a member can buy with points, what's available to earn at each tier, and what offers a program is currently marketing.
curl -sS --get "$BASE/api/v1/rewardpolicies" \
--data-urlencode 'query={"effectiveDate":{"$lte":"2026-05-14"},"expirationDate":{"$gte":"2026-05-14"}}' \
--data-urlencode 'limit=100' \
-H "Authorization: Bearer $TOKEN"
Listing eligible reward policies for a member¶
When you want the catalogue filtered to what a specific member qualifies for right now, use /api/v1/members/{memberId}/offers. Despite the name, this endpoint returns reward policies (catalogue entries), not wallet items — it walks the catalogue, applies the program's eligibility rules against the given member, and returns the subset they currently qualify for.
[
{
"name": "Hotel $350 Platinum Credit #0494",
"desc": "Auto-generated stress test reward 494",
"isAppeasement": false,
"isNonRefundable": false,
"expirationHours": 0,
"numUses": 1,
"availableRedemptions": 1,
"effectiveDate": "2025-01-01T00:00:00.000Z",
"expirationDate": "2027-12-31T23:59:59.999Z",
"upc": "STRESS-0494",
"priority": 0,
"intendedUse": "Global Offer"
}
]
You can layer additional filters on with the standard query parameter:
curl -sS --get "$BASE/api/v1/members/$MEMBER_ID/offers" \
--data-urlencode 'query={"availableRedemptions":{"$gt":0}}' \
-H "Authorization: Bearer $TOKEN"
This is the right call for a member-facing display ("here's what's available to you"). For an unfiltered admin or merchandising UI, query /api/v1/rewardpolicies directly as in the previous subsection.
Looking up what a member already has issued¶
The two wallet routes return instances that have actually been issued to a member:
# Rewards wallet
curl -sS --get "$BASE/api/v1/rewards" \
--data-urlencode 'query={"memberId":"6967e..."}' \
-H "Authorization: Bearer $TOKEN"
# Offers wallet
curl -sS --get "$BASE/api/v1/offers" \
--data-urlencode 'query={"memberId":"6967e..."}' \
-H "Authorization: Bearer $TOKEN"
Filter by the effective/expiration window for the "what can the member redeem right now" view. Wallet entries carry a value, an expiration, and a reference back to the RewardPolicy that created them.
Granting rewards and offers¶
Rewards and offers are always granted by a rule action firing during activity processing. From integration code, choose the activity that triggers the right action.
The most common patterns:
Buying a reward with points¶
The member spends points to acquire a reward. Post a Redemption activity with the name of the reward policy in couponCode:
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"type": "Redemption",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType":"Web",
"srcChannelID": "Corporate",
"loyaltyID": "abc123",
"couponCode": "Free Night Certificate"
}'
A program rule wired with Give Reward by Name (or Give Reward by UPC, or Give Specific Reward) looks up the policy named "Free Night Certificate", checks the member has enough points, debits the points purse, and writes a new entry to the Rewards wallet with a freshly issued unique code.
This is also the pattern for points-purchased offers — the redemption activity fires Give Offer by Lookup instead, which writes to the Offers wallet.
Granting an offer without points¶
Bounce-back coupons, periodic offer drops, appeasement, segment-based offer pushes — these are non-earned grants. They're issued by rules wired with Give Offer actions during activity processing. The triggering activity depends on the program design:
- Periodic batch — an internal job iterates eligible members and posts an activity type that fires a "give offer" rule.
- Real-time on accrual — an
Accrualrule includes a "give offer if X" branch. - Customer-service appeasement — an
Adjustmentactivity wired to fire Give Specific Reward or Give Offer by Lookup.
In every case, your integration code's job is to post the right activity. The wallet write is the rule engine's job.
Redeeming an offer or reward¶
Whether the entry is in the Offers wallet or the Rewards wallet, the redemption pattern is the same: pass the entry's unique code in couponCode on any activity the rules know how to evaluate. A rule wired with Use Offer by Code or Use Reward by Code recognises the code, marks the wallet entry as used, and applies its side effects (discounting points, awarding a bonus, etc).
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"type": "Accrual",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType":"POS",
"srcChannelID": "Store-12",
"loyaltyID": "abc123",
"value": 83.45,
"currencyCode": "USD",
"couponCode": "WELCOME10",
"lineItems": [ ... ]
}'
The use of an offer or reward is rarely a standalone activity — it's almost always layered onto the activity that already represents the customer action (the purchase, the redemption, the check-in). Just include couponCode on that activity.
The response tells you, via the data section and the rule log, whether the code was found, whether it was usable, and what side effects fired.
Reward usage history¶
Every redemption is captured for audit and analytics:
curl -sS --get "$BASE/api/v1/rewardusagehistory" \
--data-urlencode 'query={"memberId":"6967e..."}' \
--data-urlencode 'sort={"createdAt":-1}' \
--data-urlencode 'limit=20' \
-H "Authorization: Bearer $TOKEN"
Use this to render "your reward activity" in a member account UI.
Offer usage history¶
Mirror of the above, for offers:
curl -sS --get "$BASE/api/v1/offerusagehistory" \
--data-urlencode 'query={"memberId":"6967e..."}' \
-H "Authorization: Bearer $TOKEN"
Promo codes¶
Promo codes are a way to load a bank of codes — typically supplied by a partner — into RCX and have the engine auto-assign them to members for redemption at that partner. They are most often single-use: each code in the bank can be claimed by exactly one member and then redeemed once.
Typical lifecycle:
- Load. A bank of codes is loaded into RCX (from the partner's CSV, or via direct write to
/api/v1/promocodes). RCX can also generate the codes itself — random strings drawn from a predetermined alphabet at a configured length. See the Program Administration Guide for setup of both the upload and the in-engine generator. - Distribute. Codes can be handed out in any channel — emails, push, direct mail, in-app banners, partner sites. Whatever the customer ends up holding, it's a code that RCX recognises.
- Assign. When a member presents the code (typically by including it on an activity as
couponCode), a rule wired with Assign Promo Code (or Assign and Redeem Promo Code) claims the code from the bank and binds it to that member. - Redeem. A subsequent activity (or the same one, if the rule is
Assign and Redeem) consumes the code. After redemption, the code is marked used and cannot be claimed again.
From the integration's point of view, posting an activity that carries the code in couponCode is generally all that's needed — the rule engine handles the bank lookup, the assignment, and the redemption.
curl -sS -X POST "$BASE/api/v1/activity" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "Register",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelID": "Corporate",
"loyaltyID": "abc123",
"couponCode": "SPRING2026-A1B2C3"
}'
For inspecting the bank itself (which codes are loaded, which are claimed, which are still available), query /api/v1/promocodes:
curl -sS --get "$BASE/api/v1/promocodes" \
--data-urlencode 'query={"code":"SPRING2026-A1B2C3"}' \
-H "Authorization: Bearer $TOKEN"
See the Program Administration Guide for setting up promo code banks, generators, and the engine actions that assign and redeem them.
Putting it together: a member portal "What's available to me" view¶
A common page in a member-facing portal needs four pieces of data:
- Identity and balances →
GET /api/v1/members/:lid/profile - Catalogue items the member qualifies for →
GET /api/v1/members/:id/offers(returns reward policies, not wallet items) - Issued offers →
GET /api/v1/offers?query={"memberId":"..."} - Issued rewards →
GET /api/v1/rewards?query={"memberId":"...","expirationDate":{"$gte":"<today>"}} - Recent activity →
GET /api/v1/activityhistories?query={"memberID":"..."}&sort={"date":-1}&limit=20
Make those calls in parallel — they don't depend on each other once you have the loyalty ID — and you have everything a typical "my loyalty" page needs in a single round-trip-batched render.
Or: consolidate into one Personalization activity¶
If you'd rather fetch the whole bundle in a single round-trip — and have the rule engine decide exactly what each member should see — define a Personalization activity type (e.g. Get Member Dashboard) and post it with persist=false. The rules read the member's state, assemble the offer set, the available rewards, the recent activity slice, and any channel- or market-specific tailoring, and write the result into data:
curl -sS -X POST "$BASE/api/v1/activity?persist=false" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "Get Member Dashboard",
"date": "2026-05-14T03:12:55.000Z",
"srcChannelType":"Web",
"srcChannelID": "Corporate",
"loyaltyID": "abc123",
"ext": { "market": "US", "locale": "en-US", "include": ["offers","rewards","recent"] }
}'
persist=false makes the POST effectively a read operation that consults the engine — nothing is saved, no events fire, and you can call it as often as you want. The rule flow can also branch on the ext query params (market, locale, requested sections, surface — homepage vs checkout vs app) to return a tailored payload per channel without you having to maintain a parallel set of REST endpoints.
Querying the API¶
Almost every resource in RCX is exposed by a generic, consistent CRUD layer that accepts a standard set of query-string parameters. They let you shape requests precisely — picking only the entities you want, projecting only the fields you need, joining in related records, sorting, and paginating — and they work the same way on every collection endpoint (/api/v1/members, /api/v1/offers, /api/v1/loyaltyids, /api/v1/locations, and so on).
Mastering them saves you round-trips, keeps your payloads small, and — importantly — insulates your client from later schema changes such as new ext fields being added.
query¶
JSON-encoded match expression. Supports the usual comparison and set operators — $gt, $gte, $lt, $lte, $in, $nin, $ne, $exists, $regex, $and, $or — and dotted field paths for nested fields ("tiers.level.name": "Pearl"). The value goes on the URL as a URL-encoded JSON object.
# Members enrolled in the last 30 days
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'query={"enrollDate":{"$gte":"2026-04-14T00:00:00.000Z"}}' \
-H "Authorization: Bearer $TOKEN"
# Loyalty IDs that look like POC test cards
curl -sS --get "$BASE/api/v1/loyaltyids" \
--data-urlencode 'query={"loyaltyId":{"$regex":"^test-","$options":"i"}}' \
-H "Authorization: Bearer $TOKEN"
URL-encode the JSON
The query value contains {, }, ", :, spaces — none of which can ride a URL unescaped. curl --data-urlencode does this for you; if you build the URL yourself, run the JSON through encodeURIComponent (JS) / urllib.parse.quote (Python) / equivalent.
select¶
Projects which fields come back. Space-separated string, with -fieldName to exclude a specific field. Use it to shrink large documents and to insulate your client from schema growth:
# Only the fields we need
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'query={"_id":"6967..."}' \
--data-urlencode 'select=firstName lastName email tiers.level.name purses.name purses.balance' \
-H "Authorization: Bearer $TOKEN"
# Everything except large arrays
curl -sS --get "$BASE/api/v1/members?select=-events -activities" \
-H "Authorization: Bearer $TOKEN"
Always select what you actually need
Selecting explicit fields means your client won't silently start ingesting new ext fields, large embedded arrays, or future schema additions that you didn't design for. It's the single highest-leverage piece of hygiene in any RCX integration.
populate¶
Joins in referenced documents. By default, fields like member.program come back as an ObjectId. With populate, RCX replaces it with the linked document (or a projected subset of it).
# Member with program details inlined
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'query={"_id":"6967..."}' \
--data-urlencode 'populate=program' \
-H "Authorization: Bearer $TOKEN"
# Population with field selection on the joined document
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'populate=[{"path":"program","select":"name desc"}]' \
-H "Authorization: Bearer $TOKEN"
Combine populate with select to project only what you need from both sides of the join.
Dynamic lookup — collapse two calls into one¶
Many endpoints (notably activity processing and the CRUD POST/PATCH endpoints under preprocess=true) accept a lookup expression in place of an ObjectId. Instead of "give me the member id then post the activity", you describe how to find the id in the payload itself and RCX resolves it during processing.
The classic case: setting a member preference, where you know the member by loyaltyId but the MemberPreference.memberId field expects a member _id. Without lookup that's two calls; with lookup, one:
curl -sS -X POST "$BASE/api/v1/memberpreferences?preprocess=true" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"memberId": {
"lookup": {
"model": "LoyaltyID",
"keys": { "loyaltyId": "4400678932223002" },
"select": ["memberId"]
}
},
"name": "Email",
"value": true,
"category": "Marketing",
"optedInDate": "2026-05-14T00:00:00.000Z",
"expirationDate": "3000-01-01T00:00:00.000Z"
}'
The lookup block tells RCX: "find the LoyaltyID with this loyaltyId, take its memberId field, substitute that into memberId here". The whole operation completes in one round-trip.
The same pattern works in any field that takes an ObjectId — program, program references inside an offer query, locationId, etc. — and it's particularly nice for bulk loaders that don't want to pre-fetch IDs.
Pagination — skip and limit¶
# Second page of 100 results
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'query={"status":"Active"}' \
--data-urlencode 'limit=100' \
--data-urlencode 'skip=100' \
-H "Authorization: Bearer $TOKEN"
Two defaults to know:
- Always send
limitexplicitly. There is no default page size — withoutlimitthe server returns up to the hard cap below in a single response. - Hard cap is 500. Most endpoints refuse to return more than 500 records in a single response, regardless of how high you set
limit. To walk a larger result set, paginate withskip/limitin a loop.
A canonical paginated walk:
PAGE = 500
skip = 0
while True:
page = rcx._call('GET', '/api/v1/members',
params={'query': json.dumps({'status': 'Active'}),
'limit': PAGE,
'skip': skip,
'sort': json.dumps({'_id': 1})})
if not page:
break
for m in page:
process(m)
if len(page) < PAGE:
break
skip += PAGE
Sort by _id (or any monotonic field) for stable pagination — without it, an insert during the walk can shift records across page boundaries.
sort¶
JSON-encoded object mapping field name → 1 (ascending) or -1 (descending). Multi-field sort is supported by listing keys in order.
# Most recent activities first
curl -sS --get "$BASE/api/v1/activityhistories" \
--data-urlencode 'query={"loyaltyID":"abc123"}' \
--data-urlencode 'sort={"date":-1}' \
--data-urlencode 'limit=20' \
-H "Authorization: Bearer $TOKEN"
# Sort by tier descending, then last name ascending
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'sort={"tiers.level.number":-1,"lastName":1}' \
--data-urlencode 'limit=100' \
-H "Authorization: Bearer $TOKEN"
For stable pagination, always include a tiebreaker (typically _id):
Putting it together¶
A typical "render this dashboard" call composes all five:
curl -sS --get "$BASE/api/v1/members" \
--data-urlencode 'query={"status":"Active","tiers.level.name":"Pearl"}' \
--data-urlencode 'select=firstName lastName email tiers.level.name purses.name purses.balance' \
--data-urlencode 'populate=[{"path":"program","select":"name"}]' \
--data-urlencode 'sort={"lastName":1,"_id":1}' \
--data-urlencode 'limit=100' \
--data-urlencode 'skip=0' \
-H "Authorization: Bearer $TOKEN"
Every collection endpoint follows the same conventions; once you've internalised them on one resource, every other resource works the same way.
Extensions¶
Every major model in RCX — Member, Activity, Offer, Reward, Location, Program, Rule, Product, and most others — supports extensions: program-defined custom fields that live alongside the standard schema.
Extensions are authored declaratively, not coded:
- An administrator defines an
ExtensionSchemafor a given model in the Admin Console. The schema is a JSON Schema (Draft-04) document that declares the field names, types, and validation rules. - Once saved, those fields become readable and writable on the corresponding model under the
extkey. Standard validation runs on every write; reads return theextblock untouched.
So if your program defines this schema extension on Member:
{
"type": "object",
"properties": {
"preferredLocale": { "type": "string", "enum": ["en-US","ja-JP","fr-CA"] },
"favoriteProperty": { "type": "string" },
"vipFlag": { "type": "boolean" }
}
}
…then writes go in under ext:
curl -sS -X PATCH "$BASE/api/v1/crmprofiles/$MEMBER_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "ext": { "preferredLocale": "ja-JP", "favoriteProperty": "Bellagio" } }'
…and reads come out the same way:
{
"_id": "...",
"firstName": "Jane",
"ext": { "preferredLocale": "ja-JP", "favoriteProperty": "Bellagio", "vipFlag": false }
}
The same ext block also appears in:
- Activity payloads you POST (
act.ext.campaignCode, etc.). Rules can read fromact.ext.*to drive promotions and conditional logic. - Line items and tender items on activities (
lineItems[].ext,tenderItems[].ext). - Event payloads sent to webhooks — Level 1 and Level 2 events carry the
extblock of the affected entity verbatim.
What this means for integrations¶
- No engineering work is required to add a field. A program admin defines the extension; your integration can immediately read and write it via
ext. - Tolerate unknown fields. A future schema change adds
ext.somethingNew; your client should ignore it cleanly. Useselectto project only theextfields you care about if you want to be defensive. - Validate on the client where it matters. RCX enforces the JSON Schema on writes, but your UI is usually better placed to give the user a friendly error before the request ever leaves.
- Search by extensions. Extension fields are queryable like any other field —
query={"ext.vipFlag":true}works.
For the underlying schema authoring workflow, see Extensions in the User Interface Guide and the extensionschemas endpoint in the API Reference.
Events and webhooks¶
So far this guide has covered the request side of the integration — you call RCX, RCX responds. The other half is the event side: RCX tells your downstream systems that something happened. This is how you keep a search index, an ESP, a push-notification service, or a live analytics dashboard in step with RCX.
For nightly or periodic bulk loads into a data warehouse, use the RCX data-warehouse sync, not the event stream — the event stream is for near-real-time consumers, not batch warehousing.
There are two levels of events. Pick the one that fits each downstream system; you typically subscribe to both.
Level 1: replication events¶
Per-entity insert, update, and delete. Idempotent, low-level. Best for near-real-time consumers that need a faithful, up-to-the-second mirror of RCX state. Typical targets:
- Search indexes (Elasticsearch, Algolia) — keep the member/offer/reward index in step with RCX as records change.
- ESP and push-notification vendors — push attribute updates (tier, balance, status, marketing preferences,
extfields) to Adobe, Salesforce Marketing Cloud, Braze, etc., so the next campaign send has current values to filter and personalize on. - Aggregates that combine RCX with other sources — typically used to compile member-facing summary and detail views of activity history (points earned, points spent, recent transactions, redemptions) by joining RCX events with order data, content metadata, or partner activity from other systems.
- Real-time analytics dashboards — power dashboards that need fresh state (member counts by tier, points in flight, enrolments per hour) without waiting for a batch.
Level 1 is not the right channel for periodic warehouse loads. For Snowflake / BigQuery / Redshift bulk sync, use the RCX data-warehouse sync.
Three event names:
| Event | Emitted when |
|---|---|
ObjectInsertEvent |
A new document is created in any tracked collection. |
ObjectUpdateEvent |
An existing document is mutated. |
ObjectDeleteEvent |
A document is deleted. |
Structure¶
Every event has a header and a payload:
{
"header": {
"id": "6a053db7e58462caded04bc2",
"name": "ObjectUpdateEvent",
"status": "processed",
"timestamp": 1715655165000,
"userId": "69670030577c429a3dae1bd8",
"entityType": "members",
"affectedObjId": "69d88d30a7ef554b121cf584"
},
"payload": {
"_id": "69d88d30a7ef554b121cf584",
"firstName": "Jane",
"lastName": "Smith",
"purses": [ ... ],
"tiers": [ ... ],
"updatedAt": "2026-05-14T03:13:43.387Z"
}
}
Important properties:
- Idempotency. Each event can be applied multiple times safely.
ObjectUpdateEventoverwrites the whole document at the destination;ObjectInsertEventis idempotent if your sink has a unique constraint on_id;ObjectDeleteEventis idempotent because deleting an already-deleted row is a no-op. - Strict ordering per
affectedObjId. You must apply events intimestamporder for the same target document. Different objects can be processed in parallel. - At-least-once delivery. You may occasionally see duplicate deliveries. Use
header.idas a dedupe key.
What's tracked¶
Every entity exposed via the CRUD API emits Level 1 events. The header.entityType matches the URL slug: members, locations, loyaltyIds, rewards, offers, programs, tierpolicies, and so on.
Level 2: semantic events¶
Higher-level "what happened to this member" signals, designed to drive communications.
| Event | Emitted when |
|---|---|
EnrollmentEvent |
A member's status transitions to Active. |
AddPointsEvent |
A purse's balance increases. The payload carries prevBalance and newBalance so you can communicate the delta. |
RedeemPointsEvent |
A purse's balance decreases. |
SetTierEvent |
A tier transition. Payload carries prevTier and newTier. |
GiveRewardEvent |
A reward is granted. |
UseRewardEvent |
A reward is consumed. |
ExpirePointsEvent |
Points expired off a purse. |
OfferEvent |
An offer's lifecycle changed (granted, locked, expired, etc). |
Level 2 events overlap with Level 1 — a points addition produces both an ObjectUpdateEvent for the member and an AddPointsEvent — but Level 2 is non-idempotent (you cannot replay it without re-sending the same notification). The trade-off is that Level 2 carries the pre/post comparison inside the event, which Level 1 deliberately omits to preserve idempotency.
When to use each¶
| Downstream system | Level |
|---|---|
| Email/SMS triggered sends (Adobe, Salesforce Marketing Cloud, Braze) | Level 2 |
| Push notification triggers | Level 2 |
| ESP/push attribute sync (keep contact records current) | Level 1 |
| Search index (Elasticsearch, Algolia) | Level 1 |
| Member activity-history summary/detail views (joining RCX with other sources) | Level 1 |
| Real-time analytics dashboards | Level 1 |
| Real-time personalization engine | Both |
| Nightly/periodic warehouse load (Snowflake, BigQuery, Redshift) | Use the RCX data-warehouse sync, not the event stream |
Webhook delivery¶
Events are delivered to a webhook URL configured on your tenant by your RCX administrator. Once configured, every event is POSTed to your endpoint with a body of { "header": ..., "payload": ... }.
Your webhook receiver should:
- Respond 2xx within a few seconds. Slow webhooks back up the event queue and risk being retried.
- Dedupe on
header.id. Persist seen ids in a TTL store; ignore replays. - Order by
header.timestampbefore applying to per-affectedObjIdstate. - Tolerate unknown fields. Future RCX releases add fields; ignore the ones you don't understand.
- Secure the endpoint at the network layer. RCX does not sign event deliveries — use a path that's hard to guess, restrict the source IP range to the RCX tenant's egress, or front the endpoint with mTLS or an API gateway that validates a shared secret on a header you control.
A reference webhook receiver in Node.js (express):
import express from 'express';
const app = express();
app.use(express.json());
app.post('/rcx/webhook', (req, res) => {
const { header, payload } = req.body;
// Dedupe on header.id, route by header.name, apply.
console.log(`${header.name} for ${header.entityType}/${header.affectedObjId}`);
res.status(200).end();
});
app.listen(8080);
Extension fields in event payloads¶
When you have configured ExtensionSchema for an entity, the ext object is included in the event payload exactly the same way it appears in API responses:
{
"header": { "entityType": "members", "name": "ObjectUpdateEvent", ... },
"payload": {
"_id": "...",
"firstName": "Jane",
"ext": { "preferredLocale": "ja-JP", "favoriteProperty": "Bellagio" }
}
}
Your downstream consumer can use those fields without further configuration.
Event API reference¶
For the full per-event schema, see Event API, Level 1 Events, and Level 2 Events in the API reference.
Error handling¶
Every error response from the API follows the same envelope:
{
"message": "Human-readable description",
"status": 400,
"code": 4006,
"context": { /* optional, error-specific */ }
}
| Field | Meaning |
|---|---|
message |
A localized, human-readable description. Suitable for logging, not for parsing. |
status |
The HTTP status; this matches the response code. |
code |
A numeric, stable RCX-internal error code. This is the field to branch on. |
context |
Optional. Carries the failing element, rule name, field name, or whatever the specific error needed to convey. |
code is the stable contract. The message text is localized and may change between releases; code does not.
HTTP status code conventions¶
| HTTP | When you see it |
|---|---|
| 200 | Success. The request was accepted, processed without errors, and any side effects (rule actions, points changes) were applied. |
| 400 | Validation failure (bad payload) or the rule engine returned an error while processing an activity. Inspect the response body — errors[] will tell you which rule failed and why. |
| 401 | Token missing, invalid, or expired. Log in again. |
| 403 | Authenticated but lacks permission for this resource/action. |
| 404 | The thing you asked for doesn't exist. |
| 409 | A conflict — duplicate key, member already enrolled, etc. |
| 500 | Server-side error. Retry with backoff; if persistent, escalate. |
Codes you will see most often¶
| Code | HTTP | Meaning | Typical fix |
|---|---|---|---|
| 1005 | 401 | Token expired. | Log in again to get a fresh token, then retry. |
| 1010 | 401 | Token missing or invalid on a protected route. | Log in again. |
| 1110 | 401 | Login credentials incorrect. | Verify username (including org/ prefix) and password. |
| 2050 | 400 | Activity date format invalid. | Use one of the accepted date formats. |
| 2063 | 400 | Duplicate activity — same externalTxnId already processed. |
Not an error in retry scenarios — RCX returns the prior result. See Idempotency. |
| 4001 | 400 | Invalid API parameters. | Check that all required parameters are present and shaped correctly. |
| 4006 | 400 | Invalid program ID. | Verify the program value is a 24-char hex ObjectId and exists in your org. |
| 9999 | 500 | Unexpected server error. | Retry once with backoff; if it persists, contact RCX support with the request body and timestamp. |
Codes from 5000 upwards are reserved for program-specific custom rules — they are defined by the program you are posting against, not by RCX core. What a given 5xxx code means therefore varies from one tenant to another. See the full Error Code reference for the system codes; check your program's custom error codes with your RCX administrator.
Activity responses still carry full detail on errors¶
When a rule fails during activity processing, the HTTP response is non-2xx (typically 400) — the status code of the first error is what gets returned. But the response body still includes the full data/log/errors envelope so you can inspect what happened:
{
"data": { "status": "Error", "activityId": "...", "purses": [...] },
"log": [ ... ],
"errors": [ { "code": 2040, "message": "Cannot find purse in member with _id ...", "status": 404, "context": { "ruleName": "Base Earn" } } ]
}
So for activities, don't drop the body on non-2xx — that's where the engine's diagnostics live. A robust client treats activity responses as: parse the body regardless of status code, then branch on data.status and errors[].
Retrying¶
A simple, safe retry policy:
| Class | Retry? | Strategy |
|---|---|---|
| 401 with code 1005 (token expired) or 1010 (token missing/invalid) | Yes, after re-auth | Log in again, then retry once. |
| 429 (rate limited, if your tenant has limits) | Yes | Honour Retry-After; exponential backoff. |
| 5xx | Yes | Capped exponential backoff, 3–5 attempts. For activities, always include externalTxnId — RCX will return the prior result on retry. |
| 4xx other than 401/429 | No. | Surface the error; do not retry — your request is wrong, the next attempt won't be different. |
For activities specifically, the safest pattern is to always include externalTxnId and just retry the same POST on a network error. RCX detects the duplicate by externalTxnId and returns the original result. See Idempotency.
Logging what you got back¶
Two minimum fields to log for every failed call:
- The HTTP status and the
code. - The
contextobject (or its absence).
This is enough to triage most integration issues without needing the raw response body, which may contain customer data you don't want in logs.
Programmatic error handling in Node.js¶
For the activity endpoint, a non-2xx response still carries the engine's diagnostic envelope (data/log/errors), so the client should parse the body either way:
async function callRcx(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Standard envelope: { message, status, code, context? }
// For /activity, the body also contains data/log/errors — expose them.
const err = new Error(data.message || `HTTP ${res.status}`);
err.status = res.status;
err.code = data.code;
err.context = data.context;
err.body = data;
throw err;
}
return data;
}
try {
const result = await callRcx('POST', '/api/v1/activity', accrual);
console.log('Activity posted:', result.data.activityId);
} catch (err) {
if (err.code === 1010) {
await login(); /* retry once */
} else if (err.body?.errors?.length) {
// Rule engine error — body has the per-rule detail
console.warn('Activity rejected by rules:', err.body.errors);
} else {
throw err;
}
}
When to escalate to RCX support¶
A short triage list before opening a ticket:
- Reproduce the call with
curlso you have a self-contained reproduction. - Capture the full response, including
message,code, andcontext. - Note the
versionandbuildfrom your most recent/loginresponse — that pins the API release. - If you have access to the audit log, capture the request ID. Otherwise, RCX support can correlate by timestamp and member id.
5xx responses, unexpected 9999s, and codes that don't appear in the Error Codes reference are the strongest signals that a ticket is warranted.
Recipes¶
Copy-paste starting points for the integration patterns most integrations need. Each recipe is self-contained and ends at the point where you have the data you needed — you decide what to do with it.
Node.js: RCX client¶
A small client that:
- caches the token,
- re-authenticates automatically on
401, - exposes typed-ish helpers for the most common calls.
// rcx-client.mjs
const BASE = process.env.RCX_BASE || 'https://your-tenant.reactorcx.com';
const USERNAME = process.env.RCX_USERNAME || 'demo/admin';
const PASSWORD = process.env.RCX_PASSWORD || 'asj4hjshd3js2&';
class RcxClient {
constructor({ base = BASE, username = USERNAME, password = PASSWORD } = {}) {
this.base = base.replace(/\/$/, '');
this.username = username;
this.password = password;
this.token = null;
}
async login() {
const r = await fetch(`${this.base}/api/v1/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.username, password: this.password })
});
if (!r.ok) throw new Error(`login failed: ${r.status}`);
const j = await r.json();
this.token = j.token;
return j;
}
async _call(method, path, body, isRetry = false) {
if (!this.token) await this.login();
const r = await fetch(`${this.base}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (r.status === 401 && !isRetry) {
this.token = null;
return this._call(method, path, body, true);
}
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const err = new Error(j.message || `HTTP ${r.status}`);
err.status = r.status;
err.code = j.code;
err.context = j.context;
throw err;
}
return j;
}
// ---------- convenience helpers ----------
getMember(loyaltyId) { return this._call('GET', `/api/v1/members/${encodeURIComponent(loyaltyId)}/profile`); }
getMemberOffers(memberId) { return this._call('GET', `/api/v1/members/${memberId}/offers`); }
enrollMember(body) { return this._call('POST', `/api/v1/members/enroll`, body); }
postActivity(activity) { return this._call('POST', `/api/v1/activity`, activity); }
cancelActivity(activityId) { return this._call('POST', `/api/v1/activity/${activityId}/cancel`); }
}
export default RcxClient;
Use it:
import RcxClient from './rcx-client.mjs';
const rcx = new RcxClient();
const { member } = await rcx.getMember('test-1775799600099');
console.log(`Tier: ${member.tiers.find(t => t.primary).level.name}`);
const result = await rcx.postActivity({
type: 'Accrual',
date: new Date().toISOString(),
srcChannelType: 'Web',
srcChannelID: 'Corporate',
loyaltyID: 'test-1775799600099',
value: 83.45,
currencyCode: 'USD',
externalTxnId: `WEB-${Date.now()}`,
lineItems: [
{ lineNo: 1, itemSKU: 'DEMO-SKU', itemPrice: '83.45', quantity: 1, itemAmount: 83.45, itemUOM: 'each' }
]
});
if (result.data.status === 'Error') {
console.error('Engine errors:', result.errors);
} else {
console.log('Activity posted:', result.data.activityId);
}
Python: minimal client¶
# rcx_client.py
import os, time, json, requests
BASE = os.environ.get('RCX_BASE', 'https://your-tenant.reactorcx.com').rstrip('/')
USERNAME = os.environ.get('RCX_USERNAME', 'demo/admin')
PASSWORD = os.environ.get('RCX_PASSWORD', 'asj4hjshd3js2&')
class RcxClient:
def __init__(self, base=BASE, username=USERNAME, password=PASSWORD):
self.base = base
self.username = username
self.password = password
self.token = None
self.session = requests.Session()
def login(self):
r = self.session.post(f'{self.base}/api/v1/login',
json={'username': self.username, 'password': self.password})
r.raise_for_status()
self.token = r.json()['token']
def _call(self, method, path, json_body=None, params=None, _retry=False):
if not self.token:
self.login()
r = self.session.request(
method, f'{self.base}{path}',
headers={'Authorization': f'Bearer {self.token}'},
json=json_body, params=params
)
if r.status_code == 401 and not _retry:
self.token = None
return self._call(method, path, json_body, params, _retry=True)
if not r.ok:
try:
body = r.json()
except ValueError:
body = {}
raise RcxError(r.status_code, body.get('code'), body.get('message') or r.text, body.get('context'))
return r.json()
def get_member(self, loyalty_id):
return self._call('GET', f'/api/v1/members/{loyalty_id}/profile')
def post_activity(self, activity):
return self._call('POST', '/api/v1/activity', json_body=activity)
class RcxError(Exception):
def __init__(self, status, code, message, context):
super().__init__(f'[{status}/{code}] {message}')
self.status = status
self.code = code
self.context = context
Use it:
from rcx_client import RcxClient, RcxError
rcx = RcxClient()
m = rcx.get_member('test-1775799600099')
print('Member:', m['member']['firstName'], m['member']['lastName'])
try:
res = rcx.post_activity({
'type': 'Accrual',
'date': '2026-05-14T03:12:55.000Z',
'srcChannelType': 'Web',
'srcChannelID': 'Corporate',
'loyaltyID': 'test-1775799600099',
'value': 10,
'currencyCode': 'USD',
'externalTxnId': 'PY-DEMO-001',
'lineItems': [{ 'lineNo': 1, 'itemSKU': 'DEMO', 'itemPrice': '10', 'quantity': 1, 'itemAmount': 10 }]
})
print('activityId =', res['data']['activityId'])
except RcxError as e:
print('Failed:', e.status, e.code, e)
Recipe: enrol a brand-new customer at sign-up¶
A web form posts the customer's email, name, and a generated card number. Your backend turns it into an enrolment in RCX.
const lid = await generateLoyaltyId(); // your own generator
const { member } = await rcx.enrollMember({
program: PROGRAM_ID,
member: {
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
enrollChannel: 'Web',
acquisitionChannel: 'Web',
address: form.address1,
city: form.city,
state: form.state,
zipCode: form.zip,
country: form.country
},
loyaltyIds: [{ loyaltyId: lid, name: 'WebCard', primary: true }],
preferences: [
{ name: 'Email', value: form.emailOptIn, category: 'Marketing' }
]
});
return { rcxId: member._id, loyaltyId: lid };
Persist the returned rcxId and loyaltyId against your local customer record so the next activity post can identify them.
Recipe: process a POS sale¶
A point-of-sale completed a sale. You need to forward it to RCX.
sale = {
'externalTxnId': pos.transaction_id,
'loyaltyID': pos.customer_card,
'srcChannelType':'POS',
'srcChannelID': pos.store_code,
'date': pos.completed_at_iso,
'value': pos.total,
'currencyCode': pos.currency,
'type': 'Accrual',
'lineItems': [
{ 'lineNo': i + 1,
'itemSKU': li.sku,
'itemPrice': str(li.unit_price),
'quantity': li.qty,
'itemAmount': li.line_total,
'itemUOM': 'each' }
for i, li in enumerate(pos.line_items)
],
'tenderItems': [
{ 'lineNo': i + 1, 'type': t.method, 'value': str(t.amount) }
for i, t in enumerate(pos.tenders)
]
}
# Persist locally first, so on a crash we know to retry
sale_record = local_db.save_pending(sale)
try:
result = rcx.post_activity(sale)
local_db.mark_processed(sale_record.id, result['data']['activityId'])
except RcxError as e:
local_db.mark_error(sale_record.id, e.code, str(e))
Recipe: nightly reconciliation¶
For activities you suspect didn't land (you logged a timeout, a 5xx, or a worker died mid-batch), just re-POST them. As long as each activity carries its externalTxnId, RCX dedupes automatically — the ones that already processed return their prior result, and the ones that genuinely didn't land run normally.
yesterday = '2026-05-13'
for sale in local_db.activities_for_status(yesterday, status='unknown'):
result = rcx.post_activity(sale)
# RCX sets X-RCX-DUPLICATE: true on retries that hit existing records
local_db.mark_processed(sale.id, result['data']['activityId'])
There is no need to GET activityhistories first to see what's already there — that's what externalTxnId-based dedup is for.
Recipe: member portal page¶
Single page, all the data a member sees about their account.
async function memberPortal(loyaltyId) {
const profileP = rcx.getMember(loyaltyId);
const profile = await profileP;
const memberId = profile.member._id;
const [offers, rewards, history] = await Promise.all([
rcx._call('GET', `/api/v1/members/${memberId}/offers`),
rcx._call('GET', `/api/v1/rewards?query=${encodeURIComponent(JSON.stringify({memberId, expirationDate: {$gte: new Date().toISOString()}}))}`),
rcx._call('GET', `/api/v1/activityhistories?query=${encodeURIComponent(JSON.stringify({memberID: memberId}))}&sort=${encodeURIComponent('{"date":-1}')}&limit=20`)
]);
return {
name: `${profile.member.firstName} ${profile.member.lastName}`,
tier: profile.member.tiers.find(t => t.primary)?.level?.name,
balances: Object.fromEntries(profile.member.purses.map(p => [p.name, p.balance])),
offers,
rewards,
recent: history.map(a => ({ date: a.date, type: a.type, value: a.value, status: a.status }))
};
}
Recipe: idempotent retry wrapper¶
A small helper that makes any post_activity call survive network blips without double-posting.
Because RCX dedupes activities by externalTxnId, the safe retry pattern is just "retry the same POST". On a successful retry of a previously-processed activity, RCX returns the original result and adds X-RCX-DUPLICATE: true to the response headers.
def post_activity_idempotent(rcx, activity, max_retries=3):
assert activity.get('externalTxnId'), 'externalTxnId is required for safe retry'
for attempt in range(max_retries):
try:
return rcx.post_activity(activity)
except (requests.ConnectionError, requests.Timeout):
# Don't try to dedupe client-side — RCX will return the original
# result on the next attempt if the first one had already landed.
time.sleep(2 ** attempt)
raise RuntimeError(f"Activity {activity['externalTxnId']} could not be posted after {max_retries} attempts")
Recipe: webhook receiver¶
import express from 'express';
const app = express();
const seen = new Map(); // header.id -> expiry timestamp
app.use(express.json());
app.post('/rcx/webhook', (req, res) => {
const { header, payload } = req.body;
// Dedupe
const now = Date.now();
for (const [id, exp] of seen) if (exp < now) seen.delete(id);
if (seen.has(header.id)) return res.status(200).end();
seen.set(header.id, now + 24 * 60 * 60 * 1000);
// Route
switch (header.name) {
case 'SetTierEvent': handleTierChange(payload); break;
case 'AddPointsEvent': handlePointsAdded(payload); break;
case 'GiveRewardEvent':handleRewardGranted(payload);break;
default: /* ignore */;
}
res.status(200).end();
});
app.listen(8080);
Secure the endpoint at the network layer (restricted source IPs, hard-to-guess path, mTLS, or an API-gateway header check) — RCX does not sign event deliveries.