Skip to main content
Scrip uses CEL (Common Expression Language) for rule conditions and dynamic amount calculations. You write conditions to control when a rule fires and expressions to compute how much to credit, debit, or hold. Conditions return a boolean. Amount expressions return a number.
// Condition (returns boolean)
event.type == "purchase" && event.amount > 50.0

// Amount (returns number)
event.amount * 0.10

Operators

OperatorExample
Equalityevent.type == "purchase"
Inequalityevent.type != "refund"
Comparisonevent.amount > 100.0, event.amount >= 50.0
Logical ANDevent.type == "purchase" && event.amount > 0
Logical ORevent.category == "dining" || event.category == "travel"
Negation!("vip" in participant.tags)
Containment"vip" in participant.tags
List membershipevent.category in ["dining", "travel"]
Ternaryevent.tier == "gold" ? 100.0 : 50.0
Use == for equality, not =. Using = is a syntax error.

Available Variables

When a rule evaluates, these variables are available in your conditions and amount expressions.

event

The event_data payload from your API request. Fields depend on what you send, so every expression you write is specific to your event schema.
event.type == "purchase"
event.amount > 50.0
event.category in ["dining", "travel", "entertainment"]
event.items.size() > 0
event.items[0].sku == "ABC123"
Numeric values in your payload work directly in arithmetic:
event.amount * 0.10       // works, amount is already a number
event.quantity * 5.0       // works
If a numeric value is sent as a string in your payload, cast it with double():
double(event.amount) * 0.10  // only needed if amount is "49.99" not 49.99

Optional fields

Not every event has the same shape. If a condition references a field that isn’t in the payload, the entire condition evaluates to false and the rule is skipped. Use has() to check for a field before accessing it:
has(event.referrer_id) && event.referrer_id != ""
has(event.coupon_code) && event.coupon_code == "SUMMER25"

participant

The participant’s current state at the time of evaluation. Most rules use this to check tags, counters, or attributes before granting rewards.
participant.tags        // list of strings
participant.counters    // map of string to number
participant.attributes  // map of string to string
participant.tiers       // map of string to tier state

Tags

"vip" in participant.tags
!("welcome_bonus" in participant.tags)

Counters

Use get() to read counter values with a default. If the counter hasn’t been set, get() returns the default instead of failing.
get(participant.counters, "purchase_count", 0.0) >= 10.0
get(participant.counters, "lifetime_spend", 0.0) > 1000.0

Attributes

get(participant.attributes, "region", "") == "US"
get(participant.attributes, "plan", "free") in ["pro", "enterprise"]

program

The program’s current state. Supports the same tags, counters, and attributes as participants. Use this for global logic that isn’t tied to any one participant, like a program-wide redemption cap.
program.id                                              // UUID string
"milestone_reached" in program.tags
get(program.counters, "total_issued", 0.0) < 100000.0
get(program.attributes, "region", "") == "US"

groups

List of groups the participant belongs to. Each entry has id, name, tags, counters, attributes, and tiers.
groups.size() > 0
groups.size() > 0 && groups[0].name == "VIP Customers"
groups.size() > 0 && get(groups[0].counters, "team_purchases", 0.0) < 100.0
A participant can belong to multiple groups, so groups is a list. If your program uses one group per participant, groups[0] works as a shorthand.

now

The event’s event_timestamp as a CEL timestamp. Scrip uses the event timestamp rather than wall-clock time so that evaluation stays deterministic across retries and reprocessing.
now > timestamp("2025-01-01T00:00:00Z")
now < timestamp("2025-12-31T23:59:59Z")

Quick Reference

DataPattern
Event field (number)event.amount > 50.0
Event field (string)event.type == "purchase"
Optional event fieldhas(event.referrer_id) && event.referrer_id != ""
Tag check"vip" in participant.tags
Counterget(participant.counters, "key", 0.0) >= 10.0
Attributeget(participant.attributes, "key", "") == "US"
Timestamp comparisonnow > timestamp("2025-01-01T00:00:00Z")
Program counterget(program.counters, "key", 0.0) < 1000.0

Helper Functions

Scrip adds a few helpers on top of standard CEL for safe map access, rounding, and time calculations.

get(map, key, default)

Reads a value from a map, returning default if the key doesn’t exist. This is the most common helper in practice. Always use get() for counters and attributes, since accessing a missing key directly causes the entire condition to evaluate to false, which can prevent the other side of an || expression from evaluating.
get(participant.counters, "spend", 0.0) > 100.0
get(participant.attributes, "plan", "") in ["pro", "enterprise"]

round(value, scale)

Rounds a number to the specified decimal places.
round(event.amount * 0.03, 2)   // 49.99 * 0.03 = 1.4997 → 1.50
round(event.amount, 0)          // 49.99 → 50.0

