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
| Operator | Example |
|---|
| Equality | event.type == "purchase" |
| Inequality | event.type != "refund" |
| Comparison | event.amount > 100.0, event.amount >= 50.0 |
| Logical AND | event.type == "purchase" && event.amount > 0 |
| Logical OR | event.category == "dining" || event.category == "travel" |
| Negation | !("vip" in participant.tags) |
| Containment | "vip" in participant.tags |
| List membership | event.category in ["dining", "travel"] |
| Ternary | event.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
"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
| Data | Pattern |
|---|
| Event field (number) | event.amount > 50.0 |
| Event field (string) | event.type == "purchase" |
| Optional event field | has(event.referrer_id) && event.referrer_id != "" |
| Tag check | "vip" in participant.tags |
| Counter | get(participant.counters, "key", 0.0) >= 10.0 |
| Attribute | get(participant.attributes, "key", "") == "US" |
| Timestamp comparison | now > timestamp("2025-01-01T00:00:00Z") |
| Program counter | get(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 value | Behavior |
|---|
"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
| Mistake | Fix |
|---|
event.type = "purchase" | Use == not = |
event.amount when amount is a string | Use double(event.amount) |
Referencing event.field that may not exist | Condition returns false. Use has(event.field) if the rule should still match. |
| Checking counter updated earlier in same event | Counters reflect pre-event state. Use the threshold crossing pattern. |