> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scrip.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Event Processing

> Event ingestion, rule matching, and action execution

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](/guides/automations), which generate events on a schedule or in response to participant state changes.

## Sending an Event

```bash theme={null}
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                                                                                        |
| `recipient_id`          | No                                       | Route rewards to a different participant by Scrip UUID (e.g., gifting). Mutually exclusive with `recipient_external_id`. |
| `recipient_external_id` | No                                       | Route rewards to a different participant by your external ID. Mutually exclusive with `recipient_id`.                    |
| `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](/guides/cel-expressions#now), 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 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](/guides/writing-rules#time-windowed-rules).

## Processing Pipeline

Events are processed asynchronously. The API confirms receipt, not validity. Business validation (program existence and status, participant resolution) and rule evaluation happen in the background. Existing participants are automatically enrolled in the target program if not already members; see [Programs: Enrollment](/guides/programs#enrollment) for enrollment behavior and the `on_unknown_participant` setting. Validation errors surface via [`event.failed`](/guides/webhooks#event-payloads) webhooks.

```
POST /v1/events → 202 Accepted (queued for async validation)
                       ↓
               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 and assembles the CEL context: the participant's current state (tags, counters, attributes, tiers), program state, and group memberships. It then evaluates each rule's condition against this context. Actions from matching rules execute within the same transaction.

This context is a single event-start snapshot. All rule conditions for that event evaluate against the same snapshot. Actions from earlier matching rules do not change what later rule conditions see. See [State Snapshot Evaluation Behavior](/guides/writing-rules#state-snapshot-evaluation-behavior) for the implications and patterns.

The rule definitions themselves are also read at processing time, not at ingestion. If you update a rule while events are queued, only events not yet processed use the new definition. A batch that spans the change can split across both versions. See [Updating rules under live traffic](/guides/writing-rules#updating-rules-under-live-traffic) for the full contract and how to reconcile which version applied.

To check processing status:

```bash theme={null}
GET /v1/events/{id}
```

The response includes the event status, rule evaluations that occurred, and error details if processing failed.

<Note>
  **Read-after-write visibility:** the ID returned by the `202` is durable, but in queue-based ingestion mode the event may briefly `404` on both `GET /v1/events/{id}` and `GET /v1/events/by-key` (typically well under a second) until the async consumer materializes 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.
</Note>

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.

```bash theme={null}
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 or inactive program, suspended or closed participant) or a rule action was blocked |
| `RECEIVED`   | Legacy status on historical rows; new events enter at `PENDING`. Still accepted as a filter value.            |

<Note>
  Events whose resolved actor or recipient is `SUSPENDED` or `CLOSED` are rejected at the ingestion boundary, before any rule runs: `422` with code `participant_suspended` or `participant_closed` when identities resolve synchronously, or a terminal `FAILED` event with the same `error_code` in queue-based mode. See [Participants: How this affects events](/guides/participants#how-this-affects-events).
</Note>

Failed events carry a machine-readable `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](/guides/webhooks) 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) retry automatically with exponential backoff (2s, 4s, 8s, 16s, 32s), up to 5 retries. Validation failures are terminal; fix the cause and retry manually:

```bash theme={null}
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 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.

<Note>
  Use meaningful, deterministic idempotency keys like `order-12345-completed` or `referral-user456-signup`. Avoid random UUIDs, which defeat the purpose of deduplication.
</Note>

You can also look up an event by its key:

```bash theme={null}
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:

```json theme={null}
{
  "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         |

<Note>
  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](/guides/cel-expressions#optional-fields).
</Note>

## Batch Ingestion

Send up to 100 events in a single request:

```bash theme={null}
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 accepted, validated, and processed independently; a batch is never all-or-nothing. The `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`](/guides/webhooks#event-payloads) 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`:

```json theme={null}
{
  "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"
  }
}
```

```json theme={null}
{
  "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](/guides/rule-actions#targeting) for more on static and dynamic targeting.