duration_hours(duration)

Converts a CEL duration to hours. You get a duration by subtracting two timestamps. Divide by 24 for days, multiply by 60 for minutes.
// More than 30 days since activation
duration_hours(now - timestamp(event.activation_date)) / 24.0 > 30.0

// Less than 1 hour old
duration_hours(now - timestamp(event.created_at)) < 1.0

has(field)

Returns true if a field exists on an object. Use this for event fields that only appear on some event types.
// Only match if the event includes a coupon code
has(event.coupon_code) && event.coupon_code == "SUMMER25"

timestamp(string)

Parses an RFC 3339 string (e.g., "2025-06-01T00:00:00Z") into a CEL timestamp for comparison and arithmetic.
timestamp(event.purchase_date) >= timestamp("2025-06-01T00:00:00Z")
now - timestamp(event.activation_date)  // produces a duration

Extensions

Scrip enables the CEL math and sets extension libraries.

Math

Min/max capping, absolute value, and rounding. Useful for bounding dynamic amounts to a floor or ceiling.
math.least(event.amount * 0.1, 50.0)     // cap: 10% of amount, max 50
math.greatest(event.amount * 0.05, 10.0)  // floor: 5% of amount, min 10
math.abs(-42.0)                            // 42.0
math.ceil(3.2)                             // 4.0
math.floor(3.8)                            // 3.0

Sets

Membership checks across lists for tag-based conditions.
sets.contains(participant.tags, ["vip", "gold"])     // participant has ALL of these tags
sets.intersects(participant.tags, ["vip", "silver"])  // participant has ANY of these tags

Dynamic Amounts

Rule actions that move assets (CREDIT, DEBIT, HOLD, etc.) have an amount field. You can set it to a static number or a CEL expression. If the value parses as a number, it’s used directly. Otherwise, Scrip evaluates it as a CEL expression against the event data.
{
  "name": "Cashback Reward",
  "condition": "event.type == 'purchase'",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "...",
      "amount": "round(event.amount * 0.03, 2)"
    }
  ]
}
In this example, a $105 purchase evaluates round(105.0 * 0.03, 2) and credits 3.15 points.
Amount valueBehavior
"100"Static. Credits exactly 100.
"event.amount * 0.10"10% of the event amount
"round(event.amount * 0.03, 2)"3% of the event amount, rounded to 2 decimal places
"math.least(event.amount * 0.1, 50.0)"10% of the event amount, capped at 50
"math.greatest(event.amount * 0.05, 10.0)"5% of the event amount, minimum 10
"event.tier == 'gold' ? 100.0 : 50.0"100 for gold tier, 50 for everyone else
The result is rounded to the asset’s configured scale before being applied.

Common Patterns

These patterns come up in most programs. Each one is a complete condition you can use directly or adapt.

One-time gate

Grant a reward once, then tag the participant to prevent re-triggering.
event.type == "signup" && !("welcome_bonus" in participant.tags)

Threshold crossing

Detect the event that pushes a counter past a target. Counters reflect pre-event state, so check the current value plus the incoming amount.
get(participant.counters, "spend", 0.0) < 1000.0 &&
(get(participant.counters, "spend", 0.0) + event.amount) >= 1000.0

Milestone (Nth occurrence)

Fire on exactly the Nth event of a type. The counter hasn’t incremented yet, so add 1 to get the count including this event.
event.type == "ride_complete" &&
(get(participant.counters, "ride_count", 0.0) + 1.0) == 10.0

Date range

Restrict a rule to events within a specific window.
timestamp(event.purchase_date) >= timestamp("2025-06-01T00:00:00Z") &&
timestamp(event.purchase_date) < timestamp("2025-07-01T00:00:00Z")

Days since a date

Check elapsed time between two dates. duration_hours returns hours, so divide by 24 for days.
duration_hours(now - timestamp(event.activation_date)) / 24.0 <= 30.0

Capped bonus

Reward a percentage of the event amount but cap the maximum payout.
math.least(event.amount * 0.1, 50.0)

Category-specific

Only match events in certain categories.
event.type == "purchase" && event.category in ["dining", "travel"]

Segment-specific

Use tags to restrict rules to a participant segment.
event.type == "purchase" && "vip" in participant.tags

Exclude already-rewarded

Prevent a participant from claiming a promotion more than once.
event.type == "purchase" && !("promo_claimed" in participant.tags)

Common Gotchas

MistakeFix
event.type = "purchase"Use == not =
event.amount when amount is a stringUse double(event.amount)
Referencing event.field that may not existCondition returns false. Use has(event.field) if the rule should still match.
Checking counter updated earlier in same eventCounters reflect pre-event state. Use the threshold crossing pattern.