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"}
]
}
| 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
get(participant.counters, "purchase_count", 0.0) >= 10.0
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 current wall-clock time, not the event’s event_timestamp. This means a time-windowed rule won’t fire for historical imports that fall within the window if the window has already passed.
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"}
]
}
During December, both rules fire on every purchase and the participant earns 2x total. After January 1st, the promo rule is skipped 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.
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. |
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., "720h" for 30 days). Timer starts from when the budget is created. |
Schedule Types
The schedule_type controls whether and how a budget resets its consumed amount. A budget without a schedule type is a lifetime cap that never resets on its own. Adding a schedule type turns the budget into a recurring allowance that resets automatically, like a monthly spending limit or a rolling 30-day window.
Lifetime (no schedule_type): The budget applies for the lifetime of the rule. Once exhausted, it stays exhausted until manually reset.
{"asset_id": "...", "limit": "50000"}
Cron: Resets on a calendar-aligned schedule. All budget periods are 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. The timer starts when the budget is created and restarts after each reset.
{"asset_id": "...", "limit": "500", "schedule_type": "INTERVAL", "interval": "720h"}
Consumption
Each time the rule fires a credit action, the amount is checked against the budget. If the consumed amount plus the new amount would exceed the limit, the action is suppressed. Consumption is atomic, so concurrent events can’t overspend.
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": []}
Counter Evaluation Behavior
When an event is processed, Scrip snapshots participant.counters at the start. All rule conditions within that event evaluate against this snapshot, not the live database. That means if Rule A increments a counter, Rule B still sees the original value even though it evaluates after Rule A.
Example
Rule 1 (order: 100): COUNTER "spend" += event.amount
Rule 2 (order: 200): condition: get(participant.counters, "spend", 0.0) >= 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
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
get(participant.counters, "spend", 0.0) < 1000.0 &&
(get(participant.counters, "spend", 0.0) + event.amount) >= 1000.0
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. |
Validation and Simulation
Validate a condition
Check CEL syntax before creating a rule:
POST /v1/rules/validate
{"condition": "event.type == \"purchase\" && event.amount > 0"}
Returns whether the expression is syntactically valid. Field references like event.amount are not verified against a schema.
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.).