Skip to main content
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"}
  ]
}
FieldRequiredDescription
program_idYesWhich program this rule belongs to
nameYesDisplay name (1-255 characters)
conditionYesA CEL expression that must return true for actions to fire
actionsYesWhat to do when the condition matches (see Rule Actions)
descriptionNoAdditional context (max 1000 characters)
orderNoEvaluation order. Lower values evaluate first. Auto-assigned if omitted.
stop_after_matchNoIf true, skip all subsequent rules when this one matches
active_from / active_toNoTime window for the rule (RFC 3339)
budgetsNoAsset-level spending caps for this rule. See Budgets.
statusNoACTIVE (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:
OrderUse Case
100VIP overrides, special cases
500Category-specific bonuses
1000Default rules
2000Fallback / 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.
FieldRequiredDescription
asset_idYesThe asset this budget constrains. Must be linked to the rule’s program.
limitYesMaximum amount that can be issued before the budget is exhausted
schedule_typeNoCRON or INTERVAL. Omit for a lifetime budget that never resets.
cron_expressionWhen schedule_type is CRONStandard cron expression for the reset schedule (e.g., "0 0 1 * *" for monthly)
intervalWhen schedule_type is INTERVALDuration 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

StatusBehavior
ACTIVEEvaluates on every event
SUSPENDEDDisabled. Skipped during evaluation. Can be reactivated.
ARCHIVEDSoft-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.).