Skip to main content
Build a cashback credit card program where participants earn different rates based on purchase category, with a higher rate that kicks in after they hit a monthly spend threshold.

What you’ll build

Use caseHow it works
Category multipliers5% on dining, 3% on groceries, 1% on everything else
Spend threshold bonusRate jumps from 1% to 3% after $2,500/month in total spend
Retroactive bonusWhen crossing the threshold, a one-time bonus covers the delta on prior spend
Monthly resetCounters reset on the 1st of each month via automation
The end result is 8 rules and 1 automation.

Assumptions

This guide assumes you already have:
  • A program created
  • An asset linked to that program (CASHBACK_USD, scale 2, UNLIMITED, SIMPLE)
See the quickstart if you need help with setup.

The rules

1. Track monthly spend

Every rule in this program needs to know how much the participant has spent this month. We track two counters: total spend (for the threshold check) and non-category spend (for the retroactive bonus calculation).
{
  "name": "track_monthly_spend",
  "order": 50,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    { "type": "COUNTER", "key": "monthly_spend", "value": "event.amount" }
  ]
}
{
  "name": "track_monthly_base_spend",
  "order": 55,
  "condition": "event.type == \"purchase\" && event.amount > 0 && !(event.mcc in [\"5812\", \"5813\", \"5814\", \"5411\", \"5422\"])",
  "actions": [
    { "type": "COUNTER", "key": "monthly_base_spend", "value": "event.amount" }
  ]
}
monthly_spend tracks everything (for the $2,500 threshold). monthly_base_spend tracks only non-category purchases - this is what the retroactive bonus pays out on.
Counter values in conditions are snapshots - they reflect the state before the current event’s actions execute. These rules increment the counters in the database, but all later rules still see the pre-increment values. This is important for threshold detection.

3. Retroactive bonus at $2,500

When a participant crosses the $2,500 monthly threshold, they should retroactively earn an extra 2% on their non-category spend so far. This makes up the difference between the 1% they already earned and the 3% high-spender rate.
{
  "name": "threshold_retroactive_bonus",
  "order": 60,
  "condition": "event.type == \"purchase\" && event.amount > 0 && get(participant.counters, \"monthly_spend\", 0.0) < 2500.0 && (get(participant.counters, \"monthly_spend\", 0.0) + event.amount) >= 2500.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "round(get(participant.counters, 'monthly_base_spend', 0.0) * 0.02, 2)",
      "description": "Retroactive 2% bonus on prior non-category spend"
    }
  ]
}
The condition has two parts that make it fire exactly once per month:
  • snapshot < 2500 - hasn’t crossed yet
  • (snapshot + event.amount) >= 2500 - this event crosses it
The credit amount uses monthly_base_spend (not monthly_spend) so the retroactive bonus only applies to purchases that earned the 1% base rate. Dining and grocery purchases already earned their full 5%/3% - paying an extra 2% on those would overshoot.
Don’t use stop_after_match here. The current purchase still needs to flow through the earning rules below to earn its own cashback.

4. Dining - 5%

{
  "name": "dining_cashback",
  "order": 100,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && event.mcc in [\"5812\", \"5813\", \"5814\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "round(event.amount * 0.05, 2)",
      "description": "Dining 5% cashback"
    }
  ]
}
MCC codes: 5812 (restaurants), 5813 (bars), 5814 (fast food). stop_after_match: true ensures a dining purchase earns 5% and only 5% - it won’t also match the grocery or base rules.

5. Groceries - 3%

{
  "name": "grocery_cashback",
  "order": 200,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && event.mcc in [\"5411\", \"5422\"]",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "round(event.amount * 0.03, 2)",
      "description": "Grocery 3% cashback"
    }
  ]
}
MCC codes: 5411 (grocery stores), 5422 (freezer/meat lockers). Same pattern as dining - stop_after_match prevents the base and high-spender rules from stacking on top.

6. High-spender - 3% on everything else

After crossing the $2,500 monthly threshold, all remaining non-category purchases earn 3% instead of 1%.
{
  "name": "high_spender_cashback",
  "order": 300,
  "stop_after_match": true,
  "condition": "event.type == \"purchase\" && event.amount > 0 && (get(participant.counters, \"monthly_spend\", 0.0) + event.amount) >= 2500.0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "round(event.amount * 0.03, 2)",
      "description": "High-spender 3% cashback"
    }
  ]
}
This uses (snapshot + event.amount) >= 2500 so the threshold-crossing purchase itself earns at the higher rate.

7. Base - 1% on everything else

The catch-all for purchases below the threshold that don’t match a category.
{
  "name": "base_cashback",
  "order": 1000,
  "condition": "event.type == \"purchase\" && event.amount > 0",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "{{CASHBACK_USD_ASSET_ID}}",
      "amount": "round(event.amount * 0.01, 2)",
      "description": "Base 1% cashback"
    }
  ]
}
Only fires when no higher-priority rule matched - dining (100), grocery (200), and high-spender (300) all use stop_after_match.

