> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scrip.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Common Patterns

> Copy-paste recipes for the most common reward behaviors

Self-contained rule snippets for behaviors that come up in almost every program. Each pattern assumes you already have a program and asset configured. See the [quickstart](/quickstart) if you need help with setup.

## Category multipliers

A credit card that pays different rates by purchase category.

| Category        | Rate | MCC codes        |
| --------------- | ---- | ---------------- |
| Dining          | 5%   | 5812, 5813, 5814 |
| Groceries       | 3%   | 5411, 5422       |
| Everything else | 1%   | -                |

Each category is its own rule, ordered from most specific to least. `stop_after_match` on the category rules prevents a dining purchase from also earning the 1% base rate.

```json theme={null}
{
  "name": "dining_5pct",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.mcc in [\"5812\", \"5813\", \"5814\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.05, 2) }}"
    }
  ]
}
```

```json theme={null}
{
  "name": "grocery_3pct",
  "order": 200,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.mcc in [\"5411\", \"5422\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.03, 2) }}"
    }
  ]
}
```

```json theme={null}
{
  "name": "base_1pct",
  "order": 1000,
  "condition": "event.type == \"purchase\"",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.01, 2) }}"
    }
  ]
}
```

**How it works:** Three rules, evaluated in order. A dining purchase matches at 100 and stops. It never sees the grocery or base rules. A non-category purchase skips dining and grocery, then falls through to the 1% catch-all. The catch-all has no `stop_after_match` because there's nothing below it to block.

<Info>
  The CEL `in` operator works well for short lists (5-20 entries). For hundreds of merchants, move classification into your backend and pass a flag like `event.is_dining: true` instead.
</Info>

## Sign-up bonus

"Spend \$4,000 in the first 3 months, earn 60,000 points." This is the most common card acquisition offer. It combines three checks: the participant must still be within their intro window, their cumulative spend must cross the threshold, and the bonus can only pay out once. `duration_days` against `participant.enrolled_at` enforces the time window, a counter snapshot detects the threshold crossing, and a TAG prevents double-claiming.

| What                      | How                                                    |
| ------------------------- | ------------------------------------------------------ |
| Track qualifying spend    | Counter `intro_spend`, only during intro window        |
| Enforce time window       | `duration_days(now - participant.enrolled_at) <= 90.0` |
| Detect threshold crossing | `(snapshot + event.amount) >= 4000.0`                  |
| Prevent double-claiming   | TAG `SIGNUP_BONUS_CLAIMED`                             |

```json theme={null}
{
  "name": "track_intro_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0 && duration_days(now - participant.enrolled_at) <= 90.0",
  "actions": [
    { "type": "COUNTER", "key": "intro_spend", "value": "${{ event.amount }}" }
  ]
}
```

```json theme={null}
{
  "name": "signup_bonus",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0 && !(\"SIGNUP_BONUS_CLAIMED\" in participant.tags) && duration_days(now - participant.enrolled_at) <= 90.0 && (participant.counter.intro_spend + event.amount) >= 4000.0",
  "actions": [
    {
      "type": "TAG",
      "tag": "SIGNUP_BONUS_CLAIMED"
    },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "60000"
    }
  ]
}
```

**How it works:** The tracking rule at order 50 accumulates spend only during the 90-day intro window. The bonus rule at order 100 checks three things: no TAG yet, still within 90 days, and this event pushes spend across \$4,000.

<Info>
  `participant.enrolled_at` is the enrollment timestamp Scrip records when the participant joins the program, available as a CEL timestamp with no backend attribute needed. If you prefer calendar months, track the deadline as an attribute and compare directly: `now < timestamp(participant.attributes.intro_deadline)`.
</Info>

## Streak challenge

A fitness app rewards users who check in 7 days in a row. Scrip tracks consecutive activity entirely within the rules engine. It stores the last checkin timestamp as a participant attribute and uses `duration_hours` to determine whether the next checkin is consecutive (24-48h gap), a broken streak (48h+), or the very first one. No backend date math needed.

| Rule                  | Purpose                                                         |
| --------------------- | --------------------------------------------------------------- |
| `continue_streak`     | Increment streak counter if checkin is consecutive (24-48h gap) |
| `break_streak`        | Reset counter to 1 if user skipped a day (48h+ gap)             |
| `start_streak`        | Start a new streak on first-ever checkin                        |
| `update_last_checkin` | Store the current timestamp for next comparison                 |
| `streak_reward_7`     | Pay out bonus when streak hits 7                                |

