Skip to main content
Scrip uses CEL (Common Expression Language) for rule conditions and dynamic action fields. You write conditions to control when a rule fires, and you wrap action-field expressions in ${{ ... }} to compute amounts, counter values, attributes, reference IDs, or dynamic targets.
// Condition (returns boolean)
event.type == "purchase" && event.amount > 50.0
{"type": "CREDIT", "asset_id": "...", "amount": "${{ 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 normal arithmetic and comparisons:
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
Modulo (%) is the exception because CEL requires integers; cast event numbers with int() first.

Numeric magnitude limit

CEL evaluates JSON numbers as 64-bit floats, which are only exact up to a magnitude of 2^53 (9,007,199,254,740,992). To protect ledger precision, event ingestion and rule simulation reject any JSON number in event_data above that magnitude with 400 amount_precision_exceeded. If you need to carry larger values, send them as strings; string values pass through exactly. The same boundary applies at execution time: an amount expression whose result exceeds 2^53 fails the action rather than posting an imprecise amount.

Optional fields

Not every event has the same shape. If a condition references a field that isn’t in the payload, the condition evaluates to false, the rule does not match, and no rule-evaluation row is recorded for that rule. 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"
If a matched rule’s action expression reads a missing field, the behavior is different: the action fails and the event is marked FAILED.

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
participant.balances    // map of string to available balance by asset symbol

// Dot-access shorthand (recommended): safe defaults, no get() needed
participant.counter.<name>    // number, defaults to 0 if unset
participant.tag.<name>        // bool,   defaults to false if unset
participant.attribute.<name>  // string, defaults to null if unset
participant.balance.<symbol>  // number for program-linked assets

Identity

Participant identity fields are available alongside state:
participant.id           // Scrip participant UUID (string)
participant.external_id  // your application's user ID (string)
participant.status       // "ACTIVE", "SUSPENDED", or "CLOSED"
participant.enrolled_at  // CEL timestamp of program enrollment
participant.created_at   // CEL timestamp of participant creation
enrolled_at and created_at are CEL timestamps, so date math works directly without timestamp():
// Within 30 days of enrollment
duration_days(now - participant.enrolled_at) <= 30.0
For program-scoped events with no participant, identity values are absent and a condition that reads them is a clean non-match.

Tags

participant.tag.vip                 // shorthand: true if present, false if not
"vip" in participant.tags           // membership check (equivalent for presence)
!("welcome_bonus" in participant.tags)
"x" in participant.tag (and "x" in participant.counter) reflects whether a key is actually set, while participant.tag.<name> returns the default (false / 0) for unset keys, and has(participant.counter.x) is always true. Use in when you need a real presence check.

Counters

Use the participant.counter.<name> shorthand. A missing counter defaults to 0, so you don’t need get():
participant.counter.purchase_count >= 10
participant.counter.lifetime_spend > 1000

// Equivalent explicit form (still supported):
get(participant.counters, "purchase_count", 0.0) >= 10.0
Counter keys that aren’t valid identifiers (hyphens, leading digits, spaces) need bracket access: participant.counter["q1-2026"]. Counter values are always evaluated numerically, even when the source carried them as decimal strings (the wire format the participant state endpoints return). You can copy a counters map from a state read into a simulation request as-is, with no casting needed.

Attributes

A missing attribute via participant.attribute.<name> is null (no error):
participant.attribute.region == "US"
participant.attribute.plan in ["pro", "enterprise"]

// Equivalent explicit form (still supported):
get(participant.attributes, "region", "") == "US"

Balances

Use participant.balance.<symbol> to read the participant’s available ledger balance for a program-linked asset:
participant.balance.credits < 5000.0
participant.balance.credits - double(event.cost) < 5000.0
Every asset linked to the program is loaded with a real value. A linked but unfunded asset reads as 0. An unknown symbol is strict: in a condition it becomes a clean non-match, and in an action expression it fails the action. Even get(participant.balances, "unknown", 0.0) does not substitute the default for an unknown symbol. Balances are snapshotted at event start, like counters. Actions from earlier rules in the same event are not visible to later rule conditions, so use the projected-balance form above when you need to detect a same-event crossing. Balance references are also validated when you save a rule. A symbol not linked to the rule’s program is rejected with a 400 such as unknown asset symbol "gems" (linked to program: credits, points). In automation participant_filter and guard_condition expressions, balance references are rejected at save entirely.

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                                       // the program's public UUID (string)
program.name                                     // program display name
"milestone_reached" in program.tags
program.counter.total_issued < 100000            // shorthand, defaults to 0
program.attribute.region == "US"                 // shorthand, defaults to null

// Explicit form (still supported):
get(program.counters, "total_issued", 0.0) < 100000.0

groups

List of groups the participant belongs to. Each entry has id, name, tags, counters, attributes, and tiers, and supports the same counter/tag/attribute shorthand.
groups.size() > 0
groups.exists(g, g.name == "VIP Customers")
groups.exists(g, g.counter.team_purchases < 100)   // shorthand
A participant can belong to multiple groups, so prefer groups.exists(g, ...) over assuming a specific list position.

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 checkparticipant.tag.vip or "vip" in participant.tags
Counterparticipant.counter.purchase_count >= 10
Attributeparticipant.attribute.region == "US"
Balanceparticipant.balance.credits < 5000.0
Participant statusparticipant.status == "ACTIVE"
Days since enrollmentduration_days(now - participant.enrolled_at) <= 30.0
Group membershipgroups.exists(g, g.name == "VIP Customers")
Timestamp comparisonnow > timestamp("2025-01-01T00:00:00Z")
Program counterprogram.counter.total_issued < 1000

Helper Functions

Scrip adds get(), round(), and the duration_* helpers on top of standard CEL for safe map access, rounding, and time calculations. has() and timestamp() are standard CEL functions, listed here because most rules use them.

get(map, key, default)

Reads a value from a map, returning default if the key doesn’t exist. For counters, tags, and attributes, prefer the dot-access shorthand (participant.counter/tag/attribute.<name>), which bakes in safe defaults. Tiers have no shorthand: read a known tier with participant.tiers.<key>.<field>, and use get() with a default object when the tier may be unset (so the field access stays safe).
participant.tiers.loyalty.level == "gold"                        // known tier, dot-access
get(participant.tiers, "status", {"level": ""}).level == "gold"  // safe if the tier may be unset
get(participant.counters, "spend", 5.0) > 100.0                  // counter with a custom default
Don’t index the plural maps directly for a key that may be missing (participant.counters["spend"]); use the shorthand or get(), and see Optional fields for missing-key behavior.

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. Multiply by 60 for minutes.
// Less than 1 hour old
duration_hours(now - timestamp(event.created_at)) < 1.0

duration_days(duration)

Converts a CEL duration to days.
// More than 30 days since activation
duration_days(now - timestamp(event.activation_date)) > 30.0

duration_weeks(duration)

Converts a CEL duration to weeks.
// More than 2 weeks since the last activity
duration_weeks(now - timestamp(event.last_activity_at)) > 2.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, sets, and strings 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

Strings

The strings extension (v4) adds member-style functions on any string value: charAt, indexOf, lastIndexOf, lowerAscii, upperAscii, replace, split, substring, trim, join, format, and reverse, plus the global strings.quote(s).
// Case-insensitive coupon match
event.code.lowerAscii() == "promo"

Expressions in action fields

Action string fields use one explicit convention:
A value is CEL when it is wrapped in ${{ ... }}. Anything else is a literal.
{
  "name": "Cashback Reward",
  "condition": "event.type == 'purchase'",
  "actions": [
    {
      "type": "CREDIT",
      "asset_id": "...",
      "amount": "${{ round(event.amount * 0.03, 2) }}",
      "reference_id": "purchase-${{ event.order_id }}"
    }
  ]
}
In this example, a $105 purchase evaluates round(105.0 * 0.03, 2) and credits 3.15 points. Fields that accept ${{ }} include amount, COUNTER value, SET_ATTRIBUTE value, reference_id, target.external_id, and target.participant_id.
ValueBehavior
"100"Literal static amount
"north_america"Literal string
"${{ event.amount * 0.10 }}"Whole-string template. The result keeps the expression type.
"${{ round(event.amount * 0.03, 2) }}"Numeric amount, rounded to 2 decimal places
"purchase-${{ event.order_id }}"Mixed template. Expression results are converted to strings and concatenated.
"${{ event.tier == 'gold' ? 100.0 : 50.0 }}"Conditional amount
Amount results are rounded to the asset’s configured scale before being applied.
Legacy unmarked expressions such as "amount": "event.amount * 0.10" still work for stored-rule compatibility, but they are deprecated. Use ${{ ... }} in new rules. For string literals that contain operator characters, wrap a quoted string expression, such as "value": "${{ 'us-west' }}".

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.
participant.counter.spend < 1000.0 &&
(participant.counter.spend + 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" &&
(participant.counter.ride_count + 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 with duration_days.
duration_days(now - timestamp(event.activation_date)) <= 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.
event.qty % 2 == 0Cast event numbers before modulo: int(event.qty) % 2 == 0
Indexing a missing counter/attribute key directly (participant.counters["x"])Use the shorthand participant.counter.x (defaults to 0) or get(); raw indexing is a quiet non-match in conditions and a failure in action expressions.
Checking state updated earlier in the same eventConditions use the event-start snapshot. For counters, use the threshold crossing or Nth occurrence pattern. For tags, attributes, or tiers, put the dependent action in the same rule or trigger it on a later event. See State Snapshot Evaluation Behavior.