8. Monthly counter reset

{
  "name": "monthly_counter_reset",
  "order": 2000,
  "condition": "event.type == \"monthly_reset\"",
  "actions": [
    {
      "type": "COUNTER",
      "key": "monthly_spend",
      "value": "-get(participant.counters, 'monthly_spend', 0.0)"
    },
    {
      "type": "COUNTER",
      "key": "monthly_base_spend",
      "value": "-get(participant.counters, 'monthly_base_spend', 0.0)"
    }
  ]
}
Triggered by the automation below. Subtracting the current value zeros both counters.

Automation

Create one automation to fire the monthly reset:
{
  "name": "Monthly Counter Reset",
  "trigger": {
    "type": "cron",
    "cron_expression": "0 0 1 * *"
  },
  "scope": "participants",
  "participant_filter": "get(participant.counters, \"monthly_spend\", 0.0) > 0",
  "event_name": "monthly_reset",
  "payload": { "type": "monthly_reset" }
}
On the 1st of each month at midnight UTC, this fans out a monthly_reset event to every participant with a non-zero spend counter. The filter avoids unnecessary events for inactive participants.

Rule evaluation flow

Here’s how a single purchase flows through the rules:
purchase event arrives

  ├─ [50]  track_monthly_spend          always fires, increments total counter
  ├─ [55]  track_monthly_base_spend     increments base counter (non-category only)
  ├─ [60]  threshold_retroactive_bonus  fires once when crossing $2,500

  ├─ [100] dining_cashback              5% if MCC matches → STOP
  ├─ [200] grocery_cashback             3% if MCC matches → STOP
  ├─ [300] high_spender_cashback        3% if monthly_spend >= $2,500 → STOP
  └─ [1000] base_cashback               1% catch-all
Rules 100-1000 are mutually exclusive via stop_after_match. Rules 50-60 always evaluate regardless of category.

Example event

Your backend sends this when a card transaction settles:
{
  "program_id": "{{PROGRAM_ID}}",
  "external_id": "user_123",
  "idempotency_key": "txn_abc123_settled",
  "event_data": {
    "type": "purchase",
    "amount": 85.00,
    "mcc": "5812",
    "merchant_name": "Corner Bistro",
    "transaction_id": "txn_abc123"
  }
}
Only type, amount, and mcc are used by rule conditions. Include whatever else you need for your own analytics.

Edge cases and watchouts

Rounding

With scale: 2, amounts are stored to the cent. Use round(expr, 2) in every CREDIT to avoid precision issues:
round(event.amount * 0.05, 2)  // good - $85.00 * 0.05 = $4.25
event.amount * 0.05             // risky - may produce $4.250000000001

The threshold-crossing purchase

The event that pushes monthly_spend past $2,500 earns at the 3% rate (high-spender), not 1%. This is because high_spender_cashback checks (snapshot + event.amount) >= 2500, which is true for the crossing purchase. The retroactive bonus also fires on the same event, covering all prior spend.

Category purchases don’t care about the threshold

A dining purchase always earns 5% whether the participant has spent $500 or $5,000 this month. The stop_after_match on dining_cashback (order 100) fires before the threshold rules (300, 1000) are even evaluated. The threshold only affects non-category purchases.

Why two counters?

monthly_base_spend exists so the retroactive bonus only pays out on purchases that earned 1%. Without it, a participant who spent $2,000 on dining before crossing the threshold would get an extra 2% on those dining purchases - bumping them from 5% to 7%, which isn’t intended. The retroactive bonus should only upgrade 1% purchases to 3%.

Counter resets and tier status

On the 1st of each month, the counter drops to zero. There’s no “tier” to maintain - the high-spender rate is purely counter-driven. A participant who spent $10,000 last month starts fresh at 1% the next month.

Refund handling

This example doesn’t include refund rules. In production, you’d add rules to:
  1. Debit cashback earned on the refunded transaction
  2. Reduce the monthly_spend counter so the threshold status stays accurate
A refund rule might look like:
{
  "name": "refund_clawback",
  "order": 500,
  "condition": "event.type == \"refund\" && event.original_cashback > 0",
  "actions": [
    { "type": "DEBIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "event.original_cashback" },
    { "type": "COUNTER", "key": "monthly_spend", "value": "-event.original_amount" }
  ]
}
Your backend would include original_cashback and original_amount from the original transaction record.

Excluded transaction types

The base rule doesn’t exclude any MCC codes. In practice, you’d want to block non-qualifying transactions like cash advances or wire transfers:
event.type == "purchase" && event.amount > 0 && !(event.mcc in ["6010", "6011", "6051", "4829", "7995"])

Large MCC lists

The CEL in operator works well for short lists (5-20 entries). If you need to match against hundreds of merchants or categories, move the classification into your backend and pass a flag like event.is_dining: true in the event payload instead.