Events are signals from your application that trigger rule evaluation. You send events via the API, and Scrip evaluates all matching rules against the participant’s current state. Events can also come from automations, which generate events on a schedule or in response to participant state changes.
Sending an Event
curl -X POST https://api.scrip.dev/v1/events \
-H "Authorization: Bearer $SCRIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"program_id": "program-uuid",
"external_id": "user_123",
"idempotency_key": "order-456-completed",
"event_timestamp": "2025-01-15T10:30:00Z",
"event_data": {
"type": "purchase",
"amount": 99.50,
"category": "electronics"
}
}'
| Field | Required | Description |
|---|
program_id | Yes | Which program’s rules should evaluate this event |
external_id | One of external_id or participant_id | Your application’s user ID |
participant_id | One of external_id or participant_id | Scrip’s internal participant UUID |
idempotency_key | Yes | Unique key per program for exactly-once processing (1-255 characters) |
event_timestamp | Yes | When the event occurred in your system (RFC 3339) |
event_data | Yes | JSON object available to rules as event.* in CEL |
Timestamps
Every event carries two timestamps:
| Timestamp | Set by | Purpose |
|---|
event_timestamp | You, at ingestion | When the event occurred in your system |
created_at | Scrip, on receipt | When Scrip received and stored the event |
These serve different roles:
-
event_timestamp is the logical clock. It becomes the now variable 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_at is the ingestion clock. The from and to query parameters on list endpoints filter on created_at, not event_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 the event_from and event_to parameters instead. Both pairs can be used simultaneously (AND semantics).
Because 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 rules, not the rules as they existed at that time. See Time-Windowed Rules for how active_from / active_to interact with this.
Processing Pipeline
Events are processed asynchronously. The API confirms receipt, not validity. Business validation (program existence) and rule evaluation happen in the background. Existing participants are automatically enrolled in the target program if not already members. The on_unknown_participant setting only controls creation of new participants. Inactive enrollments are reactivated. Validation errors surface via event.failed webhooks.
POST /v1/events → 202 Accepted (event created as PENDING)
↓
Worker picks up event
↓
Validation + rules evaluated against participant state
↓
Event transitions to COMPLETED or FAILED
When a worker picks up an event, it loads the program’s active rules, hydrates the CEL context with the participant’s current state (tags, counters, attributes, tiers), program state, and group memberships, then evaluates each rule’s condition. Actions from matching rules execute within the same transaction.
To check processing status:
The response includes the event status, rule evaluations that occurred, and error details if processing failed.
For a deeper view, use the impact endpoint to see everything an event caused: journal entries with postings, state changes, and per-entity balance impact.
GET /v1/events/{id}/impact
Event Lifecycle
| Status | Meaning |
|---|
PENDING | Received, waiting for processing |
PROCESSING | Worker is evaluating rules |
COMPLETED | All matching rules evaluated and their allowed actions executed |
FAILED | Validation failed (invalid program) or a rule action was blocked (e.g., crediting an inactive participant) |
An event targeting a SUSPENDED or CLOSED participant can still reach COMPLETED if the matching rules only trigger metadata actions like TAG or SET_ATTRIBUTE. The event only fails if a rule attempts a financial action that is blocked by the participant’s status. See Participants: What’s allowed by status.
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.
Failed events retry automatically with exponential backoff (2s, 4s, 8s, 16s, 32s) up to 5 attempts. You can also retry manually:
POST /v1/events/{id}/retry
Manual retry resets the retry count and returns the event to PENDING for a fresh set of attempts.
Idempotency
The idempotency_key ensures exactly-once processing per program. If you send the same program_id + idempotency_key combination more than once, the original event takes precedence. Subsequent submissions with the same key are silently ignored, regardless of payload differences.
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.
You can also look up an event by its key:
GET /v1/events/by-key?program_id={program_id}&idempotency_key=order-12345-completed
Event Data Design
The event_data payload becomes the event variable in CEL expressions. Design it with rules in mind:
{
"type": "purchase",
"amount": 49.99,
"category": "electronics",
"store_id": "store-west-01",
"order_id": "order-789"
}
| Tip | Rationale |
|---|
Include a consistent type field | Clean rule filtering: event.type == "purchase" |
| Keep amounts as numbers | Avoids double() casting in rules |
| Use snake_case for field names | Consistency with the API |
| Include context for debugging | store_id, order_id help troubleshoot |
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:
POST /v1/events/batch
{
"events": [
{"program_id": "...", "external_id": "user_1", "idempotency_key": "evt-1", "event_timestamp": "...", "event_data": {"type": "purchase", "amount": 50}},
{"program_id": "...", "external_id": "user_2", "idempotency_key": "evt-2", "event_timestamp": "...", "event_data": {"type": "purchase", "amount": 75}}
]
}
Each event is validated and processed independently. Individual events can succeed or fail without affecting others. The response includes per-event results with status and error details. Validation errors for individual events surface via event.failed webhooks.
Event Routing
By default, rule actions apply to the event’s participant. To credit a different participant, include their identifier in event_data and reference it in the rule action’s target:
{
"program_id": "...",
"external_id": "user_123",
"idempotency_key": "referral-user123-signup",
"event_timestamp": "2025-01-15T10:30:00Z",
"event_data": {
"type": "referral",
"referrer_id": "user_456"
}
}
{
"name": "Referral Bonus",
"condition": "event.type == 'referral'",
"actions": [
{
"type": "CREDIT",
"asset_id": "...",
"amount": "50",
"target": {"external_id": "event.referrer_id"}
}
]
}
The 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.