Rule structure
${{ }} wrapper marks a value as a CEL expression (see Rule Actions: Dynamic action fields).
Conditions
Conditions are written in CEL (Common Expression Language), a simple expression language for evaluating boolean conditions against event data and participant state.Rule sets
Every rule belongs to a rule set. Every program starts with an immutable-keydefault set, and rule writes that omit rule_set_id go there.
To place a rule when creating it, use position (first, last, or before/after with a reference_id). Omit it to append.
stop_after_match: true skips later rules in this rule’s set after a successful match. Other sets continue. A budget skip does not trigger the stop. Rule sets covers how that stop interacts with set order.
See Managing rule configuration for consistent reads, moves, preview and apply, history, and rollback.
Time-windowed rules
Useactive_from and active_to to schedule rules for specific periods. Outside the window, the rule is skipped during evaluation.
Time windows are checked against the event’s event_timestamp, the same value CEL sees as now, not the wall-clock time at processing. active_from is inclusive and active_to is exclusive. A matching rule outside its window is recorded as a skipped evaluation with reason OUTSIDE_TIME_WINDOW.
- Delayed processing, retries, and replays make the same decision because they read the same timestamp.
- Event timestamps are supplied by the caller, so a backdated event (a historical import, for example) can land inside a window that has already closed on the calendar and still fire the rule.
event_timestamp fire both rules and earn 2x total. Events stamped January 1st or later skip the promo rule (active_to is exclusive) and the base rule continues on its own. Campaigns covers how windowed rules stack on or override the base rate, and how to cap and test them.
Budgets
Budgets cap how much of an asset a rule can issue. When a credit would go over a budget, the rule still matches but all of its actions are skipped for that evaluation. Each budget has ascope that sets who the limit applies to:
A rule can have one budget per asset and scope, so one asset can carry a program-wide pool and a per-participant cap at the same time, each with its own
limit and schedule. Budgets are defined inline on the rule:
A budget caps an amount of an asset. To cap how many times a rule pays, such as a bonus on each participant’s first three purchases a day, gate the rule on a counter instead:
Schedule types
A lifetime cap omitsschedule_type:
CRON resets on a calendar schedule evaluated in UTC, regardless of when the rule was created. Expressions cannot include a timezone. A PARTICIPANT budget resets every participant on the same clock. This one resets everyone’s cap at midnight UTC:
INTERVAL resets after a fixed duration. A program-wide budget’s window starts when the budget is saved and restarts after each reset. A PARTICIPANT budget gives each participant their own window, starting at their first credit from the rule. After a participant’s window ends, their next credit starts a new one:
Who a per-participant budget counts
APARTICIPANT budget counts each CREDIT of its asset against the participant who receives it:
A
PARTICIPANT budget needs at least one CREDIT of its asset to a participant in the same rule. Without one, saving the rule, previewing a configuration, or simulating a draft configuration returns 400. The error names the asset and has details.reason: "participant_budget_unreachable".
details.field identifies the budget to fix:
An unknown
scope or a duplicate budget for the same asset_id and scope also returns 400.
When a budget runs out
Each credit is checked against the rule’s budgets on that asset before it is issued. A credit can bring the total exactly to the limit. Scrip never reduces a credit to fit the remaining budget. If a credit would take the rule’s total (PROGRAM) or its recipient’s total (PARTICIPANT) past the limit, all of the rule’s actions roll back. Evaluation continues with the next rule in the set and then with later sets. A budget skip does not trigger stop_after_match.
Events cannot exceed the budget, even when different participants trigger the rule at the same time.
The skip is recorded in the event’s rule_evaluations, returned by GET /v1/events/{id}, with status SKIPPED and a reason:
resets clause for a lifetime budget, or when the recipient has no open window, such as a first credit larger than the limit. When a rule has both scopes on one asset, a skip by either budget rolls back the whole rule, so the program-wide total counts only credits that were issued.
The response for any rule includes each budget’s scope. Program-wide budgets also report consumed and, when scheduled, next_reset_at. Per-participant budgets omit both because usage differs for each participant:
Resetting a budget
Scheduled budgets reset on their own. To reset a program-wide budget early, for example after erroneous events used it up:consumed back to zero and advances next_reset_at to the next scheduled reset. For lifetime budgets, the consumed amount resets but no future reset is scheduled.
The endpoint resets only the rule’s program-wide budget for that asset. It returns 404 when the asset has only a per-participant budget on the rule. Per-participant usage has no manual reset. To let a capped participant earn more, raise the PARTICIPANT budget’s limit, which applies to every participant, or credit that participant with POST /v1/participants/{id}/balances/adjust, which does not count against rule budgets.
Updating budgets
Budgets are updated as part of the rule. Include the fullbudgets array in your update request and it replaces the previous one. Omitting the field leaves budgets unchanged.
limit keeps its current usage and applies immediately. Changing its schedule restarts usage: the program-wide total, or every participant’s usage for a PARTICIPANT budget.
To remove all budgets from a rule, send an empty array:
State snapshot evaluation
When an event is processed, Scrip snapshots participant state, program state, and group state at the start. All rule conditions and dynamic action expressions within that event evaluate against this snapshot, not the live database. That means if Rule A increments a counter, adds a tag, sets an attribute, or assigns a tier, Rule B still sees the original value even though it evaluates after Rule A. This applies to counters, tags, attributes, tiers, program state, and group state. It also applies to dynamic action expressions:CREDIT / DEBIT / HOLD / RELEASE / FORFEIT amounts, COUNTER values, SET_ATTRIBUTE values, reference_id expressions, and dynamic target expressions all use the same event-start snapshot. The snapshot is shared across every rule set. Scrip saves state changes in rule-set and rule order. Later events see those changes; later conditions and expressions in the same event keep using the snapshot.
For shared program and group state, Scrip can refresh a key if another request changed it before a state action updates it. The snapshot then uses the value found before this event’s update. See Shared program and group state.
Example
spend is 900 and event.amount is 200:
- Rule 1 executes, updating the counter to 1100 in the database
- Rule 2 evaluates against the snapshot value (900), so it does not match
TAG action that adds vip, a Rule B condition that checks "vip" in participant.tags still evaluates against the pre-event tag set. The tag is visible on the next event.
Threshold crossing pattern
To detect when a counter crosses a threshold during an event, check the pre-event value plus the event amount:SCHEDULE_EVENT).
See CEL Expressions for more patterns including milestones, date ranges, and capped bonuses.
Rule status
Updating rules under live traffic
Scrip evaluates each event against the rule definitions that are live when processing starts, not when you submitted the event. This matters when you change a rule (its condition, actions, amount, containing set, order, or status) while events are still in flight:- Events already processed keep the result from the previous definition.
- Events not yet processed use the new definition.
- A batch that spans your change can split across both versions.
rule_set_id and a rule_history_id that links to the exact rule version active when the event was evaluated:
rule_configuration_version on moves, preview, and rollback. A stale version returns 409 rule_configuration_version_conflict. See Managing rule configuration.
If you need every event in a batch to use a single definition, stop submitting affected events and wait until in-flight events finish before you make the change.
Validation and simulation
Save-time validation
Rule create and update validate every action expression, not only the condition. Template syntax errors, CEL compile errors, and references to unknown participant fields, tier keys, or asset symbols are rejected with a400 that names the field path (for example, actions[0].amount). A static amount that is zero or negative is also rejected.
Expiration and maturity dates on a CREDIT require a LOT-mode asset. A SIMPLE-mode asset rejects either field, regardless of its value.
Saving the rule, previewing a configuration, or simulating a draft returns 400 with details.reason: "invalid_inventory_mode". details.field identifies the first invalid field, such as actions[0].expires_at. In a configuration preview, it also includes the rule’s location: rule_sets[i].rules[j].actions[N]. Validate with program_id and the draft actions reports the same message with valid: false.
The condition field is always CEL and never uses the ${{ }} wrapper. A condition containing ${{ is rejected at save with condition is always a CEL expression; write it without the ${{ }} wrapper, so the GitHub Actions habit of wrapping expressions fails loudly instead of as a cryptic parse error. The same check applies to automation participant_filter and guard_condition.
Create, update, and validate responses include a non-blocking warnings array. Warnings cover both the condition and action expressions, and include deprecated_alias when an expression uses the deprecated state variable: CEL variable "state" is deprecated; use "participant" instead. Rules with warnings still save and evaluate.
Validate a condition
Check a condition, and optionally draft actions, before creating a rule:program_id to check tier keys and asset symbols against that program’s configuration and receive vocabulary warnings, and an optional actions array to run the same save-time checks on draft actions. event.* field references are not verified because events are schemaless: a misspelled event field saves fine and then silently never matches. To see which event fields a program’s rules currently depend on, call the event references endpoint. To see which fields appear in recent traffic, call List observed event shapes.
Simulate a rule
Test a rule against sample data without persisting any changes:participant_state field is optional. The response includes whether the condition matched and, for each action, the evaluated result (resolved amounts, projected counter values, etc.).
Asset action results also show where the value would go, with the fields that apply to each action: from_bucket, to_bucket, and the resolved reference_id. For example, a CREDIT result has to_bucket but no from_bucket, and a DEBIT result has only from_bucket. Simulate a rule lists the fields for each action type. A CREDIT result shows expires_at and matures_at as full timestamps, including fractional seconds. A duration such as 30d counts from the time of your request, so a live run of the rule computes a later timestamp.
This result is for a settlement CREDIT with "matures_at": "30d", so the rewards land in DEFERRED:
"settles_reference": true marks the CREDIT as a settlement for auth_001. It does not mean pending rewards exist for auth_001. When the CREDIT runs, it settles the pending rewards for auth_001. If there are none, it credits the full amount as new rewards.
Simulation uses participant balances to evaluate the condition and any action expression that reads them. The actions do not run against the ledger or look at lots, so a result does not show whether pending rewards exist or which lots an action would use. A rule configuration simulation shows that.
To simulate against a real participant’s current state instead of mocking it, pass participant_id (mutually exclusive with participant_state):
missing_participant_context warning and the condition evaluates against empty defaults.
Counter values in participant_state may be JSON numbers or decimal strings (the format the participant state endpoints return), so you can paste a counters map from a real state read directly into a simulation request.
Simulation does not execute SCHEDULE_EVENT or BROADCAST actions, but it does resolve their event_data templates: the action result shows the resolved event_data the action would emit, or an error when a template fails. Any action whose target names a recipient also shows who it would reach in resolved_target. See Targeting.
Simulation enforces the same runtime constraints as live execution: asset action amounts must resolve to positive values (zero or negative amounts fail in the action result), and event numbers or amount results beyond the 2^53 precision boundary are rejected. A clean simulation is a faithful pre-flight check for that rule. It does not prove ordering or stop behavior across multiple rules and sets. See Testing multiple rule sets.
Simulate a draft rule
To test a rule before saving it, usePOST /v1/rules/simulate with a program_id and a rule object (name, condition, actions) plus the same event, participant_id, or participant_state fields. The draft is validated exactly like rule create, but nothing is persisted.