Setting Up a Loyalty Program
A loyalty program is a set of rules. Each rule binds to an event type, checks conditions against the event and other declared resources, and runs actions that change wallets or call out. Money in payloads is integer minor units (cents): $50.00 is 5000.
Endpoints below are relative to https://api.storelayer.io and use Authorization: Bearer <token>.
1. Register the event resource
Section titled “1. Register the event resource”Rules can only declare resources that exist on the project. Check GET /projects/{projectId}/resources first. If no purchase event resource exists, create one:
POST /projects/{projectId}/resources{ "key": "purchase", "name": "Purchase event", "type": "event" }Keep the returned data.id. Because the key equals the event type, any rule that declares this resource runs only for type: "purchase" events.
2. Create the rule
Section titled “2. Create the rule”POST /projects/{projectId}/rules{ "name": "100 points for purchases of $50+", "conditions": { "combinator": "AND", "conditions": [ { "leftValue": "{{ $('event').payload.amount }}", "operator": "gte", "rightValue": 5000, "rightType": "number" } ] }, "resources": [{ "id": "<purchase resource id>", "key": "purchase", "type": "event", "value": {} }], "actions": [{ "type": "reward", "config": { "assetType": "points", "amount": 100, "description": "Purchase reward", "tags": ["{{ $('event').payload.orderId }}"] } }]}userId is omitted, so it defaults to {{ $('event').userId }}. The tag puts the order ID on the wallet asset, which helps with tracing.
3. Test the boundaries
Section titled “3. Test the boundaries”POST /projects/{projectId}/rules/test evaluates a condition group against a context you supply. Nothing is saved and no resources are resolved, so pass the event under event exactly as the rule sees it:
POST /projects/{projectId}/rules/test{ "conditions": { "combinator": "AND", "conditions": [ { "leftValue": "{{ $('event').payload.amount }}", "operator": "gte", "rightValue": 5000, "rightType": "number" } ]}, "context": { "event": { "type": "purchase", "userId": "cust_test_1", "payload": { "amount": 4999 } } }}The response is data.matched plus data.results[], with resolvedLeft, resolvedRight, matched and message per condition. Run 4999 (false), 5000 and 5001 (true). If resolvedLeft is undefined, your path is wrong (for example $('event').amount instead of $('event').payload.amount).
4. Ingest and verify
Section titled “4. Ingest and verify”POST /ingest/eventsX-API-Key: <project API key>{ "type": "purchase", "userId": "cust_test_1", "payload": { "orderId": "ord_1001", "amount": 7450, "currency": "USD" } }→ 201 { "success": true, "data": { "id": "evt_abc", "status": "accepted" } }- Poll
GET /projects/{projectId}/workflows/evt_abcuntil it stops returning404(see Workflows). - Check
data.rules[]. Your rule should showmatched: true. Then checkdata.actions[]: therewardaction should showstatus: "completed"withresult.amount: 100. - Confirm the balance:
GET /projects/{projectId}/users/cust_test_1/walletreturnsdata.assets.points.balance, and.../wallet/transactionslists the entry.
Send a 4999 purchase too and check that its workflow has the rule with matched: false and no action.
Patterns
Section titled “Patterns”Each pattern needs its event resource registered (step 1). Only the parts that differ are shown.
Welcome bonus. Event resource user_registered. Condition {{ $('event').userId }} exists. Action:
{ "type": "reward", "config": { "assetType": "points", "amount": 500, "description": "Welcome bonus" } }Storelayer does not stop a second user_registered event for the same user, so send it once from your signup flow.
Points per dollar. Use an expression amount. Whole dollars only, so $74.50 gives 74 points:
{ "type": "reward", "config": { "assetType": "points", "amount": "{{ Math.floor($('event').payload.amount / 100) }}" } }Under $1 the expression evaluates to 0. The action is then recorded as successful with result.skipped: true and nothing is earned. A negative result fails the action.
Tier multiplier. Send the tier in the payload and branch in the expression:
{ "type": "reward", "config": { "assetType": "points", "amount": "{{ $('event').payload.tier === 'gold' ? Math.floor($('event').payload.amount / 100) * 2 : Math.floor($('event').payload.amount / 100) }}" } }To read the tier from stored customer data instead, add an internal resource, declare it on the rule, and see Conditions and resources.
Expiring points. expiresIn is a positive integer and expiresInUnit is one of seconds, minutes, hours, days, weeks, months, years. Each requires the other. You can use expirationDate (ISO) instead, but expiresIn takes priority:
{ "type": "reward", "config": { "assetType": "points", "amount": 100, "expiresIn": 90, "expiresInUnit": "days" } }Stamp card (10 visits gives a free coffee). Resources are resolved before any action runs, so a stamp earned in this event is not yet in the wallet. Chain a second event:
- Register a wallet resource:
POST /projects/{projectId}/resourceswith{ "key": "wallet", "name": "Wallet", "type": "internal", "config": { "toolName": "wallet.get_balance" } }. Also register event resourcesvisitandstamp_added. - Rule A (
visit) has actions{ "type": "reward", "config": { "assetType": "stamps", "amount": 1 } }and{ "type": "emit_event", "config": { "eventType": "stamp_added" } }. - Rule B declares
stamp_addedandwallet, with condition{{ $('wallet').balances.stamps }}gte10. Actions:redemptionof 10stampsandrewardof 1free_coffee.
Emitted events inherit the parent’s userId, run as separate workflows, and stop after 5 levels or when an event type repeats in the chain.
Refunds. Bind a refund rule to a redemption action with the same formula you used for earning. If the customer has already spent the points, the spend fails with Insufficient balance… and the whole workflow is failed. Decide your policy before going live.
Action reference
Section titled “Action reference”userId defaults to {{ $('event').userId }} wherever it appears. Config values that are a single {{ … }} expression are evaluated. Strings that only contain one, like "Order {{ … }}", stay literal.
| Type | Config | Notes |
|---|---|---|
reward | assetType, amount, userId?, description?, expiresIn?+expiresInUnit?, expirationDate?, tags? | A literal amount must be > 0. { "expression": "…" } is also accepted. |
redemption | assetType, amount, userId?, description? | Fails the workflow on insufficient balance. |
modify | assetType, targetAmount, userId?, description?, expiry fields, tags? | Sets an absolute balance, not a delta. |
integration | integrationId, payloadTemplate?, sql? (postgres), to?/subject?/body? (aws-ses) | See HTTP integrations. |
apply_referral | code, refereeId | Both required by the API. |
complete_referral | refereeId | Finds the referee’s referral and returns referralId/referrerId in the result. It fails if none exists. It changes no referral state itself. |
mark_code_used | code, refereeId? | Applies the code for the referee (same call as apply_referral). |
emit_event | eventType, userId?, payload? | Queues a derived event. |
update_user | userId?, firstName?, lastName?, email?, phone?, birthday?, gender?, metadata? | Metadata is shallow-merged. |
custom_table | operation (insert/update/delete), tableName, rowId?, data? | update/delete need rowId. insert/update need data. |
Pitfalls
Section titled “Pitfalls”- Declare every resource your conditions and actions read. An undeclared one is
undefined, with no error. - A rule without an event-type binding fires on every event.
- Retrying ingest after a timeout can award twice. Look up the workflow first.
POST /projects/{projectId}/rules/execute-actionsand/rules/executereally run actions (they credit wallets). Use test users only.
See also
Section titled “See also”Loyalty events · Conditions and resources · Workflows · Debugging rules · Wallets · Rules, Resources, Wallet in the API reference