rule_set_id, so Scrip places their rules in the program’s default set, and every stop_after_match explanation is scoped to that set. In action fields, values wrapped in ${{ }} are CEL expressions evaluated per event; plain values are literals (see dynamic action fields). When a pattern contains multiple ordered rules, keep those rules together in one set so their relative order and stop_after_match behavior remain intact. See the quickstart if you need help with setup.
Category multipliers
A credit card that pays different rates by purchase category.
Each category is its own rule in the same set, ordered from most specific to least. Create these rules in the order shown; Scrip numbers them as they are added.
stop_after_match on the category rules prevents a dining purchase from also earning the 1% base rate in that set.
stop_after_match because there is nothing later in the set to block.
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.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.
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).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 usesduration_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.
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.
- 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_checkinattribute yet (start at 1) update_last_checkinfires on every checkin to stamp the current timebreak_streakresets the counter to 1 (not 0) because today’s checkin starts a new streakstreak_reward_7uses the same 24-48 hour window ascontinue_streak, so it only pays on the checkin that actually extends the streak to 7
Counter values in conditions are snapshots: they reflect the state before the current event’s actions execute. If one rule increments
streak, a later rule in the same or a later set still sees the old value. Use participant.counter.streak + 1.0 to check the post-increment value.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.
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), so a tag set by the first rule is invisible to the second rule 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.
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.
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.!(... 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%. AnINTRO_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.
Create these rules in the order shown; Scrip numbers them as they are added, so
intro_5pct evaluates before base_1pct and its stop_after_match can block the base rate.
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 later 1% rule in the same set from firing. When the scheduled event fires, UNTAG removes the flag and subsequent purchases fall through to the 1% catch-all.
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.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.
Create these rules in the order shown; Scrip numbers them as they are added, so
high_spender_3pct evaluates before base_1pct and its stop_after_match can block the 1% rate.
(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 for details on cron setup.
Promotional window
Run a “Summer Double Points” campaign from June 1 to September 1. Setactive_from and active_to on the rule and the engine handles the rest. No condition logic needed.
You can create the rule weeks in advance. Events outside the window skip the rule automatically.
active_from and active_to against each event’s event_timestamp and only evaluates the condition when the timestamp falls within the window.
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.The asset must use
inventory_mode: LOT for expiration to work. SIMPLE mode assets ignore expires_at. See Lots and Expiration for details on FIFO ordering and partial expiration."365d" counts from when each purchase is processed. For calendar-exact expiry (“expires on the same day next year”) or backfilled events, compute the date in your backend and pass it through the event: "expires_at": "${{ event.points_expires_at }}". See Expiration for all three accepted forms.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 checksnapshot + 1 to detect the milestone at the right moment.
purchase_count on every purchase. The bonus rule 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.
This pattern is safe under concurrent traffic. Scrip processes events for one participant at a time, so two near-simultaneous purchases cannot both see
purchase_count == 9: the bonus pays exactly once, on the event that actually crosses the milestone. See Execution Guarantees.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.
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:
Reversal / refund
A cardholder disputes a $85.00 dinner charge and the earned cashback has to come back. There are three ways to build this, and the right one depends on where your refund policy lives.- The reversal endpoint reverses what the original event actually credited, in proportion or in full, with Scrip enforcing the cumulative cap and targeting the original lots. Use it when the refund maps cleanly onto one event: full refunds, and partial refunds where every award scales by the same fraction.
- The exact clawback sends a refund event with amounts your backend computed from the recorded impact. Use it when refund policy differs per award, such as base points refunded proportionally but a milestone bonus removed all or nothing.
- The rate mirror recomputes the earn from the refund amount. It is the simplest and works only when every award is a pure rate on the purchase amount.
Reverse the original event
Find the original event by its idempotency key, then reverse it:fraction to reverse everything that remains. Scrip recovers value from the lots the original awards created, where the participant still holds it; anything spent, expired, or transferred is reported as shortfall rather than forced, and cumulative reversals are capped at each original award, so repeated partial refunds cannot over-claw. The response reports per-award accounting and the event’s state changes. See Reversing an event for the full contract.
Rate mirror
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.
You need one reversal rule per earn rate, mirroring your earning rules. Create them in the order shown; Scrip numbers them as they are added, which keeps the specific-to-catch-all cascade intact.
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 does not need to pre-calculate the cashback amount; it sends the reversal amount and MCC.
The rate mirror works when the MCC alone determines the rate. It cannot reproduce state-dependent awards: if the original purchase paid a 3% threshold rate the participant has since lost, or triggered a one-time milestone bonus, recomputing from the refund amount over- or under-claws. For those programs, use the exact clawback below.
Exact clawback
When refund policy differs per award, the reversal endpoint’s uniform fraction is not enough. Scrip records everything an event credited, so your backend can instead read the recorded impact, apply its own policy per asset, and send a refund event with precomputed amounts. Find the original event by the idempotency key you used for the purchase, then read its impact:balance_impact array in the impact response is the net effect, one row per entity, asset, and bucket, and it can contain several rows (multiple assets, a dynamic-target recipient, a HELD bucket). For an event whose rules only credited, the participant’s row is exactly what was earned, including any milestone or threshold award that fired. If the original event mixed credits and debits, the net row understates the credit: derive the earned amount from the positive CREDIT postings in the response’s journal_entries instead:
On
LOT assets this DEBIT consumes the oldest available lots first, not specifically the lots the original purchase created: the participant’s total corrects exactly, but which expiration dates remain can shift (see Oldest-First Spending). The reversal endpoint targets the original award’s lots instead.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 amultiplier benefit that rules reference directly in the amount expression, so you don’t need separate rules per level.
First, create the tier type with levels, qualification criteria, and a
multiplier benefit on each level:
stop_after_match can block the catch-all.
track_ytd_spend rule accumulates annual spend. After all sets finish for the event, Scrip auto-evaluates tier qualification against the updated counter. If the participant crosses a threshold, they advance. The tiered_earn rule 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 for the default 1x rate.
Tier advancement happens after all participating rule sets finish 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.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 for lifecycle details.