Scrip computes the gap between checkins using `duration_hours(now - timestamp(participant.attributes.last_checkin))`. Your backend sends a plain `checkin` event; no date math needed.

```json theme={null}
{
  "name": "continue_streak",
  "order": 100,
  "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 24.0 && duration_hours(now - timestamp(participant.attributes.last_checkin)) < 48.0",
  "actions": [
    { "type": "COUNTER", "key": "streak", "value": "1" }
  ]
}
```

```json theme={null}
{
  "name": "break_streak",
  "order": 110,
  "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 48.0",
  "actions": [
    {
      "type": "COUNTER",
      "key": "streak",
      "value": "${{ -participant.counter.streak + 1.0 }}"
    }
  ]
}
```

```json theme={null}
{
  "name": "start_streak",
  "order": 120,
  "condition": "event.type == \"checkin\" && !has(participant.attributes.last_checkin)",
  "actions": [
    { "type": "COUNTER", "key": "streak", "value": "1" }
  ]
}
```

```json theme={null}
{
  "name": "update_last_checkin",
  "order": 500,
  "condition": "event.type == \"checkin\"",
  "actions": [
    { "type": "SET_ATTRIBUTE", "key": "last_checkin", "value": "${{ string(now) }}" }
  ]
}
```

```json theme={null}
{
  "name": "streak_reward_7",
  "order": 200,
  "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 24.0 && duration_hours(now - timestamp(participant.attributes.last_checkin)) < 48.0 && participant.counter.streak + 1.0 == 7.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "100"
    }
  ]
}
```

**How it works:**

* **Consecutive day** = 24-48 hours since last checkin (increment streak)
* **Skipped a day** = 48+ hours since last checkin (reset to 1)
* **First checkin** = no `last_checkin` attribute yet (start at 1)
* `update_last_checkin` fires on every checkin at order 500 to stamp the current time
* `break_streak` resets the counter to 1 (not 0) because today's checkin starts a new streak
* `streak_reward_7` uses the same 24-48 hour window as `continue_streak`, so it only pays on the checkin that actually extends the streak to 7

<Info>
  Counter values in conditions are **snapshots**: they reflect the state *before* the current event's actions execute. If a rule at order 100 increments `streak`, a rule at order 200 still sees the old value. Use `participant.counter.streak + 1.0` to check the post-increment value.
</Info>

<Warning>
  The 24-48 hour window assumes roughly one checkin per day. A checkin under 24 hours after the previous one leaves the streak counter unchanged and pays no reward, but `update_last_checkin` still re-stamps `last_checkin`, which restarts the window for the next checkin. Adjust the lower bound if you need sub-day checkins to count.
</Warning>

## Referral credit

A new user completes their first ride, and both they and the friend who referred them earn \$10. A single event credits two different participants. The referee gets credited normally as the event participant, while the referrer is resolved from a field in the event payload using a dynamic target.

| Participant              | Role              | How they're credited                                         |
| ------------------------ | ----------------- | ------------------------------------------------------------ |
| Referee (new user)       | Event participant | Normal CREDIT. No target needed.                             |
| Referrer (existing user) | Dynamic target    | CREDIT with `target.external_id: "${{ event.referrer_id }}"` |

