Rules are the core logic layer in Scrip. Each rule is a condition/action pair: if the condition matches, the actions execute.
Rule Structure
{
"name": "Purchase Reward",
"program_id": "program-uuid",
"condition": "event.type == \"purchase\" && event.amount > 0",
"order": 1000,
"actions": [
{"type": "CREDIT", "asset_id": "points-uuid", "amount": "${{ event.amount * 10 }}"}
]
}
In action fields, the ${{ }} wrapper marks a value as a CEL expression (see Rule Actions: Dynamic action fields).
| Field | Required | Description |
|---|
program_id | Yes | Which program this rule belongs to |
name | Yes | Display name (1-255 characters) |
condition | Yes | A CEL expression that must return true for actions to fire |
actions | Yes | What to do when the condition matches (see Rule Actions) |
description | No | Additional context (max 1000 characters) |
order | No | Evaluation order. Lower values evaluate first. Auto-assigned if omitted. |
stop_after_match | No | If true, skip all subsequent rules when this one matches |
active_from / active_to | No | Time window for the rule (RFC 3339) |
budgets | No | Asset-level spending caps for this rule. See Budgets. |
status | No | ACTIVE (default) or SUSPENDED |
Conditions
Conditions are written in CEL (Common Expression Language), a simple expression language for evaluating boolean conditions against event data and participant state.
// Simple event match
event.type == "purchase"
// Combine multiple checks
event.type == "purchase" && event.amount > 100.0 && !("vip" in participant.tags)
// Counter threshold (dot-access shorthand; a missing counter defaults to 0)
participant.counter.purchase_count >= 10
See CEL Expressions for the full reference, including available variables, helper functions, and common patterns.
Evaluation Order
Rules evaluate in order ASC (lower values first). Use order to create priority tiers:
| Order | Use Case |
|---|
| 100 | VIP overrides, special cases |
| 500 | Category-specific bonuses |
| 1000 | Default rules |
| 2000 | Fallback / catch-all rules |
When order is omitted, it’s auto-assigned with a value 10 above the current highest order in the program, leaving gaps for later insertion.
No two active rules in the same program can share the same order value. The API rejects a rule if its order conflicts with an existing active rule.
stop_after_match
When a rule with stop_after_match: true matches and its actions execute, no subsequent rules are evaluated. Use this for mutually exclusive rewards:
{
"name": "VIP Double Points",
"order": 100,
"stop_after_match": true,
"condition": "event.type == 'purchase' && 'vip' in participant.tags",
"actions": [
{"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount * 10 }}"}
]
}
{
"name": "Standard Points",
"order": 200,
"condition": "event.type == 'purchase'",
"actions": [
{"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount * 2 }}"}
]
}
VIPs get 10x (the first rule matches and stops). Everyone else gets 2x (the first rule skips, the second matches).
Time-Windowed Rules
Use active_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.
This is useful for layering a promotional rule on top of an existing base rule. Consider a base rule that always gives 1x points:
{
"name": "Standard Points",
"order": 1000,
"condition": "event.type == 'purchase'",
"actions": [
{"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount }}"}
]
}
To run a holiday promo that gives an additional 1x during December, add a second rule with a time window:
{
"name": "Holiday Bonus Points",
"order": 500,
"active_from": "2025-12-01T00:00:00Z",
"active_to": "2026-01-01T00:00:00Z",
"condition": "event.type == 'purchase'",
"actions": [
{"type": "CREDIT", "asset_id": "...", "amount": "${{ event.amount }}"}
]
}
Purchases with a December 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.
Budgets
Budgets cap how much a rule can issue per asset over a given period. When the budget is exhausted, the rule still matches but all of its actions are skipped for that evaluation. You might use a budget to limit a referral rule to $10,000/month or cap a promotional rule at 50,000 points total.
A budget is a single shared pool, not a per-participant allowance. The limit applies to total issuance for the rule across every participant in the program. A 10000 point budget means the rule issues 10,000 points combined to all participants, not 10,000 per participant. Consumption is tracked per (rule, asset), so each entry in the budgets array is one program-wide cap.
To cap how much an individual participant can earn, don’t use a budget. Instead, track the participant’s earnings in a counter and gate the rule on it. Add a COUNTER action that increments a per-participant counter by the credited amount, then guard the rule with a condition that checks the counter against the cap:
{
"name": "Referral Bonus (500 point lifetime cap per participant)",
"condition": "event.type == 'referral' && participant.counter.referral_points < 500.0",
"actions": [
{"type": "CREDIT", "asset_id": "points-uuid", "amount": "100"},
{"type": "COUNTER", "key": "referral_points", "value": "100"}
]
}
Here a participant stops earning referral points once their referral_points counter reaches 500, regardless of how many other participants the rule has paid out. A budget and a counter cap are independent: combine both to cap per-participant earning and total program spend at the same time.
Budgets are defined inline on the rule as an array of per-asset limits:
{
"name": "Referral Bonus",
"condition": "event.type == 'referral'",
"actions": [
{"type": "CREDIT", "asset_id": "points-uuid", "amount": "100"}
],
"budgets": [
{
"asset_id": "points-uuid",
"limit": "10000",
"schedule_type": "CRON",
"cron_expression": "0 0 1 * *"
}
]
}
This rule credits 100 points per referral, but stops issuing after 10,000 points in a calendar month. On the 1st of each month, the budget resets automatically.
| Field | Required | Description |
|---|
asset_id | Yes | The asset this budget constrains. Must be linked to the rule’s program. |
limit | Yes | Maximum amount that can be issued before the budget is exhausted |
schedule_type | No | CRON or INTERVAL. Omit for a lifetime budget that never resets on its own; once exhausted it stays exhausted until manually reset. |
cron_expression | When schedule_type is CRON | Standard cron expression for the reset schedule (e.g., "0 0 1 * *" for monthly) |
interval | When schedule_type is INTERVAL | Duration between resets (e.g., "30d"). Accepts h, d, and w units. Timer starts from when the budget is created. |
Schedule Types
A lifetime cap omits schedule_type:
{"asset_id": "...", "limit": "50000"}
CRON resets on a calendar-aligned schedule, tied to the same wall-clock times regardless of when the rule was created:
{"asset_id": "...", "limit": "10000", "schedule_type": "CRON", "cron_expression": "0 0 1 * *"}
INTERVAL resets after a fixed duration, timed from budget creation and restarting after each reset:
{"asset_id": "...", "limit": "500", "schedule_type": "INTERVAL", "interval": "30d"}
Consumption
Each time the rule fires a credit action, the amount is checked against the budget and added to a single consumed total shared by all participants. If the consumed amount plus the new amount would exceed the limit, all of the rule’s actions are rolled back and the evaluation is recorded as skipped with reason BUDGET_EXCEEDED. Consumption is atomic, so concurrent events can’t overspend, even when different participants trigger the rule at the same time.
The response for any rule includes the current budget state:
{
"budgets": [
{
"asset_id": "points-uuid",
"limit": "10000.00",
"consumed": "4500.00",
"schedule_type": "CRON",
"cron_expression": "0 0 1 * *",
"next_reset_at": "2026-03-01T00:00:00Z"
}
]
}
Resetting a Budget
Scheduled budgets reset automatically when next_reset_at arrives. You can also reset a budget manually:
POST /v1/rules/{id}/reset-budget?asset_id={asset-uuid}
This sets 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.
Updating Budgets
Budgets are updated as part of the rule. Include the full budgets array in your update request and it replaces the previous one. Omitting the field leaves budgets unchanged.
PATCH /v1/rules/{id}
{
"budgets": [
{"asset_id": "points-uuid", "limit": "20000", "schedule_type": "CRON", "cron_expression": "0 0 1 * *"}
]
}
To remove all budgets from a rule, send an empty array:
PATCH /v1/rules/{id}
{"budgets": []}
State Snapshot Evaluation Behavior
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, and reference_id expressions all use the same event-start snapshot. State actions write durable updates during processing, but those updates become inputs for future events, not for later rules (or later action expressions) in the same event.
Example
Rule 1 (order: 100): COUNTER "spend" += event.amount
Rule 2 (order: 200): condition: participant.counter.spend >= 1000
If 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
The same holds for other state types. If Rule A fires a 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:
// Detect the event that crosses the $1000 threshold
participant.counter.spend < 1000.0 &&
(participant.counter.spend + event.amount) >= 1000.0
For tag, attribute, or tier dependencies within the same event, put the dependent action in the same rule or trigger it on a later event (for example, with SCHEDULE_EVENT).
See CEL Expressions for more patterns including milestones, date ranges, and capped bonuses.
Rule Status
| Status | Behavior |
|---|
ACTIVE | Evaluates on every event |
SUSPENDED | Disabled. Skipped during evaluation. Can be reactivated. |
ARCHIVED | Soft-deleted. Excluded from evaluation and listings unless include_archived=true. |
Updating rules under live traffic
Scrip reads a program’s rule definitions at processing time: the moment a worker picks up the event, not when you submit it. Each queued event is evaluated against whatever the rules say at the instant it is processed.
This matters when you change a rule (its condition, actions, amount, 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, because workers process queued events independently and at slightly different times.
This behavior is intentional and consistent: the newest active definition wins as soon as it goes live. That is what you want for changes like a midnight promotional rate or a fast fraud-response tweak. Scrip does not snapshot the rule as it was when the event was ingested.
To see which version applied to a given event, read its rule evaluations. Each entry in the rule_evaluations array includes a rule_history_id that links to the exact rule version active when the event was evaluated:
If you need every event in a batch to use a single definition, stop submitting affected events and let the queue drain 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 a 400 that names the field path (for example, actions[0].amount). A static amount that is zero or negative is also rejected.
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 legacy 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:
POST /v1/rules/validate
{"condition": "event.type == \"purchase\" && event.amount > 0"}
Returns whether the expression is valid. Pass an optional 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 and check your event sender supplies each one.
Simulate a rule
Test a rule against sample data without persisting any changes:
POST /v1/rules/{id}/simulate
{
"event": {"type": "purchase", "amount": 75.0},
"participant_state": {
"tags": ["vip"],
"counters": {"purchase_count": 9},
"attributes": {"region": "US"}
}
}
The 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.).
To simulate against a real participant’s current state instead of mocking it, pass participant_id (mutually exclusive with participant_state):
POST /v1/rules/{id}/simulate
{
"event": {"type": "purchase", "amount": 75.0},
"participant_id": "550e8400-e29b-41d4-a716-446655440000"
}
This loads the participant’s live tags, counters, attributes, tiers, identity fields, and ledger balances, the same context production evaluation sees, so threshold and balance rules can be tested faithfully. If the rule’s condition reads participant state and you supply neither field, the response includes a non-blocking missing_participant_context warning and the condition evaluates against empty defaults.
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, more than a syntax pass.