Skip to content

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

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.

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.

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

POST /ingest/events
X-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" } }
  1. Poll GET /projects/{projectId}/workflows/evt_abc until it stops returning 404 (see Workflows).
  2. Check data.rules[]. Your rule should show matched: true. Then check data.actions[]: the reward action should show status: "completed" with result.amount: 100.
  3. Confirm the balance: GET /projects/{projectId}/users/cust_test_1/wallet returns data.assets.points.balance, and .../wallet/transactions lists the entry.

Send a 4999 purchase too and check that its workflow has the rule with matched: false and no action.

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:

  1. Register a wallet resource: POST /projects/{projectId}/resources with { "key": "wallet", "name": "Wallet", "type": "internal", "config": { "toolName": "wallet.get_balance" } }. Also register event resources visit and stamp_added.
  2. Rule A (visit) has actions { "type": "reward", "config": { "assetType": "stamps", "amount": 1 } } and { "type": "emit_event", "config": { "eventType": "stamp_added" } }.
  3. Rule B declares stamp_added and wallet, with condition {{ $('wallet').balances.stamps }} gte 10. Actions: redemption of 10 stamps and reward of 1 free_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.

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.

TypeConfigNotes
rewardassetType, amount, userId?, description?, expiresIn?+expiresInUnit?, expirationDate?, tags?A literal amount must be > 0. { "expression": "…" } is also accepted.
redemptionassetType, amount, userId?, description?Fails the workflow on insufficient balance.
modifyassetType, targetAmount, userId?, description?, expiry fields, tags?Sets an absolute balance, not a delta.
integrationintegrationId, payloadTemplate?, sql? (postgres), to?/subject?/body? (aws-ses)See HTTP integrations.
apply_referralcode, refereeIdBoth required by the API.
complete_referralrefereeIdFinds the referee’s referral and returns referralId/referrerId in the result. It fails if none exists. It changes no referral state itself.
mark_code_usedcode, refereeId?Applies the code for the referee (same call as apply_referral).
emit_eventeventType, userId?, payload?Queues a derived event.
update_useruserId?, firstName?, lastName?, email?, phone?, birthday?, gender?, metadata?Metadata is shallow-merged.
custom_tableoperation (insert/update/delete), tableName, rowId?, data?update/delete need rowId. insert/update need data.
  • 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-actions and /rules/execute really run actions (they credit wallets). Use test users only.

Loyalty events · Conditions and resources · Workflows · Debugging rules · Wallets · Rules, Resources, Wallet in the API reference