Skip to main content
Self-contained rule snippets for behaviors that come up in almost every program. Each pattern assumes you already have a program and asset configured. The snippets omit 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.
How it works: The three rules evaluate in the order they were created. A dining purchase matches the first rule and stops the rest of that set. 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 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.
How it works: The tracking rule accumulates spend only during the 90-day intro window. The bonus rule checks three things: no TAG yet, still within 90 days, and this event pushes spend across $4,000.
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 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. 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.
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 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
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.
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.

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.
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.
Do not gate the referrer rule on "REFERRAL_USED" in participant.tags (the inverse check). The tag set by referee_bonus is not visible to referrer_bonus 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.
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.

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.
How it works: Same TAG-gating pattern as the 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. 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.
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 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.
Add a reset rule and a cron automation to zero the counter at the start of each month:
The automation that triggers the 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 for details on cron setup.
The reset is an event like any other, and Scrip does not guarantee processing order between it and purchases sent near the boundary (see Execution Guarantees). A purchase arriving close to midnight on the 1st can process before or after the reset, so it may count against the old month or the new one. If the boundary must be exact, compute the running total in your backend and send it on each event (for example event.monthly_spend), gate the threshold on that value instead of the counter, and skip the cron reset entirely.

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. You can create the rule weeks in advance. Events outside the window skip the rule automatically.
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.
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.

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.
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.
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 check snapshot + 1 to detect the milestone at the right moment.
How it works: The tracking rule increments 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.
For a one-time milestone (e.g., 100th purchase only), replace the modulo with an exact check:

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

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.
How faithfully any of these reverses “the refund” depends on how you structured events: an event-level reversal is exact when one purchase is one event. Whether a refund should also touch state, like decrementing a visit counter, is always your policy; no reversal changes state automatically.

Reverse the original event

Find the original event by its idempotency key, then reverse it:
Omit 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.
How it works: This uses the same set-local 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.
"allow_negative": true on the DEBIT is the standard way to claw back cashback the participant has already spent: the balance goes negative and future earning repays the shortfall. Omit it only if you would rather absorb the loss than carry negative balances; without it, a DEBIT that exceeds the available balance fails and the reversal event errors.
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:
The 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:
Compute the clawback in your backend: for a full refund, the recorded amount; for a partial refund, your policy (commonly proportional to the refunded fraction). Then send the reversal event with the exact amount:
One rule handles every rate and award, with no cascade to maintain:
With this pattern, track cumulative clawbacks per original order in your backend and cap them at the recorded credit. A refund event is not linked to the purchase it corrects, so nothing stops repeated partial refunds from clawing back more than was earned. If you want Scrip to enforce that cap, use the reversal endpoint.
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 a multiplier 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:
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. Create them in the order shown; Scrip numbers them as they are added, so the tier rule’s stop_after_match can block the catch-all.
How it works: The 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.