ACTIVE rules across every rule set against one event-start state snapshot. Events can also come from automations, which generate events on a schedule or in response to participant state changes.
Sending an event
Rules can read the named recipient as
recipient.id and recipient.external_id.
Naming a recipient does not send rewards to them automatically. To credit them,
set the action’s target to {"participant_id": "${{ recipient.id }}"}.
Actions without a target still apply to the participant who triggered the event.
Timestamps
Every event carries two timestamps:
These serve different roles:
-
event_timestampis the logical clock. It becomes thenowvariable in CEL expressions, so rule conditions that compare against time use the event’s occurrence time, not the current time. This keeps evaluation deterministic across retries and reprocessing. -
created_atis the ingestion clock. Thefromandtoquery parameters on list endpoints filter oncreated_at, notevent_timestamp. This makes incremental polling reliable: you can track “give me everything since my last sync” without missing late-arriving events. To filter by when events actually occurred, use theevent_fromandevent_toparameters instead. Both pairs can be used simultaneously (AND semantics).
event_timestamp is customer-supplied, it can differ from created_at. A batch import might backdate events to last month, or clock skew might push timestamps slightly into the future. Rules evaluate against the current rule definitions regardless of event_timestamp. A backdated event runs against today’s rule definitions, but active_from / active_to windows are checked against the event’s event_timestamp, so a backdated event inside a past window still fires time-windowed rules. See Time-Windowed Rules.
After you send an event
Events process asynchronously. A202 confirms receipt, not that rules have run. Program checks, participant resolution, and rule evaluation happen in the background. Existing participants are enrolled in the target program if they are not already members; see Programs: Enrollment. Validation errors surface via event.failed webhooks.
Rule sets covers evaluation order and stop_after_match. Writing rules covers the event-start snapshot. Updating rules under live traffic covers what happens if you change a rule while events are still processing.
To check processing status:
rule_set_id, and the array follows the historical set and rule order used for that event.
Read-after-write visibility: the ID returned by the
202 is durable, but the event may briefly 404 on both GET /v1/events/{id} and GET /v1/events/by-key (typically well under a second) until Scrip finishes recording it. Poll until the ID resolves. Every accepted submission eventually becomes readable: either as a processed event or as status FAILED with an error_code if it was rejected asynchronously.Execution guarantees
Rules use counters, tags, and balances to decide what participants earn. These guarantees explain what happens when events arrive at the same time.One event at a time per participant
Scrip processes events for a given participant one at a time. The next event waits until the first finishes, then reads the participant’s updated state. This makes counter-based patterns safe under concurrent traffic. If two purchases arrive at the same moment for a participant whosepurchase_count is 9, they cannot both see 9: one commits first, and the other then sees 10. A milestone rule that checks (participant.counter.purchase_count + 1.0) % 10 == 0 pays exactly once, on the event that actually crosses the milestone.
Events for different participants can run at the same time.
This guarantee covers the participant’s own events. Another participant’s event can change their state through a target, and your app can change it through the participant state endpoints. Those changes can happen while an event is running, without rechecking what it already read. For an exact counter milestone, update the counter only through that participant’s own events.
Shared program and group state
Several participants can share a program counter or a group, such as a family account. Their events can read the same value at the same time. To award a shared milestone once, have the event both read and update the counter or tag that controls the reward. Before a state action updates the program or one of the participant’s groups, Scrip waits for other events updating it. It checks whether the key changed since this event read it, accounting for the event’s own earlier updates. This applies toTAG, UNTAG, COUNTER, and SET_ATTRIBUTE.
If the key changed, Scrip checks whether it can continue using the value found before this event’s update:
After three attempts that need to start again, Scrip retries the event later.
A group visit bonus shows the result. A family group’s 10th visit earns the member who makes it 100 points, and two members check in at the same moment. One rule counts the visit on the group, and a later rule pays the milestone:
visits as 9, the event that writes first finds 9, and its bonus pays. The other event’s COUNTER finds 10, so its bonus rule sees 10 and does not match. If both events read 8, the event that writes second finds 9 and pays the 10th visit. Either way the bonus pays exactly once.
A tag claims a one-time reward the same way. This rule pays the first participant to finish a challenge:
!program.tag.challenge_winner is false against that value, so its CREDIT rolls back and the trace records the skip:
FAILED.
Use named keys, such as program.counter.visits, in rules that run before the update. Expressions that read more broadly can cause more retries:
- A computed key (
program.counter[event.key]) or a whole map (size(program.tags)) reads every key of that kind. size(groups),groups.filter(...), or a group used as a whole value reads every group key.programon its own reads every program key.
- Rules that only read. If no action in the event updates the shared key, Scrip does not recheck it. To award a shared reward once, update its counter or set a tag that marks it as claimed in the same event.
- Group tiers. A group tier set by
SET_TIERand read asg.tiersis not checked. - Changes through group state endpoints. These requests can update a group while an event is running. Scrip catches a change saved before the event updates the same key. A change made during that update can be missed. Use rule actions for shared milestones that must pay once.
SHARED_STATE_CHANGED.
Processing order is not guaranteed
Serialized is not the same as ordered. Events for one participant never run at the same time, but Scrip does not promise they run in the order you sent them. Two events ingested moments apart usually process in submission order, but delivery batching and automatic retries can swap them, and an event that fails transiently can retry after later events have already completed.event_timestamp never influences processing order; it only sets the now variable in CEL.
Automation-generated events have no ordering relationship with events you send around the same time. A cron automation that fires at midnight enqueues its events on its own schedule, so a purchase sent near the boundary can process before or after the reset.
Design for this by keeping rules order-tolerant. Accumulating counters, tag guards, and threshold checks written as (snapshot + event.amount) >= threshold reach the same totals regardless of arrival order, though which specific event crosses a threshold can differ. When the outcome genuinely depends on sequence, such as which side of a period boundary a purchase belongs to, compute that fact in your backend and send it on the event instead of deriving it from processing order. See the spend threshold pattern for a worked example.
Failure is all-or-nothing
An event either completes with all of its effects or fails with none of them. Every rule evaluation and action for an event runs in one transaction. If any action fails, the transaction rolls back: aFAILED event has written nothing. No credits, no counter changes, no tags, no partial state to reconcile.
Two cases continue instead of failing. A rule that would exceed its budget rolls back only its own actions, is recorded in rule_evaluations as skipped with reason BUDGET_EXCEEDED (program-wide budget) or PARTICIPANT_BUDGET_EXCEEDED (per-participant budget), and the remaining rules still run. A rule skipped with reason SHARED_STATE_CHANGED is handled the same way; see Shared program and group state.
Because a failed event committed nothing, retrying it is safe. A retry, automatic or manual, re-runs every rule from scratch and cannot double-pay. The retry evaluates against the participant’s state at retry time, not the state when the event first arrived; only now stays pinned to the event’s event_timestamp.
Event lifecycle
Events whose caller or recipient is
SUSPENDED or CLOSED are rejected before any rule runs: the event is accepted (202) and then recorded as a terminal FAILED event with code participant_suspended or participant_closed. See Participants: Inactive participants and events.error_code (when the failure has a classified code, such as participant_suspended or program_inactive) on the event resource and the event.failed webhook payload, so you can branch on failure type without string-matching the error message.
If you have webhook endpoints configured, Scrip sends event.completed or event.failed notifications when processing finishes. This lets your application react to processing results without polling.
Transient failures (infrastructure errors, timeouts, and program or group state that keeps changing across three attempts) retry automatically with exponential backoff (2s, 4s, 8s, 16s, 32s), up to 5 retries. Validation failures are terminal; fix the cause and retry manually:
PENDING for a fresh set of attempts. A retried event re-runs every rule from scratch against the participant’s current state; because a failed event committed nothing, nothing can apply twice. See Failure is all-or-nothing.
Reversing an event
When a purchase is refunded, the value its event earned has to come back. Because the ledger records exactly what each event credited, Scrip can reverse it directly:fraction to reverse all remaining value. The contract:
- Recovery targets the original awards. Scrip claws back from the lots each award created, where the originally credited participant still holds that value. Value already spent, expired, transferred to someone else, or currently held is reported as
shortfall_amountand never forced; a reversal cannot overdraw a balance. - Cumulative reversals are capped. Repeated partial reversals of one event cannot exceed what it originally awarded. A request over the remaining reversible value returns
409 Conflictinstead of clamping. - The original event’s record never changes. Reversals post their own journal entries and return recovered value to the account each award drew from (the program wallet for prefunded assets). The impact endpoint shows the original event exactly as it ran.
- State is reported, not reverted. The response includes the event’s counter, tag, and tier changes so you can decide what a refund means for them; send a compensating event if your policy requires it. Whether a refund erases a visit, and how partial refunds split across awards, are business decisions the endpoint deliberately leaves to you.
- Plain rule-issued credits are reversible. Credit entries the reversal cannot process, such as settlement reconciliation entries, are listed in the response as
ineligible_entrieswith reasons.
event.reversed webhook fires with the per-award accounting, and each line that recovered value fires one balance.reversed. When refund policy differs per award rather than scaling uniformly, compute the amounts in your backend and use the exact clawback pattern instead.
Idempotency
Theidempotency_key ensures exactly-once processing per program. If you send the same program_id + idempotency_key combination more than once, the duplicate is ignored and the original event is returned. This applies regardless of whether the payload differs.
If a network timeout occurs, re-send the same request. The duplicate is safely deduplicated.
Treat idempotency keys as unique identifiers per intent. If the payload needs to change (e.g., correcting an amount), use a new key.
Use meaningful, deterministic idempotency keys like
order-12345-completed or referral-user456-signup. Avoid random UUIDs, which defeat the purpose of deduplication.Event data design
Theevent_data payload becomes the event variable in CEL expressions. Design it with rules in mind:
Rules reference
event_data fields directly as event.amount, event.category, etc. If a rule references a field that isn’t in the payload, the condition evaluates to false and the rule doesn’t match. Use has() for fields that only appear on some events. See CEL Expressions.Batch ingestion
Send up to 100 events in a single request:202 response reports per-event outcomes: each entry in results is either accepted (with the full event object) or error (with an error_code and message). Valid events proceed even when siblings fail. A 400 is returned only when the envelope itself is malformed (zero events, more than 100, or unparseable JSON). Business validation errors surface later via event.failed webhooks.
Event routing
By default, rule actions apply to the event’s participant. To credit a different participant, include their identifier inevent_data and reference it in the rule action’s target:
target field’s external_id accepts a CEL expression that resolves to a participant’s external ID. You can also use participant_id to resolve by Scrip UUID. The target participant must exist (they are automatically enrolled if not already a member of the program).
Rules always evaluate conditions against the event’s participant (user_123). Only the action’s credit is routed to the target. See Rule Actions for more on static and dynamic targeting.
Writing rules
Event-start snapshot evaluation.
Rule sets
Evaluation order and set-local stopping.
Webhooks
event.completed and event.failed.