Both rules gate on the same `!("REFERRAL_USED" in participant.tags)` check. Participant state is snapshotted once per event (see the Info in the [streak challenge](#streak-challenge)), so a tag set by the rule at order 100 is invisible to the rule at order 200 within the same event. Because both rules read the same pre-event snapshot, both fire on the first `referral_completed` event, and the TAG written by `referee_bonus` makes both skip on any duplicate event.

```json theme={null}
{
  "name": "referee_bonus",
  "order": 100,
  "condition": "event.type == \"referral_completed\" && !(\"REFERRAL_USED\" in participant.tags)",
  "actions": [
    { "type": "TAG", "tag": "REFERRAL_USED" },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "10.00",
      "description": "Referee welcome bonus"
    }
  ]
}
```

```json theme={null}
{
  "name": "referrer_bonus",
  "order": 200,
  "condition": "event.type == \"referral_completed\" && !(\"REFERRAL_USED\" in participant.tags)",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "10.00",
      "target": {
        "external_id": "${{ event.referrer_id }}"
      },
      "description": "Referrer bonus"
    }
  ]
}
```

**How it works:** On the first event, both conditions see the untagged snapshot: the referee gets \$10, the referrer gets \$10, and `REFERRAL_USED` is written. A replayed or duplicate `referral_completed` event sees the tag and both rules skip. The `target.external_id` field is a CEL expression: it evaluates `event.referrer_id` to find the referrer participant. The referrer must be enrolled in the same program.

<Warning>
  Do not gate the referrer rule on `"REFERRAL_USED" in participant.tags` (the inverse check). The tag set at order 100 is not visible at order 200 during the same event, so that version pays the referrer nothing on the referral event and then pays them again on every subsequent `referral_completed` event.
</Warning>

<Warning>
  If `event.referrer_id` resolves to a user who isn't enrolled in the program, the action will fail and the entire event will error. Validate referrer enrollment in your backend before sending the event.
</Warning>

## One-time welcome reward

"Complete your first purchase and earn \$25." Unlike the sign-up bonus (which tracks cumulative spend over a time window), this fires on any single qualifying purchase. A TAG gates the condition so the bonus only pays out once.

| What           | How                                            |
| -------------- | ---------------------------------------------- |
| Trigger        | First `purchase` event with `amount > 0`       |
| One-time guard | TAG `FIRST_PURCHASE_DONE` checked in condition |
| Payout         | Fixed \$25 CREDIT                              |

```json theme={null}
{
  "name": "first_purchase_bonus",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0 && !(\"FIRST_PURCHASE_DONE\" in participant.tags)",
  "actions": [
    { "type": "TAG", "tag": "FIRST_PURCHASE_DONE" },
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "25.00",
      "description": "First purchase bonus"
    }
  ]
}
```

**How it works:** Same TAG-gating pattern as the [sign-up bonus](#sign-up-bonus). The first purchase passes the `!(... in participant.tags)` check, sets `FIRST_PURCHASE_DONE`, and earns the bonus. Every subsequent purchase sees the tag and the condition evaluates to false.

## Introductory rate with expiration

A card that pays 5% for the first 90 days, then drops to the standard 1%. An `INTRO_ACTIVE` tag marks the window, a scheduled event removes it when the window closes, and `UNTAG` cleans up the flag so the standard rate takes over.

| Rule             | Purpose                                              |
| ---------------- | ---------------------------------------------------- |
| `activate_intro` | TAG participant on signup, schedule expiration event |
| `intro_5pct`     | 5% earn rate while `INTRO_ACTIVE` tag exists         |
| `end_intro`      | UNTAG `INTRO_ACTIVE` when the scheduled event fires  |
| `base_1pct`      | 1% catch-all after intro ends                        |

```json theme={null}
{
  "name": "activate_intro",
  "order": 50,
  "condition": "event.type == \"signup\"",
  "actions": [
    { "type": "TAG", "tag": "INTRO_ACTIVE" },
    { "type": "SCHEDULE_EVENT", "event_name": "intro_expired", "delay": "90d" }
  ]
}
```

```json theme={null}
{
  "name": "intro_5pct",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && \"INTRO_ACTIVE\" in participant.tags",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.05, 2) }}"
    }
  ]
}
```

```json theme={null}
{
  "name": "end_intro",
  "order": 150,
  "condition": "event.type == \"intro_expired\"",
  "actions": [
    { "type": "UNTAG", "tag": "INTRO_ACTIVE" }
  ]
}
```

```json theme={null}
{
  "name": "base_1pct",
  "order": 200,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.01, 2) }}"
    }
  ]
}
```

**How it works:** On signup, the participant gets tagged `INTRO_ACTIVE` and a `intro_expired` event is scheduled 90 days out. During the intro window, purchases match the 5% rule first and `stop_after_match` prevents the 1% rule from also firing. When the scheduled event fires, `UNTAG` removes the flag and subsequent purchases fall through to the 1% catch-all.

<Info>
  This approach is more flexible than `active_from`/`active_to` time windows because the intro period is per-participant, starting from their individual signup date.
</Info>

## Spend threshold unlock

A card that pays 1% normally, but bumps to 3% after the participant spends \$2,500 in a month. A counter snapshot checks whether the pre-event total plus the current amount has crossed the line, and a cron automation zeros the counter monthly.

| Rule                  | Purpose                                                       |
| --------------------- | ------------------------------------------------------------- |
| `track_monthly_spend` | Accumulate spend in a counter                                 |
| `high_spender_3pct`   | 3% if `(snapshot + event.amount) >= 2500`, `stop_after_match` |
| `base_1pct`           | 1% catch-all for below-threshold purchases                    |
| `reset_monthly_spend` | Zero the counter on the first of each month                   |

```json theme={null}
{
  "name": "track_monthly_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    { "type": "COUNTER", "key": "monthly_spend", "value": "${{ event.amount }}" }
  ]
}
```

```json theme={null}
{
  "name": "high_spender_3pct",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && (participant.counter.monthly_spend + event.amount) >= 2500.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.03, 2) }}"
    }
  ]
}
```

```json theme={null}
{
  "name": "base_1pct",
  "order": 200,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.01, 2) }}"
    }
  ]
}
```

Add a reset rule and a cron automation to zero the counter at the start of each month:

```json theme={null}
{
  "name": "reset_monthly_spend",
  "order": 2000,
  "condition": "event.type == \"monthly_reset\"",
  "actions": [
    { "type": "COUNTER", "key": "monthly_spend", "value": "${{ -participant.counter.monthly_spend }}" }
  ]
}
```

The automation that triggers the reset:

```bash theme={null}
POST /v1/programs/{programId}/automations
{
  "name": "Monthly Counter Reset",
  "trigger": {
    "type": "cron",
    "cron_expression": "0 0 1 * *"
  },
  "scope": "participants",
  "participant_filter": "participant.counter.monthly_spend > 0",
  "event_name": "monthly_reset"
}
```

**How it works:** The threshold check uses `(snapshot + event.amount) >= 2500.0` so the purchase that crosses the threshold earns at the higher rate. Once above 2500, every subsequent purchase that month also earns 3%. On the first of the month, the cron automation generates a `monthly_reset` event for every participant with a non-zero spend counter, and the reset rule zeros the counter by subtracting its current value. See [Automations](/guides/automations) for details on cron setup.

## Promotional window

Run a "Summer Double Points" campaign from June 1 to September 1. Set `active_from` and `active_to` on the rule and the engine handles the rest. No condition logic needed.

| Field         | Value                  | Effect                                                                     |
| ------------- | ---------------------- | -------------------------------------------------------------------------- |
| `active_from` | `2025-06-01T00:00:00Z` | Rule skipped for events stamped before this timestamp (inclusive start)    |
| `active_to`   | `2025-09-01T00:00:00Z` | Rule skipped for events stamped at or after this timestamp (exclusive end) |

You can create the rule weeks in advance. Events outside the window skip the rule automatically.

```json theme={null}
{
  "name": "summer_double_points",
  "order": 100,
  "active_from": "2025-06-01T00:00:00Z",
  "active_to": "2025-09-01T00:00:00Z",
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.02, 2) }}",
      "description": "Summer double points bonus"
    }
  ]
}
```

**How it works:** You create the rule anytime, even weeks before the campaign starts. The engine checks `active_from` and `active_to` against each event's `event_timestamp` and only evaluates the condition when the timestamp falls within the window.

<Warning>
  `active_from` and `active_to` are compared against the event's `event_timestamp`, not the wall-clock time at processing. Historical imports with an `event_timestamp` inside a past window do fire time-windowed rules, and event timestamps are supplied by the caller, so a backdated event can reach a window that has already closed on the calendar.
</Warning>

## Expiring points

Points that expire 12 months after they're earned. Each purchase starts its own expiration clock. A January purchase expires in January of next year, not when the program year ends. LOT inventory mode creates a separate lot per CREDIT, each with its own countdown.

| Config                 | Value  | Notes                           |
| ---------------------- | ------ | ------------------------------- |
| Asset `inventory_mode` | `LOT`  | Required for expiration to work |
| `expires_at` on CREDIT | `365d` | 12 months in days               |

```json theme={null}
{
  "name": "earn_points",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 1.0, 0) }}",
      "expires_at": "365d",
      "description": "Points earned (expire in 12 months)"
    }
  ]
}
```

**How it works:** A January purchase expires in January of next year, a March purchase in March, and so on. When a lot expires, the balance is automatically forfeited.

<Info>
  The asset must use `inventory_mode: LOT` for expiration to work. `SIMPLE` mode assets ignore `expires_at`. See [Lots and Expiration](/guides/lots-and-expiration) for details on FIFO ordering and partial expiration.
</Info>

## Milestone bonus (Nth action)

A coffee shop that gives a free drink every 10th purchase. A counter and the modulo operator handle the detection. Because conditions see the counter snapshot (pre-increment value), you check `snapshot + 1` to detect the milestone at the right moment.

| Rule                        | Purpose                              |
| --------------------------- | ------------------------------------ |
| `track_purchases`           | Increment `purchase_count` counter   |
| `every_10th_purchase_bonus` | Fire when `(snapshot + 1) % 10 == 0` |

```json theme={null}
{
  "name": "track_purchases",
  "order": 50,
  "condition": "event.type == \"purchase\"",
  "actions": [
    { "type": "COUNTER", "key": "purchase_count", "value": "1" }
  ]
}
```

```json theme={null}
{
  "name": "every_10th_purchase_bonus",
  "order": 100,
  "condition": "event.type == \"purchase\" && int(participant.counter.purchase_count + 1.0) % 10 == 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "50",
      "description": "Every 10th purchase bonus"
    }
  ]
}
```

**How it works:** The tracking rule at order 50 increments `purchase_count` on every purchase. The bonus rule at order 100 adds 1 to the snapshot and checks if the result is divisible by 10. On the 10th, 20th, 30th purchase (and so on), the modulo evaluates to 0 and the bonus fires.

For a **one-time milestone** (e.g., 100th purchase only), replace the modulo with an exact check:

```
participant.counter.purchase_count + 1.0 == 100.0
```

## Capped reward per transaction

10% cashback on every purchase, but capped at \$50 per transaction. `math.least` and `math.greatest` in CEL let you express the cap directly in the amount formula.

| Purchase amount | Calculated reward | After cap |
| --------------- | ----------------- | --------- |
| \$200           | \$20              | \$20      |
| \$500           | \$50              | \$50      |
| \$1,000         | \$100             | **\$50**  |

```json theme={null}
{
  "name": "capped_cashback",
  "order": 100,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(math.least(event.amount * 0.10, 50.0), 2) }}",
      "description": "10% cashback, max $50 per transaction"
    }
  ]
}
```

**How it works:** `math.least(event.amount * 0.10, 50.0)` evaluates both expressions and returns the smaller value. Below \$500, the percentage is less than \$50 so the participant gets the full 10%. At \$500 and above, the cap kicks in.

For a **floor** instead of a cap, use `math.greatest`:

```
math.greatest(event.amount * 0.01, 1.0)   // at least $1 per transaction
```

## Reversal / refund

A cardholder disputes a \$85.00 dinner charge. The issuer sends a reversal event with the amount and merchant details: the same fields as the original purchase. Scrip debits the cashback that would have been earned on that amount.

| Field          | Example      | Purpose                                  |
| -------------- | ------------ | ---------------------------------------- |
| `event.type`   | `"reversal"` | Distinguishes from a purchase            |
| `event.amount` | `85.00`      | Amount being reversed (can be partial)   |
| `event.mcc`    | `"5812"`     | Used to determine the original earn rate |

You need one reversal rule per earn rate, mirroring your earning rules:

```json theme={null}
{
  "name": "reversal_dining",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"reversal\" && event.amount > 0 && event.mcc in [\"5812\", \"5813\", \"5814\"]",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.05, 2) }}",
      "description": "Reversal of dining 5% cashback"
    }
  ]
}
```

```json theme={null}
{
  "name": "reversal_grocery",
  "order": 200,
  "stop_after_match": true,
  "condition": "event.type == \"reversal\" && event.amount > 0 && event.mcc in [\"5411\", \"5422\"]",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.03, 2) }}",
      "description": "Reversal of grocery 3% cashback"
    }
  ]
}
```

```json theme={null}
{
  "name": "reversal_base",
  "order": 1000,
  "condition": "event.type == \"reversal\" && event.amount > 0",
  "actions": [
    {
      "type": "DEBIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 0.01, 2) }}",
      "description": "Reversal of base 1% cashback"
    }
  ]
}
```

**How it works:** Same `stop_after_match` cascade as the earning rules, but with DEBIT instead of CREDIT. A \$40 partial refund on a dining purchase debits `round(40 * 0.05, 2)` = \$2.00. The issuer doesn't need to pre-calculate the cashback amount; it sends the reversal amount and MCC.

<Warning>
  DEBIT fails if the participant's available balance is insufficient. If the participant has already spent their cashback, the reversal event will error. Your backend should handle this case (e.g., retry later, or accept the loss).
</Warning>

<Info>
  This approach works well for category-based rates where the rate is deterministic from the MCC. For threshold-based rates (e.g., 1% vs 3% depending on monthly spend), the reversal may not perfectly match the original earn rate if the participant's counter has changed since the original purchase. If precision matters, have your backend include the original earn rate in the reversal event.
</Info>

## Tiered earn rates

A hotel loyalty program where Silver members earn 1x points per dollar, Gold earns 1.5x, and Platinum earns 2x. Participants advance automatically based on annual spend. Each tier level carries a `multiplier` benefit that rules reference directly in the amount expression, so you don't need separate rules per level.

| What                   | How                                                                                  |
| ---------------------- | ------------------------------------------------------------------------------------ |
| Track qualifying spend | Counter `ytd_spend`, incremented on every purchase                                   |
| Auto-advance tiers     | Qualification criteria on each level (Silver: $500, Gold: $2,000, Platinum: \$5,000) |
| Dynamic earn rate      | `participant.tiers.loyalty.benefits.multiplier` in the CREDIT amount                 |
| Annual reset           | `CALENDAR_YEAR` qualification period, counters reset to 0                            |

First, create the tier type with levels, qualification criteria, and a `multiplier` benefit on each level:

```bash theme={null}
POST /v1/programs/{programId}/tiers
{
  "key": "loyalty",
  "display_name": "Loyalty Status",
  "levels": [
    {
      "key": "silver",
      "rank": 1,
      "display_name": "Silver",
      "qualification": {
        "mode": "ALL",
        "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 500}]
      },
      "benefits": {"multiplier": 1.0}
    },
    {
      "key": "gold",
      "rank": 2,
      "display_name": "Gold",
      "qualification": {
        "mode": "ALL",
        "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 2000}]
      },
      "benefits": {"multiplier": 1.5}
    },
    {
      "key": "platinum",
      "rank": 3,
      "display_name": "Platinum",
      "qualification": {
        "mode": "ALL",
        "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 5000}]
      },
      "benefits": {"multiplier": 2.0}
    }
  ],
  "lifecycle": {
    "retention": {"mode": "PERIOD_BASED"},
    "qualification_period": {"type": "CALENDAR_YEAR"},
    "downgrade_policy": {"mode": "DROP_TO_QUALIFYING"},
    "counters": {"qualifying": ["ytd_spend"], "rollover": "NONE"}
  }
}
```

Then three rules: one to track spend (which drives qualification), one to earn at the tier rate, and a catch-all for participants who haven't reached a tier yet.

```json theme={null}
{
  "name": "track_ytd_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    { "type": "COUNTER", "key": "ytd_spend", "value": "${{ event.amount }}" }
  ]
}
```

```json theme={null}
{
  "name": "tiered_earn",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && has(participant.tiers.loyalty)",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * participant.tiers.loyalty.benefits.multiplier, 0) }}"
    }
  ]
}
```

```json theme={null}
{
  "name": "base_earn",
  "order": 200,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{ASSET_ID}}",
      "amount": "${{ round(event.amount * 1.0, 0) }}"
    }
  ]
}
```

**How it works:** The `track_ytd_spend` rule at order 50 accumulates annual spend. After all rules fire for the event, Scrip auto-evaluates tier qualification against the updated counter. If the participant crosses a threshold, they advance. The `tiered_earn` rule at order 100 pulls the earn rate from `participant.tiers.loyalty.benefits.multiplier`, so adding a new tier level or changing a multiplier is a tier config change, not a rule change. `has(participant.tiers.loyalty)` ensures participants without a tier fall through to `base_earn` at order 200 for the default 1x rate.

<Info>
  Tier advancement happens after all rules evaluate for the current event. A purchase that pushes `ytd_spend` past the Gold threshold qualifies the participant, but the current event still earns at the pre-advancement rate. The new multiplier applies starting with the next event.
</Info>

<Info>
  At the end of each calendar year, the `PERIOD_BASED` lifecycle re-evaluates tiers. Participants who no longer meet their level's criteria are downgraded to the highest level they still qualify for (`DROP_TO_QUALIFYING`). The `NONE` rollover resets `ytd_spend` to 0. See [Tiers](/guides/tiers) for lifecycle details.
</Info>
