# 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

Rules can only declare resources that exist on the project. Check `GET /projects/{projectId}/resources` first. If no `purchase` event resource exists, create one:

```http
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

```http
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

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

```http
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

```http
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](/guides/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.

## 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:
```json
{ "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:
```json
{ "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:
```json
{ "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](/guides/conditions/).

**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:
```json
{ "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.

## 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](/guides/webhooks/). |
| `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

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

## See also

[Loyalty events](/concepts/loyalty-events/) · [Conditions and resources](/guides/conditions/) · [Workflows](/guides/workflows/) · [Debugging rules](/guides/debugging/) · [Wallets](/concepts/wallets/) · **Rules**, **Resources**, **Wallet** in the [API reference](https://api.storelayer.io/docs)