# AI resources Source: https://docs.scrip.dev/ai-resources Context files and documentation links for AI coding tools Use these resources when asking AI coding tools to help integrate Scrip. Start with the core model, then add task-specific guides and the OpenAPI spec when the tool needs endpoint schemas. ## Context files * [Scrip overview](https://scrip.dev/llms.txt): Start here if the tool only knows the product website. * [Docs index](https://docs.scrip.dev/llms.txt): Use this to choose relevant docs pages. * [Full docs](https://docs.scrip.dev/llms-full.txt): Load this when the tool supports large context files. * [OpenAPI spec](https://docs.scrip.dev/openapi.json): Use this for endpoint paths, request schemas, response schemas, and error codes. ## Recommended context Events are the input that drives rule evaluation, balance changes, ledger entries, webhooks, and downstream reporting. Load event context before asking an AI tool to design rules, balances, or automations. For most integration work, load these pages in order: 1. [Introduction](/introduction) 2. [Core concepts](/guides/core-concepts) 3. [Authentication](/authentication) 4. [Quickstart](/quickstart) 5. [Event processing](/guides/event-processing) 6. [API introduction](/api-reference/introduction) 7. [OpenAPI spec](https://docs.scrip.dev/openapi.json) ## Workflow context For programs and assets, load [Core concepts](/guides/core-concepts), [Programs](/guides/programs), and [Asset configuration](/guides/asset-configuration). For participants and groups, load [Core concepts](/guides/core-concepts), [Participants](/guides/participants), [State management](/guides/state-management), and [Groups](/guides/groups). For rules and events, load [Event processing](/guides/event-processing), [Writing rules](/guides/writing-rules), [CEL expressions](/guides/cel-expressions), and [Rule actions](/guides/rule-actions). For balances and ledger behavior, load [Ledger](/guides/ledger), [Balance operations](/guides/balance-operations), and [Lots and expiration](/guides/lots-and-expiration). For redemptions and rewards catalog work, load [Ledger](/guides/ledger), [Balance operations](/guides/balance-operations), [Redemptions](/guides/redemptions), and [Rewards catalog](/guides/rewards-catalog). For transfers, load [Ledger](/guides/ledger), [Balance operations](/guides/balance-operations), and [Transfers](/guides/transfers). For reporting, load [Ledger](/guides/ledger), [Event processing](/guides/event-processing), and [Reporting](/guides/reporting). For automations, load [Event processing](/guides/event-processing), [Writing rules](/guides/writing-rules), and [Automations](/guides/automations). For webhooks, load [Event processing](/guides/event-processing) and [Webhooks](/guides/webhooks). For testing, load [Quickstart](/quickstart), [Event processing](/guides/event-processing), and [Testing](/guides/testing). ## Integration path When an AI tool is helping build a first Scrip integration, use this sequence: 1. Authenticate server-side with an API key. 2. Create a program. 3. Create and link an asset. 4. Define a rule with a CEL condition and one or more actions. 5. Ingest an event with an `idempotency_key`. 6. Verify the participant balance and ledger entries. ## Security constraints Scrip API keys use the `sk_` prefix and have full read/write access. Keep them server-side. Do not expose them in browser code, mobile apps, public repositories, or AI prompts. Use deterministic `idempotency_key` values for retries. Reusing the same key with the same payload returns the original result. Reusing the same key with a different payload returns `409 Conflict`. # Create an asset Source: https://docs.scrip.dev/api-reference/assets/create-an-asset POST /v1/assets Create a new asset type within a program. Creates a new asset in your organization. The asset is automatically linked to the specified program on creation. All fields are required: * `program_id`: the program to link this asset to * `name` and `symbol`: display name and short code * `inventory_mode`: `SIMPLE` tracks a single balance per participant; `LOT` tracks individual batches with expiration and vesting dates * `issuance_policy`: `UNLIMITED` mints value on demand with no budget cap; `PREFUNDED` draws from the program wallet * `scale`: decimal precision from 0 to 18 (e.g., `2` for dollars and cents) The `inventory_mode`, `issuance_policy`, and `scale` fields are immutable after creation because they affect how the ledger records and resolves balances. If you need a different configuration, create a new asset. For usage patterns and examples, see the [Asset Configuration guide](/guides/asset-configuration). # Get an asset Source: https://docs.scrip.dev/api-reference/assets/get-an-asset GET /v1/assets/{id} Retrieve an asset by ID. Returns a single asset by its `id`, including its full configuration: `name`, `symbol`, `inventory_mode`, `issuance_policy`, and `scale`. Use this to verify an asset's immutable settings before linking it to a program or referencing it in a rule. The response also includes the creation timestamp and linked programs. For usage patterns and examples, see the [Asset Configuration guide](/guides/asset-configuration). # List assets Source: https://docs.scrip.dev/api-reference/assets/list-assets GET /v1/assets List all assets for your organization. Returns all assets in your organization. Assets are organization-level resources, not scoped to a single program. Each asset can be linked to one or more programs. Filter by `status` or `program_id`, and use `search` to find assets by symbol or name. The response includes each asset's `inventory_mode`, `issuance_policy`, `scale`, and `status`. For usage patterns and examples, see the [Asset Configuration guide](/guides/asset-configuration). # Assets Source: https://docs.scrip.dev/api-reference/assets/overview Units of value that participants earn and spend An asset defines a unit of value in your system: points, cashback, credits, nights. Each asset has an `inventory_mode` (`SIMPLE` or `LOT`) and an `issuance_policy` (`UNLIMITED` or `PREFUNDED`) that control how balances behave. ## Endpoints * Create, list, get, and update assets * Set `inventory_mode`, `issuance_policy`, `scale`, and `symbol` at creation * Update `name` and `status` after creation For usage patterns and examples, see the [Asset Configuration guide](/guides/asset-configuration). # Update an asset Source: https://docs.scrip.dev/api-reference/assets/update-an-asset PATCH /v1/assets/{id} Update an asset's name, status, or maximum transaction amount. Updates an asset's `name` or `status`. Only include the fields you want to change; omitted fields are left unchanged. Set `status` to `ARCHIVED` to permanently retire an asset. Archived assets stop accepting new ledger entries but preserve all historical data. This cannot be reversed. The `inventory_mode`, `issuance_policy`, and `scale` fields cannot be modified because they govern ledger behavior. If you need different settings, create a new asset. For usage patterns and examples, see the [Asset Configuration guide](/guides/asset-configuration). # Cancel an automation Source: https://docs.scrip.dev/api-reference/automations/cancel-an-automation POST /v1/programs/{programId}/automations/{automationId}/cancel Cancel a running or pending automation execution. Cancels a pending or in-progress automation execution. Only two cases are cancellable: * **Participant-scoped fan-out** (execution\_status is `pending` or `executing`): Sets `execution_status` to `failed`. Participants already processed retain their generated events; remaining participants are skipped. The automation stays `active` and fires again on its next scheduled trigger unless you also pause or archive it. * **Program-scoped one-time** (not yet processed): Archives the automation. All other combinations return a `400` error. For example, a cron+program automation or an already-idle fan-out cannot be cancelled. Fan-out cancellation is best-effort. A small number of additional participants may be processed between the cancel request and acknowledgment. For usage patterns and examples, see the [Automations guide](/guides/automations). # Create an automation Source: https://docs.scrip.dev/api-reference/automations/create-an-automation POST /v1/programs/{programId}/automations Create a scheduled or event-driven automation within a program. Creates an automation scoped to a program. At minimum you must provide `name`, `trigger.type`, `scope`, and `event_name`. The `trigger.type` can be `cron`, `one_time`, `immediate`, or `participant_state`. The `scope` determines whether the automation fires once at the program level or fans out across individual participants. Not all trigger and scope combinations are valid. The `immediate` and `participant_state` trigger types only support `participants` scope. If you specify an invalid combination, the request will be rejected with a validation error. For `one_time` automations with `scope` set to `program`, you can optionally include `participant_id` to target a specific participant. This sends the event to that participant rather than to the program at large. When the automation fires, it generates an event with the specified `event_name` and optional `payload`. That event enters the rules engine like any other event, so you can attach rule logic to it without additional wiring. For usage patterns and examples, see the [Automations guide](/guides/automations). # Delete an automation Source: https://docs.scrip.dev/api-reference/automations/delete-an-automation DELETE /v1/programs/{programId}/automations/{automationId} Soft-delete (archive) an automation. Archives an automation. This is a soft delete: the automation stops firing and is excluded from default list responses, but it is not permanently removed. You can still retrieve it by ID. Archived automations cannot be re-activated. If you need to temporarily disable an automation with the intent to resume it later, use the update endpoint to set `status` to `paused` instead. For usage patterns and examples, see the [Automations guide](/guides/automations). # Get an automation Source: https://docs.scrip.dev/api-reference/automations/get-an-automation GET /v1/programs/{programId}/automations/{automationId} Retrieve an automation by ID. Returns a single automation by ID, including its full configuration and current execution state. For participant-scoped automations, the response includes fan-out progress fields: `participants_total` and `participants_processed`. These let you track how far along a running automation is. For program-scoped automations, these fields are omitted. For usage patterns and examples, see the [Automations guide](/guides/automations). # List automation subscriptions Source: https://docs.scrip.dev/api-reference/automations/list-automation-subscriptions GET /v1/programs/{programId}/automations/{automationId}/subscriptions Returns per-participant subscription records for a participant-scoped automation. Lists the per-participant subscriptions for a `participant_state` automation. Each subscription shows the participant, `next_trigger_at`, and current status. Results are paginated. This endpoint only applies to automations with `trigger.type` set to `participant_state`. Calling it on a `cron`, `one_time`, or `immediate` automation returns an empty list. Use this to inspect which participants are currently enrolled and when their next event will fire. For usage patterns and examples, see the [Automations guide](/guides/automations). # List automations Source: https://docs.scrip.dev/api-reference/automations/list-automations GET /v1/programs/{programId}/automations Returns a paginated list of automations for a program. Lists all automations for a given program. Results are paginated and returned in reverse-chronological order by default. Use `search` to find automations by name. Filter by `trigger_type`, `scope`, `status`, and `source`. The `source` filter distinguishes between automations created via the API (`api`) and those generated by rule actions (`rule_action`). Combining multiple filters narrows results with AND logic. For usage patterns and examples, see the [Automations guide](/guides/automations). # Automations Source: https://docs.scrip.dev/api-reference/automations/overview Scheduled and triggered event generation An automation generates events on a schedule, at a specific time, or in response to participant state changes. Automations are scoped to a program. The `trigger.type` can be `cron`, `one_time`, `immediate`, or `participant_state`. All endpoints are under `/v1/programs/{programId}/automations`. ## Endpoints * Create, list, get, update, and delete automations * Trigger an automation manually outside its schedule * Cancel a pending or scheduled automation run * Refresh subscriptions for a `participant_state` automation (re-evaluate filters) * List subscriptions for a `participant_state` automation For usage patterns and examples, see the [Automations guide](/guides/automations). # Refresh subscriptions Source: https://docs.scrip.dev/api-reference/automations/refresh-subscriptions POST /v1/programs/{programId}/automations/{automationId}/refresh-subscriptions Re-evaluate participant filters and update subscriptions for a participant_state automation. Queues a re-evaluation of a `participant_state` automation's filter criteria against the current participant set. New participants that now match the filter are subscribed and will receive future triggers. Participants that no longer match have their subscriptions cancelled. Scrip runs this evaluation periodically on its own, but you can call this endpoint to queue a re-evaluation sooner, for example after a bulk import or a rule change that affects participant state. The endpoint returns `202` and the evaluation runs asynchronously. The automation must be `active`. Paused or archived automations return a `400` error. This endpoint only supports `participant_state` automations. Calling it on a `cron`, `one_time`, or `immediate` automation returns a `400` error. To manually fire those automation types, use the [trigger endpoint](/api-reference/automations/trigger-an-automation) instead. For usage patterns and examples, see the [Automations guide](/guides/automations). # Trigger an automation Source: https://docs.scrip.dev/api-reference/automations/trigger-an-automation POST /v1/programs/{programId}/automations/{automationId}/trigger Manually trigger an automation outside its normal schedule. Manually fires an automation outside its normal schedule. This is useful for testing a new automation before its first scheduled run or for triggering a one-off execution on demand. The automation must be `active`. For participant-scoped automations, the `execution_status` must also be `idle` (no fan-out already running). For program-scoped cron automations, this sets `next_run_at` to now so the scheduler picks it up immediately. If the automation doesn't meet these requirements, the API returns `400` with message "Automation cannot be triggered (must be active and idle or cron+program)". This includes automations that have been canceled (which are archived and no longer `active`). `participant_state` automations cannot be manually triggered. They fire automatically when participant state matches. To re-evaluate which participants are subscribed, use [refresh subscriptions](/api-reference/automations/refresh-subscriptions) instead. | Trigger Type | Scope | Supported? | | ------------------- | -------------- | ------------------------------------------------------------------------------------------ | | `cron` | `program` | Yes | | `cron` | `participants` | Yes (must be `idle`) | | `one_time` | `participants` | Yes (must be `idle`) | | `immediate` | `participants` | Yes (must be `idle`) | | `one_time` | `program` | No — fires at `trigger_at` only | | `participant_state` | `participants` | No — use [refresh subscriptions](/api-reference/automations/refresh-subscriptions) instead | For usage patterns and examples, see the [Automations guide](/guides/automations). # Update an automation Source: https://docs.scrip.dev/api-reference/automations/update-an-automation PATCH /v1/programs/{programId}/automations/{automationId} Partially update an automation. Partial update on an existing automation. Only the fields you include in the request body are changed. You can modify `name`, `description`, `event_name`, `payload`, `status`, scheduling fields (`cron_expression`, `timezone`, `trigger_at`, `schedule_config`), and filter fields (`participant_filter`, `guard_condition`, `filter_hints`). Set `status` to `paused` to temporarily stop the automation from firing. Set it back to `active` to re-enable it. Pausing does not affect any in-progress fan-out; it prevents future triggers from starting. For usage patterns and examples, see the [Automations guide](/guides/automations). # Data Model Source: https://docs.scrip.dev/api-reference/data-model Reference map of Scrip's resources and how they relate Every resource in the Scrip API is listed below with its key fields and relationships. For a higher-level walkthrough, see [Core Concepts](/guides/core-concepts). All resources are identified by a UUID `id` assigned by Scrip. Many also carry an external identifier or human-readable key for integration with your system. ## Entity Overview | Entity | Description | Key Fields | API Prefix | | ---------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | | Organization | Top-level tenant that owns all other resources | `id`, `name`, `status` | Managed via dashboard | | Program | Container for rules, assets, and participants | `id`, `name`, `key`, `status`, `on_unknown_participant` | `/v1/programs` | | Asset | Unit of value (points, cashback, credits) | `id`, `name`, `symbol`, `scale`, `inventory_mode`, `issuance_policy` | `/v1/assets` | | Participant | A user in your system | `id`, `external_id`, `status`, `email`, `phone`, `first_name`, `last_name`, `display_name`, `tags`, `counters`, `attributes` | `/v1/participants` | | Group | Collection of participants with shared wallet and state | `id`, `name`, `status` | `/v1/groups` | | Event | Signal from your app that triggers rule evaluation | `id`, `idempotency_key`, `event_data`, `status` | `/v1/events` | | Rule | CEL condition + list of actions | `id`, `name`, `condition`, `actions`, `order`, `status` | `/v1/rules` | | Automation | Scheduled or triggered event generation | `id`, `name`, `type`, `schedule`, `status` | `/v1/programs/{id}/automations` | | Tier Type | Status hierarchy definition with levels | `id`, `key`, `display_name`, `levels`, `lifecycle` | `/v1/programs/{id}/tiers` | | Reward | Catalog item available for redemption | `id`, `name`, `unit_cost`, `redemption_type`, `status` | `/v1/programs/{id}/rewards` | | Redemption | Record of a participant spending balance | `id`, `participant_id`, `asset_id`, `amount`, `status` | `/v1/participants/{id}/redemptions` | | Transfer | Movement of value between participants | `id`, `source_external_id`, `recipients`, `total_amount` | `/v1/transfers` | | Journal Entry | Double-entry ledger record | `id`, `postings`, `event_id`, `rule_id`, `created_at` | `/v1/journal-entries` | | Lot | Individual credit with expiration/vesting | `id`, `asset_id`, `amount`, `expires_at`, `matures_at` | via participant lots endpoint | | Webhook Endpoint | Registered URL that receives event notifications | `id`, `url`, `enabled_events`, `status`, `secret` | `/v1/webhook-endpoints` | | Webhook Delivery | Record of a delivery attempt to a webhook endpoint | `id`, `webhook_event_id`, `webhook_endpoint_id`, `event_type`, `status`, `attempt_count` | `/v1/webhook-deliveries` | | Request Log | Record of an API request | `id`, `method`, `path`, `status_code`, `duration_ms` | `/v1/logs` | ## Relationships * An **Organization** is the top-level tenant. Programs, Assets, Participants, and Groups all belong to an organization. API keys are scoped to an organization. * A **Program** belongs to an Organization and contains Rules, links to Assets, and scopes Participants. * An **Asset** exists at the organization level and can be linked to multiple Programs. Each program maintains independent wallet balances for prefunded assets. * A **Participant** exists at the organization level and can participate across multiple Programs. Participants can be members of one or more Groups. * **Events** are sent to a Program for a specific Participant. The event triggers rule evaluation against that participant's state. * **Rules** belong to a Program and produce Journal Entries when their actions fire. * **Redemptions**, **Transfers**, and balance adjustments all produce Journal Entries in the double-entry ledger. * **Lots** track individual credits within a participant's balance. They only exist for assets using `LOT` inventory mode. * **Tier Types** belong to a Program. Participants progress through tier levels based on counter qualification or explicit rule actions. * **Automations** are scoped to a Program and generate Events on a schedule, at a specific time, or in response to participant state changes. * **Webhook Endpoints** belong to an Organization. When domain events occur (balance changes, redemptions, tier transitions), matching endpoints receive signed HTTP notifications via Webhook Deliveries. *** ## Organization The top-level tenant in Scrip. Every other resource (programs, assets, participants, groups) belongs to an organization. API keys are scoped to an organization. Organizations are managed through the [Scrip dashboard](https://app.scrip.dev), not the API. ### Key Fields | Field | Type | Description | | ------------ | ------------------- | --------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `name` | `string` | Display name | | `status` | `string` | Lifecycle state | | `created_at` | `string (RFC 3339)` | Creation timestamp | | `updated_at` | `string (RFC 3339)` | Last modification timestamp | ### Status Values | Status | Description | | ---------- | ---------------- | | `ACTIVE` | Normal operation | | `ARCHIVED` | Deactivated | *** ## Program The top-level container within an organization that holds rules, links to assets, and scopes participants. Most teams create one program per use case. ### Key Fields | Field | Type | Description | | ------------------------ | ------------------- | ------------------------------------------------------------------ | | `id` | `string (UUID)` | Scrip-assigned identifier | | `name` | `string` | Display name | | `key` | `string` | URL-safe identifier | | `status` | `string` | Current lifecycle state | | `on_unknown_participant` | `string` | Enrollment policy when an event arrives for an unknown participant | | `description` | `string` | Optional context | | `created_at` | `string (RFC 3339)` | Creation timestamp | | `updated_at` | `string (RFC 3339)` | Last modification timestamp | ### Status Values | Status | Description | | ----------- | ------------------------------------------------------ | | `ACTIVE` | Events process and rules evaluate normally | | `SUSPENDED` | New events are rejected. Can be reactivated. | | `ARCHIVED` | Hidden from default listings. Historical data remains. | ### Enrollment Policy | Value | Description | | -------- | ------------------------------------------------ | | `CREATE` | Auto-create participant on first event (default) | | `REJECT` | Reject events for unknown participants | See the [Programs guide](/guides/programs) for usage patterns. Browse the [Programs endpoints](/api-reference/programs/list-programs). *** ## Asset Defines the unit of value participants earn and spend. Three immutable settings control behavior: inventory mode, issuance policy, and scale. ### Key Fields | Field | Type | Description | | ------------------------ | ------------------- | --------------------------------------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `name` | `string` | Display name | | `symbol` | `string` | Short code (e.g., `PTS`, `USD`), unique per organization | | `scale` | `integer` | Decimal precision (`0` to `18`) | | `inventory_mode` | `string` | `SIMPLE` or `LOT` | | `issuance_policy` | `string` | `UNLIMITED` or `PREFUNDED` | | `max_transaction_amount` | `string (decimal)` | Optional per-transaction ceiling. Rejects any single credit or debit that exceeds this value. | | `created_at` | `string (RFC 3339)` | Creation timestamp | ### Inventory Mode | Value | Description | | -------- | ------------------------------------------------------------------------ | | `SIMPLE` | Single balance per bucket. No per-credit lifecycle. | | `LOT` | Each credit creates a separate lot with optional expiration and vesting. | ### Issuance Policy | Value | Description | | ----------- | ---------------------------------------------------------------- | | `UNLIMITED` | Credits mint new value on demand | | `PREFUNDED` | Credits draw from the program wallet, which must be funded first | See the [Asset Configuration guide](/guides/asset-configuration) for usage patterns. Browse the [Assets endpoints](/api-reference/assets/list-assets). *** ## Participant A user in your system, identified by your own `external_id`. Carries profile information and state (tags, counters, attributes, tiers) that rules read and write. ### Key Fields | Field | Type | Description | | -------------- | ------------------------ | ------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `external_id` | `string` | Your application's user identifier | | `status` | `string` | Current lifecycle state | | `email` | `string` | Contact email (optional) | | `phone` | `string` | Contact phone number (optional) | | `first_name` | `string` | First name (optional) | | `last_name` | `string` | Last name (optional) | | `display_name` | `string` | Display name (optional) | | `tags` | `list (string)` | Boolean flags (normalized to lowercase) | | `counters` | `map (string -> number)` | Numeric accumulators | | `attributes` | `map (string -> string)` | Key-value metadata | | `balances` | `list` | Current balances per asset, split by bucket | | `tiers` | `map` | Current tier level per tier type | | `program_ids` | `list (UUID)` | Programs this participant is enrolled in | | `created_at` | `string (RFC 3339)` | Creation timestamp | The [list endpoint](/api-reference/participants/list-or-get-participants) returns a slim response (`id`, `external_id`, `status`, profile fields, timestamps). The [detail endpoint](/api-reference/participants/get-a-participant-by-internal-id) returns all fields above in a single call. ### Identifiers | ID | Format | Use | | ------------- | ------ | ---------------------------------------------------------------- | | `id` | UUID | Scrip's internal identifier, used in all API paths | | `external_id` | String | Your application's user ID, used for lookups via query parameter | ### Status Values | Status | Earning | Spending | State Updates | | ----------- | ------- | -------- | ------------------------ | | `ACTIVE` | Yes | Yes | All | | `SUSPENDED` | No | No | Tags and attributes only | | `CLOSED` | No | No | Tags and attributes only | See the [Participants guide](/guides/participants) for usage patterns. Browse the [Participants endpoints](/api-reference/participants/list-or-get-participants). *** ## Group A collection of participants that shares a wallet and state. Groups exist at the organization level and can participate across programs. ### Key Fields | Field | Type | Description | | ------------ | ------------------------ | -------------------------------------------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `name` | `string` | Display name | | `status` | `string` | `ACTIVE` or `ARCHIVED` | | `members` | `list` | Participant members with roles (via [members endpoints](/api-reference/groups/list-group-members)) | | `tags` | `list (string)` | Group-level boolean flags (via [state endpoints](/api-reference/groups/get-group-tags)) | | `counters` | `map (string -> number)` | Group-level numeric accumulators (via [state endpoints](/api-reference/groups/get-group-counters)) | | `attributes` | `map (string -> string)` | Group-level key-value metadata (via [state endpoints](/api-reference/groups/get-group-attributes)) | ### Member Roles | Role | Description | | -------- | --------------------------- | | `ADMIN` | Can manage group membership | | `MEMBER` | Standard member | See the [Groups guide](/guides/groups) for usage patterns. Browse the [Groups endpoints](/api-reference/groups/list-groups). *** ## Event A signal from your application that triggers rule evaluation. Events are processed asynchronously and deduplicated by `idempotency_key` per program. ### Key Fields | Field | Type | Description | | ----------------- | ------------------- | ------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `program_id` | `string (UUID)` | Target program | | `external_id` | `string` | Participant's external identifier | | `participant_id` | `string (UUID)` | Participant's Scrip identifier (alternative to `external_id`) | | `idempotency_key` | `string` | Unique key per program for exactly-once processing | | `event_timestamp` | `string (RFC 3339)` | When the event occurred in your system | | `event_data` | `object` | JSON payload available to rules as `event.*` in CEL | | `status` | `string` | Processing state | | `created_at` | `string (RFC 3339)` | Ingestion timestamp | ### Status Values | Status | Description | | ------------ | ---------------------------------------- | | `PENDING` | Received, waiting for processing | | `PROCESSING` | Worker is evaluating rules | | `COMPLETED` | All rules evaluated and actions executed | | `FAILED` | Processing error or max retries exceeded | See the [Event Processing guide](/guides/event-processing) for usage patterns. Browse the [Events endpoints](/api-reference/events/list-events). *** ## Rule A CEL condition paired with a list of actions. When an event is processed, rules evaluate in order. Matching rules execute their actions within the same transaction. ### Key Fields | Field | Type | Description | | ------------------ | ------------------- | --------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `program_id` | `string (UUID)` | Owning program | | `name` | `string` | Display name | | `condition` | `string` | CEL expression that must return `true` | | `actions` | `list` | Actions to execute when the condition matches | | `order` | `integer` | Evaluation order (lower first) | | `stop_after_match` | `boolean` | Skip subsequent rules when this one matches | | `active_from` | `string (RFC 3339)` | Start of time window (optional) | | `active_to` | `string (RFC 3339)` | End of time window (optional) | | `budgets` | `list` | Per-asset spending caps | | `status` | `string` | Current lifecycle state | ### Status Values | Status | Description | | ----------- | ------------------------------------------------------------ | | `ACTIVE` | Evaluates on every event | | `SUSPENDED` | Skipped during evaluation. Can be reactivated. | | `ARCHIVED` | Soft-deleted. Excluded from evaluation and default listings. | See the [Writing Rules guide](/guides/writing-rules) for usage patterns. Browse the [Rules endpoints](/api-reference/rules/list-rules). *** ## Automation Generates events on a schedule, at a specific time, or in response to participant state changes. Scoped to a program. ### Key Fields | Field | Type | Description | | -------------- | ------------------- | ------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `program_id` | `string (UUID)` | Owning program | | `name` | `string` | Display name (unique per program) | | `trigger.type` | `string` | `cron`, `one_time`, `immediate`, or `participant_state` | | `scope` | `string` | `program` or `participants` | | `event_name` | `string` | Event type generated when the automation fires | | `payload` | `object` | JSON merged into the event's `event_data` | | `status` | `string` | Current lifecycle state | | `last_run_at` | `string (RFC 3339)` | Timestamp of the most recent execution | ### Status Values | Status | Description | | ----------- | -------------------------------------------------------------------- | | `active` | Ready to fire on schedule | | `paused` | Disabled. Will not fire until reactivated. | | `completed` | Terminal. One-time and immediate automations move here after firing. | | `failed` | Terminal. Disabled after repeated consecutive failures. | | `archived` | Soft-deleted via the delete endpoint. | Automation statuses are lowercase, unlike other resources which use uppercase. See the [Automations guide](/guides/automations) for usage patterns. Browse the [Automations endpoints](/api-reference/automations/list-automations). *** ## Tier Type Defines a ranked progression track (e.g., Silver / Gold / Platinum) with ordered levels. Participants advance based on counter qualification or explicit rule actions. ### Key Fields | Field | Type | Description | | -------------- | --------------- | ----------------------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `key` | `string` | Unique identifier within the program | | `display_name` | `string` | Human-readable name | | `levels` | `list` | Ordered tier levels, each with `key`, `rank`, `qualification`, and `benefits` | | `lifecycle` | `object` | Retention mode, qualification period, downgrade policy, and counter rollover | ### Level Fields | Field | Type | Description | | --------------- | --------- | ------------------------------------------------ | | `key` | `string` | Unique within the tier type | | `rank` | `integer` | Hierarchy position (higher = higher tier) | | `display_name` | `string` | Human-readable name | | `qualification` | `object` | Counter-based criteria for automatic advancement | | `benefits` | `object` | Arbitrary JSON returned with tier state | See the [Tiers guide](/guides/tiers) for usage patterns. Browse the [Tiers endpoints](/api-reference/tiers/list-tiers). *** ## Reward A catalog item that participants can redeem with their balance. Scoped to a program and priced in a linked asset. ### Key Fields | Field | Type | Description | | --------------------- | ------------------- | --------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `name` | `string` | Display name (unique per program) | | `redemption_type` | `string` | `UNIT_BASED` or `AMOUNT_BASED` (immutable after creation) | | `asset_id` | `string (UUID)` | Asset this reward is priced in (immutable after creation) | | `unit_cost` | `string (decimal)` | Cost per unit, or minimum amount for `AMOUNT_BASED` | | `max_total` | `integer` | Global inventory cap (`UNIT_BASED` only) | | `max_per_participant` | `integer` | Per-participant cap (`UNIT_BASED` only) | | `redeemed_count` | `integer` | Current global redemption count | | `available_from` | `string (RFC 3339)` | Start of availability window | | `available_until` | `string (RFC 3339)` | End of availability window | | `status` | `string` | Current lifecycle state | ### Status Values | Status | Description | | -------------- | ------------------------------------------ | | `DRAFT` | Not yet available for redemption | | `ACTIVE` | Available for redemption | | `OUT_OF_STOCK` | Inventory exhausted (set automatically) | | `ARCHIVED` | Removed from listings. Cannot be redeemed. | See the [Rewards Catalog guide](/guides/rewards-catalog) for usage patterns. Browse the [Rewards endpoints](/api-reference/rewards/list-rewards). *** ## Redemption A record of a participant spending balance, either as a raw amount debit or a catalog item purchase. Supports full and partial reversals. ### Key Fields | Field | Type | Description | | ----------------- | ------------------- | ------------------------------------------------ | | `id` | `string (UUID)` | Scrip-assigned identifier | | `participant_id` | `string (UUID)` | Participant who redeemed | | `program_id` | `string (UUID)` | Program context | | `asset_id` | `string (UUID)` | Asset debited | | `amount` | `string (decimal)` | Total amount debited | | `reward_id` | `string (UUID)` | Catalog item redeemed (null for raw redemptions) | | `description` | `string` | Reason for the redemption | | `idempotency_key` | `string` | Deduplication key (scoped per program) | | `reversed_amount` | `string (decimal)` | Cumulative amount reversed | | `status` | `string` | Current state | | `created_at` | `string (RFC 3339)` | Creation timestamp | ### Status Values | Status | Description | | -------------------- | ------------------------------------- | | `COMPLETED` | No reversals applied | | `PARTIALLY_REVERSED` | Some amount reversed, more can follow | | `FULLY_REVERSED` | Entire redemption reversed | See the [Redemptions guide](/guides/redemptions) for usage patterns. Browse the [Redemptions endpoints](/api-reference/redemptions/redeem-points-assets). *** ## Transfer An atomic movement of funds from one participant or group to one or more recipients. Zero-sum: the source is debited by the total credited to all recipients. ### Key Fields | Field | Type | Description | | ----------------------- | ------------------ | --------------------------------------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `program_id` | `string (UUID)` | Program context | | `asset_id` | `string (UUID)` | Asset transferred | | `source_external_id` | `string` | Participant sending funds, by external ID | | `source_participant_id` | `string (UUID)` | Participant sending funds, by Scrip UUID | | `source_group_id` | `string (UUID)` | Group sending funds | | `recipients` | `list` | Array of recipients, each with `external_id`, `participant_id`, or `group_id` and an `amount` | | `total_amount` | `string (decimal)` | Sum of all recipient amounts | | `description` | `string` | Reason for the transfer | | `idempotency_key` | `string` | Deduplication key (scoped per program) | | `journal_entry_id` | `string (UUID)` | Corresponding ledger record | Exactly one source identifier is required per transfer. The three source fields (`source_external_id`, `source_participant_id`, `source_group_id`) are mutually exclusive. See the [Transfers guide](/guides/transfers) for usage patterns. Browse the [Transfers endpoints](/api-reference/transfers/create-a-transfer). *** ## Journal Entry An immutable double-entry ledger record. Every balance change (credits, debits, holds, releases, forfeits, redemptions, transfers) produces a journal entry with postings that sum to zero. ### Key Fields | Field | Type | Description | | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | | `id` | `string (UUID)` | Scrip-assigned identifier | | `description` | `string` | Human-readable summary | | `postings` | `list` | Debit and credit lines, each with account, signed amount, and bucket | | `event_id` | `string (UUID)` | Event that triggered this entry (null for direct API operations) | | `action_type` | `string` | Ledger action type (e.g. `CREDIT`, `DEBIT`, `HOLD`, `RELEASE`, `FORFEIT`, `MATURITY`) | | `reference_id` | `string` | Correlation ID linking hold, release, and settle operations (LOT-mode assets only, null when not applicable) | | `created_by_api_key_id` | `string (UUID)` | API key that triggered this entry (null for rule-triggered operations) | | `entry_hash` | `string` | SHA-256 hash sealing this entry into the organization's hash chain | | `created_at` | `string (RFC 3339)` | Creation timestamp | ### Posting Fields | Field | Type | Description | | ---------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------- | | `id` | `string (UUID)` | Posting identifier | | `entity_type` | `string` | Account owner type: `PARTICIPANT`, `PROGRAM`, `GROUP`, `SYSTEM_ISSUANCE`, `SYSTEM_BREAKAGE`, or `SYSTEM_REDEMPTION` | | `asset_symbol` | `string` | Symbol of the asset being transacted | | `amount` | `string (decimal)` | Signed amount (positive = credit, negative = debit) | | `bucket` | `string` | `AVAILABLE`, `HELD`, or `DEFERRED` | | `participant_id` | `string (UUID)` | Participant who owns this account (present when `entity_type` is `PARTICIPANT`) | | `group_id` | `string (UUID)` | Group that owns this account (present when `entity_type` is `GROUP`) | | `program_id` | `string (UUID)` | Program that owns this account (present when `entity_type` is `PROGRAM`) | | `created_at` | `string (RFC 3339)` | Creation timestamp | See the [Ledger guide](/guides/ledger) for usage patterns. Browse the [Reporting endpoints](/api-reference/reporting/list-journal-entries). *** ## Lot An individual credit with its own balance, expiration, and vesting date. Only exists for assets using `LOT` inventory mode. Lots are consumed in FIFO order when funds are debited. ### Key Fields | Field | Type | Description | | -------------- | ------------------- | ------------------------------------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `asset_id` | `string (UUID)` | Asset this lot belongs to | | `amount` | `string (decimal)` | Original credited amount | | `remaining` | `string (decimal)` | Current unspent balance | | `status` | `string` | Current lifecycle state | | `reference_id` | `string` | Correlation ID linking this held lot to a hold operation (null when not held via reference) | | `created_at` | `string (RFC 3339)` | Creation timestamp | | `expires_at` | `string (RFC 3339)` | Expiration deadline (null if no expiry) | | `matures_at` | `string (RFC 3339)` | Vesting date (null if immediately available) | ### Status Values | Status | Spendable | Description | | ----------- | --------- | ------------------------------------------------------------------- | | `DEFERRED` | No | Lot has a future `matures_at` and is waiting for automatic maturity | | `AVAILABLE` | Yes | Mature and spendable | | `HELD` | No | Reserved via a hold operation | | `CONSUMED` | No | Fully spent | | `EXPIRED` | No | `expires_at` has passed, forfeited to breakage | Lots credited with a future `matures_at` land in `DEFERRED` and automatically transition to `AVAILABLE` when the maturity date passes. `DEFERRED` is read-only and cannot be targeted by rules or API writes. See the [Lots & Expiration guide](/guides/lots-and-expiration) for usage patterns. Browse the [Participant lots endpoint](/api-reference/participants/list-participant-lots). *** ## Webhook Endpoint A registered URL that receives signed HTTP notifications when domain events occur. Endpoints are scoped to an organization and filter events by type. ### Key Fields | Field | Type | Description | | ---------------- | ------------------- | --------------------------------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `url` | `string` | HTTPS destination URL | | `description` | `string` | Human-readable label | | `secret` | `string` | HMAC-SHA256 signing secret (`whsec_` prefix). Only returned on create and rotate. | | `enabled_events` | `list (string)` | Subscribed event types, or `["*"]` for all | | `status` | `string` | Current lifecycle state | | `metadata` | `object` | Arbitrary key-value metadata | | `created_at` | `string (RFC 3339)` | Creation timestamp | | `updated_at` | `string (RFC 3339)` | Last modification timestamp | ### Status Values | Status | Description | | ---------- | --------------------------------------------- | | `ACTIVE` | Receiving deliveries | | `DISABLED` | Not receiving deliveries. Can be reactivated. | | `ARCHIVED` | Soft-deleted via the delete endpoint. | See the [Webhooks guide](/guides/webhooks) for usage patterns. Browse the [Webhooks endpoints](/api-reference/webhooks/list-webhook-endpoints). *** ## Webhook Delivery A record of a delivery attempt from a webhook event to a specific endpoint. Tracks attempt count, response details, and retry scheduling. ### Key Fields | Field | Type | Description | | ---------------------- | ------------------- | ------------------------------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `webhook_event_id` | `string (UUID)` | Event that triggered this delivery | | `webhook_endpoint_id` | `string (UUID)` | Target endpoint | | `event_type` | `string` | Event type (e.g. `balance.credited`) | | `status` | `string` | Current delivery state | | `attempt_count` | `integer` | Number of attempts made | | `max_attempts` | `integer` | Maximum attempts before marking failed (default 8) | | `next_attempt_at` | `string (RFC 3339)` | When the next attempt is scheduled | | `last_attempt_at` | `string (RFC 3339)` | When the last attempt was made | | `last_response_status` | `integer` | HTTP status code from the last attempt | | `last_response_body` | `string` | Response body from the last attempt (truncated to 4 KB) | | `last_error` | `string` | Error message for network-level failures | | `delivered_at` | `string (RFC 3339)` | When successfully delivered | | `created_at` | `string (RFC 3339)` | Creation timestamp | | `updated_at` | `string (RFC 3339)` | Last modification timestamp | ### Status Values | Status | Description | | ----------- | ---------------------------------------------------------- | | `PENDING` | Awaiting delivery attempt | | `SENDING` | Claimed by the delivery worker | | `DELIVERED` | Successfully delivered (2xx response) | | `FAILED` | Permanently failed (4xx response or max attempts exceeded) | See the [Webhooks guide](/guides/webhooks) for usage patterns. Browse the [Webhooks endpoints](/api-reference/webhooks/list-webhook-deliveries). *** ## Request Log A record of an API request made to your organization. Useful for debugging, auditing, and monitoring. ### Key Fields | Field | Type | Description | | ------------- | ------------------- | ---------------------------------- | | `id` | `string (UUID)` | Scrip-assigned identifier | | `method` | `string` | HTTP method (`GET`, `POST`, etc.) | | `path` | `string` | Request path | | `status_code` | `integer` | HTTP response status code | | `duration_ms` | `integer` | Request duration in milliseconds | | `auth_type` | `string` | Authentication method used | | `request_id` | `string` | Value of the `X-Request-ID` header | | `created_at` | `string (RFC 3339)` | Request timestamp | See the [Reporting guide](/guides/reporting#request-logs) for usage patterns. Browse the [Logs endpoints](/api-reference/logs/list-request-logs). # Get an event Source: https://docs.scrip.dev/api-reference/events/get-an-event GET /v1/events/{id} Returns a single event by ID, including processing status and rule evaluation results. Returns a single event by ID. The response includes the full payload, processing `status`, the list of rule evaluations that fired, and error details if the event failed. The `rule_evaluations` array shows which rules matched, which actions executed, and the resolved values for each action. Each evaluation includes a `rule_history_id` linking to the exact rule version that was active at evaluation time. For failed events, the `error_message` field contains the failure reason. Pass `include_skipped=true` to include rule evaluations with `SKIPPED_ERROR` or `SKIPPED_TIMEOUT` status. These are excluded by default. Other skipped evaluations (budget exceeded, outside time window, stopped by a prior rule) are always included. For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Get event by idempotency key Source: https://docs.scrip.dev/api-reference/events/get-event-by-idempotency-key GET /v1/events/by-key Look up an event by its idempotency key and program ID. Looks up an event by its `idempotency_key` and `program_id`. Both are required as query parameters because idempotency keys are scoped per program. This is the preferred lookup method when you have the key you used at ingestion time but not the event ID. It returns the same event object as the [get-by-ID endpoint](/api-reference/events/get-an-event), including processing status and rule evaluation results. Pass `include_skipped=true` to include rule evaluations with `SKIPPED_ERROR` or `SKIPPED_TIMEOUT` status. For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Get event impact Source: https://docs.scrip.dev/api-reference/events/get-event-impact GET /v1/events/{id}/impact Returns the full causal chain for a processed event: rule evaluations, journal entries with postings, state changes, and per-entity balance impact. Returns the full causal chain for a processed event. The response includes rule evaluations, journal entries with their double-entry postings, state changes (tags, counters, attributes, tiers), and an aggregated per-entity balance impact summary. Use this to understand exactly what an event did: which rules matched, what ledger movements occurred, what state changed, and the net effect on each entity's balance. State changes include the `rule_id` that caused them, linking every tag, counter, attribute, and tier change back to the specific rule. The `balance_impact` array aggregates all postings into net changes per entity, asset, and bucket. Zero-sum entries (where credits and debits cancel out) are omitted. System accounts (`SYSTEM_ISSUANCE`, `SYSTEM_BREAKAGE`, `SYSTEM_REDEMPTION`) appear as counterparties to participant and group movements. Events still in `RECEIVED`, `PENDING`, or `PROCESSING` status return the event metadata with empty arrays for all impact fields. This endpoint is most useful once the event reaches `COMPLETED` or `FAILED`. For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Ingest an event Source: https://docs.scrip.dev/api-reference/events/ingest-an-event POST /v1/events Submit an event for asynchronous rule evaluation. The idempotency_key is the logical event identity scoped to the program: reusing the same key returns the same deterministic event_id and does not create another event, even if the payload differs. To correct or replace an event, submit a new event with a new idempotency_key. Existing participants are automatically enrolled in the target program. Inactive enrollments are reactivated. Business validation happens asynchronously — invalid submissions may not materialize as events. Subscribe to event.failed webhooks for error notification. Submits an event for asynchronous rule evaluation. The API returns `202 Accepted` immediately. A worker picks up the event, evaluates all matching rules, and executes their actions. Identify the participant with exactly one of `external_id` or `participant_id`. Pass `event_timestamp` for when the event occurred and `event_data` containing the payload your rules will evaluate against. Optionally set `recipient_id` or `recipient_external_id` to route rewards to a different participant (e.g. gifting). The `idempotency_key` is required and scoped per program. Submitting the same key with an identical payload returns the original event without reprocessing. Submitting the same key with a different payload returns `409 Conflict` (`idempotency_conflict`). Use deterministic keys like `order-12345-completed`, not random UUIDs. If the participant doesn't exist yet and the program's `on_unknown_participant` is `CREATE`, Scrip creates the participant and processes the event in one step. The `on_unknown_participant` setting controls creation of new participants only. Existing participants are automatically enrolled in the target program if not already members. Inactive enrollments (`FROZEN`, `LOCKED`, or `CLOSED`) are reactivated. Enrollment behavior applies regardless of the `on_unknown_participant` setting. Business validation (program existence, participant resolution) happens asynchronously. Subscribe to `event.failed` webhooks for error notification. For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Ingest events in batch Source: https://docs.scrip.dev/api-reference/events/ingest-events-in-batch POST /v1/events/batch Submit up to 100 events in a single request. Each event is validated independently; invalid events are rejected synchronously while valid events are accepted for asynchronous processing. Subscribe to event.failed webhooks for async error notification. Submits up to 100 events in a single request. Each event in the batch is validated independently. Individual events can succeed or fail without affecting the others. The response returns `202 Accepted` with `total`, `success_count`, `error_count`, and a `results` array in the same order as the input. Each result has a `status` of `accepted` or `error`. Processing happens asynchronously after acceptance. Batch ingestion follows the same semantics as single-event ingestion. Each event requires an `idempotency_key` scoped to its `program_id`. Duplicate keys with identical payloads return the original event; duplicate keys with different payloads return an error for that item. Check individual event statuses via the [get event endpoint](/api-reference/events/get-an-event) or by polling the [list endpoint](/api-reference/events/list-events) with the relevant filters. For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # List events Source: https://docs.scrip.dev/api-reference/events/list-events GET /v1/events List events for your organization. Returns a paginated list of events. Filter by `program_id`, `status`, `event_type`, `participant_id`, `external_id`, or `rule_id`. Use `from` / `to` to scope by ingestion time (`created_at`), and `event_from` / `event_to` to scope by occurrence time (`event_timestamp`). Both pairs can be combined. Results are sorted by `created_at` descending by default. Set `sort_by=event_timestamp` to sort by when events occurred rather than when they were received. Each event includes its processing `status` (`RECEIVED`, `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED`) and `event_type` (`EXTERNAL` for API-ingested events, `SYSTEM` for internally generated ones like scheduled events). For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Events Source: https://docs.scrip.dev/api-reference/events/overview Signals from your app that trigger rule evaluation An event is a signal from your application that triggers rule evaluation for a participant. Events process asynchronously and are deduplicated by `idempotency_key` within a program. ## Endpoints * **Ingest**: submit a single event or a batch of up to 100 * **Get**: retrieve an event by `id` or `idempotency_key` * **Impact**: get the full causal chain for a processed event (rule evaluations, journal entries, state changes, balance impact) * **List**: filter events by program, participant, status, event type, and time window * **Retry**: requeue a `FAILED` event for reprocessing For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Retry a failed event Source: https://docs.scrip.dev/api-reference/events/retry-a-failed-event POST /v1/events/{id}/retry Reset a `FAILED` event to `PENDING` for reprocessing. Requeues a `FAILED` event for reprocessing. The event is returned to `PENDING` status for a fresh set of processing attempts with exponential backoff. Only events in `FAILED` status can be retried. Events in any other status (`RECEIVED`, `PENDING`, `PROCESSING`, `COMPLETED`) return `409 Conflict`. This is useful after fixing the underlying issue (such as a misconfigured rule or a missing participant) that caused the original failure. For usage patterns and examples, see the [Event Processing guide](/guides/event-processing). # Add a member to a group Source: https://docs.scrip.dev/api-reference/groups/add-a-member-to-a-group POST /v1/groups/{id}/members Add a member to a group. Adds one or more participants to a group. Each entry specifies a participant ID and an optional `role`, which defaults to `MEMBER` if omitted. The other valid role is `ADMIN`. If a participant is already an active member of the group, the request will fail for that participant. Previously removed members (status `LEFT`) can be re-added with a new role assignment. For usage patterns and examples, see the [Groups guide](/guides/groups). # Add a tag to a group Source: https://docs.scrip.dev/api-reference/groups/add-a-tag-to-a-group PUT /v1/groups/{id}/state/tags/{tag} Add a tag to the group. Idempotent. Adds a single tag to a group. Tags are normalized to lowercase, so `VIP` and `vip` are treated as the same tag. Adding a tag that already exists on the group is a no-op and returns successfully. Tags are part of a group's state and can be referenced in rule conditions using CEL expressions. They are useful for segmentation and for controlling which rules apply to a group. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Adjust group balance Source: https://docs.scrip.dev/api-reference/groups/adjust-group-balance POST /v1/groups/{id}/balances/adjust Manually credit or debit a group's balance. Credits or debits a group's balance for a specific asset within a program. Set `type` to `CREDIT` or `DEBIT` and provide a positive `amount`. A `DEBIT` fails if the group does not have sufficient balance in the target bucket. You must specify `program_id` and `asset_id` to identify which balance to adjust. Each group maintains separate balances per program-asset pair. The adjustment is atomic and creates a journal entry for auditability. For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Create a group Source: https://docs.scrip.dev/api-reference/groups/create-a-group POST /v1/groups Create a participant group with initial members. At least one `ADMIN` member is required. Creates a new group with a `name` and an initial set of `members`. Each member references an existing participant by ID and is assigned a `role` of `ADMIN` or `MEMBER`. At least one member must have the `ADMIN` role; the request will fail otherwise. Groups exist at the organization level and can participate across multiple programs. Once created, a group can hold balances, receive events, and be targeted by rules independently of its individual members. For usage patterns and examples, see the [Groups guide](/guides/groups). # Delete a group Source: https://docs.scrip.dev/api-reference/groups/delete-a-group DELETE /v1/groups/{id} Soft-delete (archive) a group. Archives a group. This is a soft delete: the group's status changes to `ARCHIVED`, and it is excluded from list results by default. Pass `include_archived=true` on the [list endpoint](/api-reference/groups/list-groups) to see archived groups. Archived groups retain their data but cannot be modified. Balances, members, and state are preserved for auditing. For usage patterns and examples, see the [Groups guide](/guides/groups). # Delete a group counter Source: https://docs.scrip.dev/api-reference/groups/delete-a-group-counter DELETE /v1/groups/{id}/state/counters/{key} Remove a counter from the group. Removes a counter from a group entirely. The counter key is specified in the URL path. Once deleted, the counter no longer appears in the group's state. Deleting a counter that does not exist returns a successful response. If a rule action later references this counter, the counter will be re-created starting from zero. This means deletion is not a permanent block on the key, just a reset of the current state. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Forfeit group balance Source: https://docs.scrip.dev/api-reference/groups/forfeit-group-balance POST /v1/groups/{id}/balances/forfeit Moves group balance to breakage (system account). Permanently removes balance from a group by moving it to breakage. This is irreversible. This uses the same request body as the participant forfeit endpoint. See [forfeit participant balance](/api-reference/participants/forfeit-participant-balance) for field details. # Get a group Source: https://docs.scrip.dev/api-reference/groups/get-a-group GET /v1/groups/{id} Retrieve a group by ID, including current balances. Returns a single group by its ID, including current balances per asset. The response contains `id`, `name`, `status`, timestamps, and a `balances` map. Members, tags, attributes, and counters are not included in this response. Use the dedicated [members](/api-reference/groups/list-group-members), [tags](/api-reference/groups/get-group-tags), [attributes](/api-reference/groups/get-group-attributes), and [counters](/api-reference/groups/get-group-counters) endpoints to retrieve those. Archived groups are still retrievable by ID. For usage patterns and examples, see the [Groups guide](/guides/groups). # Get group attribute Source: https://docs.scrip.dev/api-reference/groups/get-group-attribute GET /v1/groups/{id}/state/attributes/{key} Returns a single attribute by key on a group. Returns a single attribute by key. The key is passed as a path parameter. If the key does not exist, returns a `404`. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get group attributes Source: https://docs.scrip.dev/api-reference/groups/get-group-attributes GET /v1/groups/{id}/state/attributes Returns all attributes on a group. Returns all key-value attributes on a group. Attributes are string key-value pairs used to store group metadata, accessible in CEL rule conditions as `group.attributes.{key}`. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get group balances Source: https://docs.scrip.dev/api-reference/groups/get-group-balances GET /v1/groups/{id}/balances Returns all balances for a group. Returns all balances for a group as a map of asset ID to total balance amount. Use the optional `program_id` query parameter to filter balances to a specific program. To move balance between buckets, use the [hold](/api-reference/groups/hold-group-balance), [release](/api-reference/groups/release-group-balance), and [forfeit](/api-reference/groups/forfeit-group-balance) endpoints. For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Get group counter Source: https://docs.scrip.dev/api-reference/groups/get-group-counter GET /v1/groups/{id}/state/counters/{key} Returns a single counter by key on a group. Returns a single counter by key, including its effective value. The key is passed as a path parameter. For counters with `reset_after` configured, the returned value accounts for the reset window. If the time since `last_reset_at` exceeds `reset_after`, the effective value returned is 0. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get group counters Source: https://docs.scrip.dev/api-reference/groups/get-group-counters GET /v1/groups/{id}/state/counters Returns all counters on a group. Returns all counters for a group as a map of counter key to current value. Counters are numeric accumulators that can increment by any amount, not just `+1`. To see reset configuration for a specific counter, use the [get counter](/api-reference/groups/get-group-counter) endpoint. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get group state history Source: https://docs.scrip.dev/api-reference/groups/get-group-state-history GET /v1/groups/{id}/activity/state-history Returns a best-effort unified timeline of state changes (tags, counters, attributes, tiers). Tier entries are recorded alongside the canonical tier history; for authoritative tier transition details use the dedicated tier history endpoint. Returns a unified timeline of state changes for a group, including tags, counters, attributes, and tiers. Each entry includes: * **`state_type`**: what changed (`tag`, `attribute`, `counter`, or `tier`) * **`key`**: the specific key that was modified * **`operation`**: `set` or `delete` * **`old_value`** / **`new_value`**: the value before and after the change * **`event_id`**: present when the change was triggered by a rule during event processing * **`changed_by_api_key_id`**: present when the change was made via a direct API call Use the `state_type` and `key` query parameters to filter results. Filter by `state_type=tier` and provide a `program_id` to isolate tier transitions. For usage patterns and examples, see the [Groups guide](/guides/groups). # Get group tags Source: https://docs.scrip.dev/api-reference/groups/get-group-tags GET /v1/groups/{id}/state/tags Returns all tags on a group. Returns all tags on a group as an array of strings. Tags are labels used for segmentation and rule conditions, accessible in CEL as `group.tags`. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Hold group balance Source: https://docs.scrip.dev/api-reference/groups/hold-group-balance POST /v1/groups/{id}/balances/hold Moves group balance from AVAILABLE to HELD. Moves balance from `AVAILABLE` to `HELD` for a group. Held balance cannot be spent or forfeited until it is released back to `AVAILABLE`. For `LOT` mode assets, you can include an optional `reference_id` to correlate this hold with a future release. See [hold participant balance](/api-reference/participants/hold-participant-balance) for full field details including `reference_id` usage. # List group members Source: https://docs.scrip.dev/api-reference/groups/list-group-members GET /v1/groups/{id}/members List members in a group. Returns the members of a group, including each member's participant ID, `role` (`ADMIN` or `MEMBER`), and `status`. Results are paginated. Former members who have been removed are assigned a `LEFT` status and are excluded from the response by default. To include them, pass `include_former=true`. This is useful for audit trails or understanding group membership history. For usage patterns and examples, see the [Groups guide](/guides/groups). # List groups Source: https://docs.scrip.dev/api-reference/groups/list-groups GET /v1/groups List all groups for your organization. Returns a paginated list of all groups in the organization. Use `search` to find groups by name, and `sort_by` / `sort_dir` to control ordering. Archived groups are excluded by default. Groups exist at the organization level and are not scoped to a single program, so this endpoint returns all groups regardless of which programs they participate in. For usage patterns and examples, see the [Groups guide](/guides/groups). # Groups Source: https://docs.scrip.dev/api-reference/groups/overview Collections of participants for team-based incentives A group is a collection of participants that shares its own wallet and state. Groups exist at the organization level and can participate across programs. Members have a `role` of `MEMBER` or `ADMIN`. ## Endpoints * Create, list, get, update, and delete groups * Add and remove members with roles * Adjust group-level balances per asset and program * Read and write group tags, counters, and attributes For usage patterns and examples, see the [Groups guide](/guides/groups). # Release group balance Source: https://docs.scrip.dev/api-reference/groups/release-group-balance POST /v1/groups/{id}/balances/release Moves group balance from HELD to AVAILABLE. Moves balance from `HELD` back to `AVAILABLE` for a group, making it spendable again. Supports `reference_id` for targeted release of correlated holds on `LOT` mode assets. See [release participant balance](/api-reference/participants/release-participant-balance) for full field details including `reference_id` usage. # Remove a member from a group Source: https://docs.scrip.dev/api-reference/groups/remove-a-member-from-a-group DELETE /v1/groups/{id}/members/{participantId} Remove a member from a group. Removes a participant from a group. This is a soft delete: the member's status changes to `LEFT`, and they no longer appear in the default member listing. The member record is preserved for audit purposes. Removing a member does not affect the group's balance or any transactions already associated with the group. The last `ADMIN` of a group cannot be removed. If you need to remove the final admin, first [promote another member](/api-reference/groups/update-group-member-role) to `ADMIN`. For usage patterns and examples, see the [Groups guide](/guides/groups). # Remove a tag from a group Source: https://docs.scrip.dev/api-reference/groups/remove-a-tag-from-a-group DELETE /v1/groups/{id}/state/tags/{tag} Remove a tag from the group. Idempotent. Removes a single tag from a group. The tag is specified in the URL path and is matched case-insensitively (tags are stored as lowercase). Removing a tag that does not exist on the group returns a successful response. Any rule conditions that reference the removed tag will no longer match for this group on subsequent evaluations. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Remove group attribute Source: https://docs.scrip.dev/api-reference/groups/remove-group-attribute DELETE /v1/groups/{id}/state/attributes/{key} Removes a single attribute by key from a group. Removes a single attribute from a group by key. The [update attributes endpoint](/api-reference/groups/update-group-attributes) (PATCH) can overwrite values but cannot remove keys. If the key does not exist, the call returns successfully as a no-op. Returns `204 No Content` on success. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Set a group attribute Source: https://docs.scrip.dev/api-reference/groups/set-a-group-attribute PUT /v1/groups/{id}/state/attributes/{key} Set a single attribute on the group. Sets a single attribute on a group by key. If the key already exists, its value is overwritten. If the key does not exist, it is created. The key is specified in the URL path. The request body contains the `value` to set. This endpoint is useful when you want to update exactly one attribute without sending the full attribute map. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Set a group counter Source: https://docs.scrip.dev/api-reference/groups/set-a-group-counter PUT /v1/groups/{id}/state/counters/{key} Set a counter on the group. Sets a counter value on a group directly. The counter is identified by its key in the URL path. If the counter does not exist, it is created. If it already exists, its value is overwritten with the provided amount. This is an absolute set, not an increment. You can optionally include `reset_after` (a Go duration string like `"24h"` or `"720h"`) to configure auto-reset. Pass an empty string to clear an existing reset interval. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Set group tags Source: https://docs.scrip.dev/api-reference/groups/set-group-tags PUT /v1/groups/{id}/state/tags Replaces all tags on a group. Replaces all tags on a group with the provided list. Any existing tags not in the new list are removed. To add or remove individual tags without replacing the full set, use the [add](/api-reference/groups/add-a-tag-to-a-group) or [remove](/api-reference/groups/remove-a-tag-from-a-group) tag endpoints instead. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Update a group Source: https://docs.scrip.dev/api-reference/groups/update-a-group PATCH /v1/groups/{id} Update a group. Updates mutable fields on a group, such as its `name`. Only the fields you include in the request body are changed; omitted fields remain untouched. Archived groups cannot be updated. Archiving is permanent; there is no unarchive operation. For usage patterns and examples, see the [Groups guide](/guides/groups). # Update group attributes Source: https://docs.scrip.dev/api-reference/groups/update-group-attributes PATCH /v1/groups/{id}/state/attributes Merge key-value pairs into the group's attributes. Merges a set of key-value attributes onto a group. Existing keys included in the request are overwritten with the new values. New keys are added. Keys not mentioned in the request body are left unchanged. This is a partial update. To remove an attribute entirely, use the [remove attribute endpoint](/api-reference/groups/remove-group-attribute). Attribute values are strings. They are available in rule conditions via CEL. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Update group member role Source: https://docs.scrip.dev/api-reference/groups/update-group-member-role PATCH /v1/groups/{id}/members/{participantId} Updates a member's role in a group. Updates a member's role within a group. Valid roles are `MEMBER` and `ADMIN`. A group must always have at least one `ADMIN`. Attempting to demote the last admin returns an error. # Introduction Source: https://docs.scrip.dev/api-reference/introduction Base URL, request and response format, pagination, idempotency, and error conventions The Scrip API is a REST API. All requests and responses use JSON. The current version is `v1`. ```bash theme={null} https://api.scrip.dev/v1 ``` If you're new to Scrip, start with the [Quickstart](/quickstart) to create a program and process your first event, or read [Core Concepts](/guides/core-concepts) for an overview of the data model. *** ## Authentication Every request requires an API key in the `Authorization` header: ```bash theme={null} curl https://api.scrip.dev/v1/programs \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" ``` Keys use the `sk_` prefix and have full read/write access to all resources in your organization. You can also pass the key via the `X-API-Key` header. Create and manage keys from the [Scrip dashboard](https://app.scrip.dev). See the [Authentication](/authentication) page for details on key management and rate limits. *** ## Conventions | Convention | Detail | | -------------- | ------------------------------------------------------ | | Property names | `snake_case` | | IDs | UUIDs (`550e8400-e29b-41d4-a716-446655440000`) | | Timestamps | RFC 3339 (`2024-01-15T10:30:00Z`) | | Amounts | Strings to preserve decimal precision (`"100.00"`) | | Content type | `application/json` for all request and response bodies | *** ## Idempotency Events, balance operations (hold, release, forfeit), redemptions, reversals, and transfers accept an `idempotency_key` field. Use deterministic keys derived from your domain data (`order-12345-completed`), not random UUIDs. All resource types behave the same way: * **Same key + same payload:** returns the original response without reprocessing. Events return `202`, all other operations return `200`. * **Same key + different payload:** returns `409 Conflict` with code `idempotency_conflict`. Keys are scoped to `program_id`. Payload comparison uses a SHA-256 hash of semantic fields. Cosmetic differences (JSON key order, trailing decimal zeros, whitespace) are normalized before hashing, so they will not trigger a conflict. *** ## Pagination List endpoints return paginated results using cursor-based pagination: ```json theme={null} { "data": [ { "id": "...", "name": "..." } ], "pagination": { "has_more": true, "next_cursor": "eyJ..." } } ``` Pass `cursor` as a query parameter to fetch the next page. Use `limit` to control page size (default 50, max 200). Pagination is stable across concurrent modifications. Inserts and deletes between pages do not cause skipped or duplicated results. *** ## Filtering, sorting, and search Most list endpoints support query parameters for filtering and ordering results. Available parameters vary by endpoint and are documented in each endpoint's parameter table. ### Filtering Filter by resource status or related IDs: ```bash theme={null} GET /v1/participants?status=ACTIVE&program_id=550e8400-... GET /v1/events?status=COMPLETED&from=2025-01-01T00:00:00Z&to=2025-02-01T00:00:00Z ``` Time-range filters (`from`, `to`) accept RFC 3339 timestamps and filter on the resource's creation time. `from` is required when `to` is provided, and `from` must be before `to`. ### Sorting Control result ordering with `sort_by` and `sort_dir`: ```bash theme={null} GET /v1/programs?sort_by=name&sort_dir=asc GET /v1/events?sort_by=event_timestamp&sort_dir=desc ``` Each endpoint defines its own set of sortable fields (e.g., `created_at`, `name`, `order`). The default sort is typically `created_at` descending. Rules default to `order` ascending. ### Search Search uses case-insensitive partial matching. The searched field varies by resource: | Resource | Searched fields | | --------------- | ------------------- | | Participants | `external_id` | | Programs, Rules | `name` | | Assets | `name` and `symbol` | ```bash theme={null} GET /v1/participants?search=user_abc GET /v1/rules?search=welcome ``` *** ## Errors Error responses include a machine-readable `code` and a human-readable `message`: ```json theme={null} { "code": "not_found", "message": "Program not found" } ``` Some errors include a `details` object with field-level validation information: ```json theme={null} { "code": "validation_error", "message": "Invalid request body", "details": { "name": "is required", "key": "must be lowercase alphanumeric with hyphens" } } ``` ### Status codes | Code | Meaning | | ----- | --------------------------------------------------------------------------------- | | `200` | Success | | `201` | Resource created | | `202` | Accepted for async processing (events) | | `204` | Success, no content | | `400` | Bad request or validation error | | `401` | Unauthorized | | `403` | Forbidden | | `404` | Resource not found | | `409` | Conflict (state violation, duplicate idempotency key, reversal exceeds remaining) | | `422` | Business rule violation (insufficient funds, inactive resource) | | `429` | Rate limit exceeded | | `500` | Internal server error | ### Error codes The `code` field in error responses is a machine-readable string you can match on programmatically. Common codes by category: #### Validation (400) | Code | Description | | ------------------------- | ---------------------------------------------------------------------------- | | `validation_error` | One or more fields failed validation. Check `details` for field-level errors | | `invalid_request` | Request structure is invalid (e.g., mutually exclusive fields provided) | | `invalid_amount` | Amount is malformed, non-positive, or exceeds asset scale | | `invalid_quantity` | Quantity must be a positive integer | | `asset_not_linked` | Asset is not linked to the specified program | | `reward_program_mismatch` | Reward does not belong to the specified program | #### Authentication & authorization (401 / 403) | Code | Description | | -------------- | -------------------------------------------------------------- | | `unauthorized` | Missing or invalid API key / JWT | | `forbidden` | Valid credentials but insufficient permissions for this action | #### Not found (404) | Code | Description | | ----------- | ------------------------------------- | | `not_found` | The requested resource does not exist | #### Conflict (409) | Code | Description | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `participant_inactive` | Participant is `SUSPENDED` or `CLOSED`. Financial operations and counter updates are blocked for inactive participants | | `idempotency_conflict` | Idempotency key was already used with different parameters | | `already_reversed` | Redemption has already been fully reversed | | `quantity_exceeds_remaining` | Reversal quantity exceeds the remaining reversible units | | `amount_exceeds_remaining` | Reversal amount exceeds the remaining reversible amount | | `max_total_exceeded` | Reward's global inventory limit reached | | `max_per_participant_exceeded` | Reward's per-participant inventory limit reached | | `program_archived` | Program is archived and cannot be modified | | `program_suspended` | Program is suspended and cannot process transactions | | `key_exists` | A tier key or reward name already exists in this program | #### Business rules (422) | Code | Description | | ----------------------- | --------------------------------------------------------------------------------------- | | `insufficient_funds` | Not enough balance for the requested operation | | `asset_archived` | Asset is archived and cannot be used in new operations | | `program_inactive` | Program is not active; events cannot be ingested | | `participant_not_found` | Participant does not exist and the program is configured to reject unknown participants | | `recipient_not_found` | Target participant not found or not enrolled in the program | *** ## Rate limits Requests are rate-limited per organization at 10 requests/second with burst to 30. All API keys in the same organization share one rate limit bucket. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. When exceeded, the API returns `429` with a `Retry-After` header. See [Authentication](/authentication#rate-limits) for the full header reference. *** ## Resources | Resource | Prefix | Description | | ---------------------------------------------------- | ----------------------------------- | ------------------------------------------ | | [Programs](/api-reference/programs/overview) | `/v1/programs` | Top-level containers for incentive logic | | [Assets](/api-reference/assets/overview) | `/v1/assets` | Units of value (points, credits, cashback) | | [Participants](/api-reference/participants/overview) | `/v1/participants` | Users who earn and spend | | [Groups](/api-reference/groups/overview) | `/v1/groups` | Collections of participants | | [Rules](/api-reference/rules/overview) | `/v1/rules` | CEL conditions and reward actions | | [Events](/api-reference/events/overview) | `/v1/events` | Signals that trigger rule evaluation | | [Tiers](/api-reference/tiers/overview) | `/v1/programs/{id}/tiers` | Status hierarchies for participants | | [Redemptions](/api-reference/redemptions/overview) | `/v1/participants/{id}/redemptions` | Balance spend operations | | [Rewards](/api-reference/rewards/overview) | `/v1/programs/{id}/rewards` | Catalog items for redemption | | [Transfers](/api-reference/transfers/overview) | `/v1/transfers` | Value movement between participants | | [Automations](/api-reference/automations/overview) | `/v1/programs/{id}/automations` | Scheduled event generation | | [Reporting](/api-reference/reporting/overview) | `/v1/reports` | Ledger summaries and activity | | [Logs](/api-reference/logs/overview) | `/v1/logs` | Request history and usage metrics | For a complete map of entities and their relationships, see the [Data Model](/api-reference/data-model). # Add tag to participant Source: https://docs.scrip.dev/api-reference/participants/add-tag-to-participant PUT /v1/participants/{id}/state/tags/{tag} Add a tag to a participant. Idempotent. Adds a single tag to a participant. The tag name is passed as a path parameter and is normalized to lowercase. If the tag already exists on the participant, this call is a no-op and returns successfully. This makes the endpoint safe to call repeatedly without side effects. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Adjust participant balance Source: https://docs.scrip.dev/api-reference/participants/adjust-participant-balance POST /v1/participants/{id}/balances/adjust Manually credit or debit a participant's balance. Manually credit or debit a participant's balance. Intended for customer service corrections, manual workflows, and one-off adjustments outside of the rules engine. * **`CREDIT`**: adds funds. No upper bound * **`DEBIT`**: removes funds. Fails if the participant does not have sufficient `available` balance, unless `allow_negative` is `true` (used for clawbacks) Every adjustment creates a journal entry in the participant's transaction history for auditability. For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Create a participant Source: https://docs.scrip.dev/api-reference/participants/create-a-participant POST /v1/participants Create a participant, or update if `external_id` already exists (upsert). Creates a new participant identified by your `external_id`. Tags, attributes, profile fields (`email`, `phone`, `first_name`, `last_name`, `display_name`), and initial `status` can be set in the same call. Status defaults to `ACTIVE`. Counters and tiers cannot be set at creation. They are managed through their dedicated endpoints or automatically by rules during event processing. Pass `program_id` to enroll the participant in a program at creation time. Event ingestion automatically enrolls existing participants in the target program, so pre-enrolling here is optional. If your program's `on_unknown_participant` is set to `CREATE`, new participants are also created automatically on their first event. If a participant with the same `external_id` already exists, the call behaves as an upsert and updates the existing record rather than returning an error. For usage patterns and examples, see the [Participants guide](/guides/participants). # Delete participant counter Source: https://docs.scrip.dev/api-reference/participants/delete-participant-counter DELETE /v1/participants/{id}/state/counters/{key} Remove a counter from a participant. Idempotent. Removes a counter from a participant entirely. The key is passed as a path parameter. Once deleted, the counter no longer appears in the counters list and any rule conditions referencing it will evaluate as if it does not exist. This is a permanent deletion, not a reset. To set a counter back to zero while keeping it defined, use the [set counter endpoint](/api-reference/participants/set-participant-counter) with a value of `0` instead. Returns `204 No Content` on success. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Forfeit participant balance Source: https://docs.scrip.dev/api-reference/participants/forfeit-participant-balance POST /v1/participants/{id}/balances/forfeit Forfeit balance from the specified bucket to the system breakage account. The `bucket` field is required and must be `AVAILABLE` or `HELD`. Unlike other balance operations, forfeit is allowed on closed participants. Removes funds from a participant permanently. Forfeited funds are moved to `SYSTEM_BREAKAGE` and cannot be recovered. Use this for point expiration, policy violations, or cleaning up balances on closed accounts. The `bucket` field is required and must be `AVAILABLE` or `HELD`, specifying which balance bucket to forfeit from. Unlike most balance operations, forfeit is allowed on `CLOSED` participants. This operation is irreversible. If you need to temporarily restrict funds without destroying them, use the [hold endpoint](/api-reference/participants/hold-participant-balance) instead. For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Get a participant Source: https://docs.scrip.dev/api-reference/participants/get-a-participant-by-internal-id GET /v1/participants/{id} Retrieve a participant with full details. Returns a single participant by Scrip `id` with inline state: `balances`, `tags`, `counters`, `attributes`, `tiers`, `program_ids`, and profile fields (`email`, `phone`, `first_name`, `last_name`, `display_name`). To look up a participant by your application's user ID instead, use the [list endpoint](/api-reference/participants/list-or-get-participants) filtered by `external_id`. The [list endpoint](/api-reference/participants/list-or-get-participants) returns a slim response (`id`, `external_id`, `status`, profile fields, timestamps). Use this detail endpoint when you need the full participant state. # Get participant attribute Source: https://docs.scrip.dev/api-reference/participants/get-participant-attribute GET /v1/participants/{id}/state/attributes/{key} Retrieve a single attribute by key. Returns a single attribute by its key. The key is passed as a path parameter. This is useful when you need one specific attribute value without fetching the full attribute set. If the key does not exist, the endpoint returns a 404. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get participant attributes Source: https://docs.scrip.dev/api-reference/participants/get-participant-attributes GET /v1/participants/{id}/state/attributes Retrieve all attributes for a participant. Returns all key-value attributes on a participant. Attributes are string key-value pairs used to store participant metadata. Values can represent any data type (strings, numbers, or dates) and are accessible in CEL rule conditions as `participant.attributes.{key}`. Date-formatted attributes (e.g. `birthday: "06-15"` or `signup_date: "2024-01-15"`) can also drive automations via the `ATTRIBUTE_DATE` schedule type. Both rules and direct API calls can modify attributes, and the [state history endpoint](/api-reference/participants/get-participant-state-history) tracks every change with its source. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get participant balances Source: https://docs.scrip.dev/api-reference/participants/get-participant-balances GET /v1/participants/{id}/balances Returns current balances for a participant, broken down by bucket. Returns balances for all assets held by a participant. Each asset's balance is split into two buckets: * **`available`**: funds the participant can spend * **`held`**: funds reserved by hold operations (e.g. pending settlements, fraud review). Not spendable until released The sum of `available` and `held` gives the participant's total balance for a given asset. For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Get participant counter Source: https://docs.scrip.dev/api-reference/participants/get-participant-counter GET /v1/participants/{id}/state/counters/{key} Retrieve a single counter by key. Returns a single counter by key, including its effective value. The key is passed as a path parameter. For counters with `reset_after` configured, the returned value accounts for the reset window. If the time since `last_reset_at` exceeds `reset_after`, the effective value returned is 0 regardless of the stored value. This lets you read counters without worrying about stale data from a previous period. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get participant counters Source: https://docs.scrip.dev/api-reference/participants/get-participant-counters GET /v1/participants/{id}/state/counters Retrieve all counters for a participant. Returns all counters for a participant. Counters are numeric accumulators that can increment by any amount, not just `+1`. A single counter can track simple counts (e.g. logins, referrals) or running totals (e.g. total purchase volume, monthly spend) by incrementing with `event.amount` in a rule action. Each counter returns its current `value` along with its key. Counters that have auto-reset configured include `reset_after` (the reset interval) and `last_reset_at` (when the counter last reset). If the reset window has elapsed since `last_reset_at`, the effective value is 0 even though the stored value has not yet been cleared. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get participant state history Source: https://docs.scrip.dev/api-reference/participants/get-participant-state-history GET /v1/participants/{id}/activity/state-history Returns a best-effort unified timeline of state changes (tags, counters, attributes, tiers). Tier entries are recorded alongside the canonical tier history; for authoritative tier transition details use the dedicated tier history endpoint. Returns a unified timeline of state changes for a participant, including tags, counters, attributes, and tiers. Each entry includes: * **`state_type`**: what changed (`tag`, `attribute`, `counter`, or `tier`) * **`key`**: the specific key that was modified * **`operation`**: `set` or `delete` * **`old_value`** / **`new_value`**: the value before and after the change * **`event_id`**: present when the change was triggered by a rule during event processing * **`changed_by_api_key_id`**: present when the change was made via a direct API call Filter by `state_type` and `key` to narrow results. Use `state_type=tier` with a `program_id` to isolate tier transitions. Filter by `from` / `to` on `changed_at` (when the state change was recorded), or by `event_from` / `event_to` on the originating event's `event_timestamp`. Entries without an event fall back to their `changed_at`. Both pairs can be used simultaneously (AND semantics). For usage patterns and examples, see the [Participants guide](/guides/participants). # Get participant tags Source: https://docs.scrip.dev/api-reference/participants/get-participant-tags GET /v1/participants/{id}/state/tags Retrieve all tags for a participant. Returns all tags on a participant. Tags are boolean flags with no associated value. They are normalized to lowercase, so `VIP` and `vip` are treated as the same tag. Tags are commonly used as conditions in rules (e.g., checking whether a participant has an `early-adopter` tag before applying a bonus). Both rules and direct API calls can set tags, and the [state history endpoint](/api-reference/participants/get-participant-state-history) records the source of each change. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Get participant transaction history Source: https://docs.scrip.dev/api-reference/participants/get-participant-transaction-history GET /v1/participants/{id}/activity/history Retrieve transaction history for a participant. Returns a chronological list of balance-affecting operations for a participant. This includes credits, debits, holds, releases, and forfeits, each with full journal entry details such as amount, asset, timestamp, and source. Results are paginated and ordered by `created_at`. Filter by `from` / `to` on `created_at` (when the journal entry was recorded), or by `event_from` / `event_to` on the originating event's `event_timestamp`. Entries without an event fall back to their `created_at`. Both pairs can be used simultaneously (AND semantics). For usage patterns and examples, see the [Participants guide](/guides/participants). # Hold participant balance Source: https://docs.scrip.dev/api-reference/participants/hold-participant-balance POST /v1/participants/{id}/balances/hold Move balance from `AVAILABLE` to `HELD` bucket. Moves funds from `AVAILABLE` to `HELD` for a participant. Held funds are not spendable and remain reserved until explicitly released or forfeited. Common use cases include authorization holds, fraud review, and pending approval workflows. For assets in `LOT` mode, hold operations preserve lot-level metadata such as `expires_at` and `matures_at`. The hold will fail if the participant does not have sufficient `AVAILABLE` balance. ### Hold/Release Correlation (`reference_id`) For `LOT` mode assets, you can include an optional `reference_id` to correlate this hold with a future release. Held lots are stamped with the reference, and a subsequent release using the same `reference_id` will target only those lots. This is useful when a participant has multiple concurrent holds (e.g., multiple pending orders). * Format: 1-255 characters, alphanumeric plus `._:@-` * Only supported on `LOT` mode assets * Not supported for forfeit operations * The `reference_id` is visible on lot responses and journal entries For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # List participants Source: https://docs.scrip.dev/api-reference/participants/list-or-get-participants GET /v1/participants Returns a paginated list of participants. When `external_id` is provided, it is treated as an exact-match filter and the response shape remains the same list envelope. Returns a paginated list of participants. Filter by `status` or `program_id`, use `search` for partial matching on `external_id`, `email`, `phone`, `first_name`, `last_name`, or `display_name`, and `from` / `to` to scope by enrollment date. When `external_id` is provided, the list is filtered to the single matching participant. The response shape stays the same (paginated list with `data[]`), so client code does not need to branch on the query parameters used. The list response includes `id`, `external_id`, `status`, profile fields, and timestamps. To get the full state (`balances`, `tags`, `counters`, `attributes`, `tiers`), use the [detail endpoint](/api-reference/participants/get-a-participant-by-internal-id). # List participant events Source: https://docs.scrip.dev/api-reference/participants/list-participant-events GET /v1/participants/{id}/activity/events Retrieve events for a participant. Returns events that have been processed for a participant. Each event represents an external action (such as a purchase or login) that was sent to Scrip and evaluated against the program's rules. Filter by `program_id` or `status`, and use `from` / `to` to scope results by ingestion time (`created_at`). Use `event_from` / `event_to` to filter by occurrence time (`event_timestamp`) instead. Both pairs can be used simultaneously (AND semantics). See [Timestamps](/guides/event-processing#timestamps) for the distinction. For usage patterns and examples, see the [Participants guide](/guides/participants). # List participant lots Source: https://docs.scrip.dev/api-reference/participants/list-participant-lots GET /v1/participants/{id}/balances/lots List lots for a participant by asset. Only applicable to `LOT` inventory mode assets. Returns individual lots for a `LOT` mode asset belonging to a participant. Only applies to assets configured in `LOT` mode — for `SIMPLE` mode assets, use the balances endpoint instead. Each lot includes: * **`remaining`**: balance still available in this lot * **`status`**: `DEFERRED`, `AVAILABLE`, `HELD`, `CONSUMED`, or `EXPIRED` * **`reference_id`**: correlation ID linking this held lot to a hold operation, if set * **`expires_at`**: when this lot expires, if set * **`matures_at`**: when this lot becomes spendable, if set Filter by `status`, `expires_before`, `expires_after`, and `reference_id` to find lots nearing expiration, isolate held lots, or identify lots belonging to a specific hold. For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Participants Source: https://docs.scrip.dev/api-reference/participants/overview Users in your system who earn and spend value A participant represents a user in your system, identified by your own `external_id`. Participants carry profile information (`email`, `phone`, `first_name`, `last_name`, `display_name`) and state that rules read and write: `tags`, `counters`, and `attributes`. ## Endpoints * Create, list, and look up participants by `external_id` or internal `id` * Set and update profile fields and metadata * Adjust, hold, release, and forfeit balances per asset * Read and write tags, counters, and attributes * Query transaction history, event activity, and state change history For usage patterns and examples, see the [Participants guide](/guides/participants). # Release participant balance Source: https://docs.scrip.dev/api-reference/participants/release-participant-balance POST /v1/participants/{id}/balances/release Move balance from `HELD` back to `AVAILABLE` bucket. Moves funds from `HELD` back to `AVAILABLE`, making them spendable again. Use this to reverse authorization holds, clear fraud reviews, or complete approval workflows. If `amount` is omitted, all held funds for the specified asset are released. For `LOT` mode assets, you can narrow the scope with `lot_ids`, `earned_from`, `earned_to`, or `reference_id` to target specific lots. ### Release by `reference_id` When `reference_id` is provided, only lots stamped with that reference during a previous hold are targeted. This enables precise release of specific holds when a participant has multiple concurrent holds. The typical settlement pattern is to release the entire hold by reference (omit `amount`) and then debit the actual settlement amount separately. The settlement amount does not need to match the original hold. | `amount` | `reference_id` | Behavior | | -------- | -------------- | ----------------------------------------------------------------------------- | | Omitted | Set | Release **all** HELD lots matching the reference (recommended for settlement) | | Set | Set | Release up to `amount` from HELD lots matching the reference | | Set | Omitted | Release up to `amount` from any HELD lots (FIFO) | | Omitted | Omitted | Release all HELD lots for the asset | For usage patterns and examples, see the [Balance Operations guide](/guides/balance-operations). # Remove participant attribute Source: https://docs.scrip.dev/api-reference/participants/remove-participant-attribute DELETE /v1/participants/{id}/state/attributes/{key} Remove a single attribute by key. Removes a single attribute from a participant by key. The key is passed as a path parameter. This is the only way to delete an attribute entirely. The [update attributes endpoint](/api-reference/participants/update-participant-attributes) (PATCH) can overwrite values but cannot remove keys. If the key does not exist, the call returns successfully as a no-op. Returns `204 No Content` on success. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Remove tag from participant Source: https://docs.scrip.dev/api-reference/participants/remove-tag-from-participant DELETE /v1/participants/{id}/state/tags/{tag} Remove a tag from a participant. Removes a single tag from a participant. The tag name is passed as a path parameter. If the tag does not exist on the participant, the call is a no-op. Returns `204 No Content` on success. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Set participant attribute Source: https://docs.scrip.dev/api-reference/participants/set-participant-attribute PUT /v1/participants/{id}/state/attributes/{key} Set a single attribute by key. Sets a single attribute on a participant. The key is passed as a path parameter and the value is provided in the request body. If the key already exists, its value is overwritten. If it does not exist, it is created. Values are stored as strings but can represent dates (e.g. `"06-15"` or `"2024-01-15"`) for use with `ATTRIBUTE_DATE` automations. This endpoint is idempotent. Calling it multiple times with the same key and value produces the same result. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Set participant counter Source: https://docs.scrip.dev/api-reference/participants/set-participant-counter PUT /v1/participants/{id}/state/counters/{key} Set a counter on a participant. Sets a counter to a specific value. You can also configure `reset_after` in the same call to enable auto-reset behavior. The key is passed as a path parameter. This endpoint sets the counter to an absolute value, unlike the COUNTER rule action which increments the existing value. Use this for manual corrections or initializing counters from an external data source. If the counter does not exist, it is created. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Set participant tags Source: https://docs.scrip.dev/api-reference/participants/set-participant-tags PUT /v1/participants/{id}/state/tags Replace all tags for a participant. Replaces all tags on a participant with the provided set. This is a full replacement, not a merge. Any tags not included in the request body are removed. If you need to add or remove a single tag without affecting the others, use the individual [add](/api-reference/participants/add-tag-to-participant) or [remove](/api-reference/participants/remove-tag-from-participant) tag endpoints instead. This endpoint is best suited for bulk tag synchronization from an external system. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Update a participant Source: https://docs.scrip.dev/api-reference/participants/update-a-participant PATCH /v1/participants/{id} Partially update a participant. Updates a participant's `external_id`, `attributes`, `tags`, or profile fields (`email`, `phone`, `first_name`, `last_name`, `display_name`). Only the fields you include in the request body are changed; omitted fields are left as-is. Sending an empty string for a profile field clears it. This endpoint does not modify the participant's `status`. To change status, use the [dedicated status endpoint](/api-reference/participants/update-participant-status). This separation prevents accidental state transitions during routine profile updates. For usage patterns and examples, see the [Participants guide](/guides/participants). # Update participant attributes Source: https://docs.scrip.dev/api-reference/participants/update-participant-attributes PATCH /v1/participants/{id}/state/attributes Merge key-value pairs into a participant's attributes. Merges the provided attributes into the participant's existing attributes. Existing keys included in the request are updated to the new value. New keys are added. Keys not included in the request are left unchanged. To remove a key entirely, use the delete attribute endpoint. This merge behavior makes the endpoint safe for partial updates without risk of accidentally clearing unrelated attributes. For usage patterns and examples, see the [State Management guide](/guides/state-management). # Update participant status Source: https://docs.scrip.dev/api-reference/participants/update-participant-status PATCH /v1/participants/{id}/status Update a participant's lifecycle state. Changes a participant's lifecycle status. * **`ACTIVE`**: the participant can earn and spend normally * **`SUSPENDED`**: temporarily frozen. Financial actions (`CREDIT`, `DEBIT`, `HOLD`, `RELEASE`, `FORFEIT`, `COUNTER`) are blocked. Metadata actions (`TAG`, `SET_ATTRIBUTE`, `SET_TIER`) are still allowed. Reversible by setting status back to `ACTIVE` * **`CLOSED`**: deactivated. Same restrictions as `SUSPENDED`. Can be transitioned back to `ACTIVE` or `SUSPENDED` When a rule triggers a blocked action against a suspended or closed participant, that action fails and the event is marked `FAILED`. Events where only allowed actions match will still complete successfully. For the full breakdown of which actions are allowed by status, see [Participants — What's allowed by status](/guides/participants#whats-allowed-by-status). # Void hold participant balance Source: https://docs.scrip.dev/api-reference/participants/void-hold-participant-balance POST /v1/participants/{id}/balances/void-hold Cancel provisionally issued HELD lots by `reference_id`, returning value to the original source account. Only processes lots created directly in HELD via CREDIT; lots moved to HELD via HOLD are excluded. Requires LOT mode asset. Cancels provisionally issued `HELD` lots by `reference_id`, returning value to the original source account (program wallet for prefunded assets, system issuance for unlimited). Use this for auth reversals where a merchant voids a transaction before settlement. Only processes lots created directly in `HELD` via `CREDIT`. Lots moved to `HELD` via a `HOLD` operation are excluded because returning those to the source would confiscate participant-owned funds. Use [release](/api-reference/participants/release-participant-balance) for participant-held funds. Requires a `LOT`-mode asset. `amount`, `lot_ids`, `earned_from`, and `earned_to` are not supported. The entire provisional accrual matching the `reference_id` is voided. Like forfeit, void hold is allowed on `CLOSED` participants. For the full auth/settle pattern and usage examples, see the [Balance Operations guide](/guides/balance-operations#void-hold). # Burn program balance Source: https://docs.scrip.dev/api-reference/programs/burn-program-balance POST /v1/programs/{id}/assets/{assetId}/burn Remove funds from a program's wallet for a PREFUNDED asset. Removes funds from a program's wallet for a specific `PREFUNDED` asset. The burned amount is debited from the program wallet and credited to `SYSTEM_BREAKAGE` in the ledger. This is the inverse of [Fund a program](/api-reference/programs/fund-a-program). Calling this on an `UNLIMITED` asset has no effect. You cannot burn more than the current wallet balance. For usage patterns and examples, see the [Programs guide](/guides/programs). # Create a program Source: https://docs.scrip.dev/api-reference/programs/create-a-program POST /v1/programs Create a new program. Creates a new program in your organization. A program only requires a `name`. By default, `on_unknown_participant` is set to `CREATE`, which means participants are auto-created on their first event. Set it to `REJECT` if you need explicit registration before processing events. Programs start as `ACTIVE`. You can suspend or archive them later via [Update a program](/api-reference/programs/update-a-program). For usage patterns and examples, see the [Programs guide](/guides/programs). # Fund a program Source: https://docs.scrip.dev/api-reference/programs/fund-a-program POST /v1/programs/{id}/assets/{assetId}/fund Add funds to a program's wallet for a PREFUNDED asset. Adds funds to a program's wallet for a specific `PREFUNDED` asset. This is how you set a budget. Every `CREDIT` action on this asset draws from the wallet, and credits fail when the wallet is empty. This endpoint has no effect on `UNLIMITED` assets, which mint value on demand and bypass the wallet entirely. For usage patterns and examples, see the [Programs guide](/guides/programs). # Get a program Source: https://docs.scrip.dev/api-reference/programs/get-a-program GET /v1/programs/{id} Retrieve a program by ID. Returns a single program by its `id`. The response includes the program's `status`, `on_unknown_participant` enrollment policy, and full configuration. Use this to verify a program is `ACTIVE` before submitting events, or to inspect its enrollment policy. For usage patterns and examples, see the [Programs guide](/guides/programs). # Get program balance Source: https://docs.scrip.dev/api-reference/programs/get-program-balance GET /v1/programs/{id}/balance Retrieve a program's wallet balance. Returns the current wallet balance for each asset linked to the program. The balance reflects the net of fund and burn operations minus credits issued by rules. Only meaningful for assets with an `issuance_policy` of `PREFUNDED`. `UNLIMITED` assets bypass the wallet entirely and do not appear here. Use this to monitor remaining budget; when a `PREFUNDED` wallet reaches zero, credits fail. For usage patterns and examples, see the [Programs guide](/guides/programs). # Get program wallet transaction history Source: https://docs.scrip.dev/api-reference/programs/get-program-wallet-transaction-history GET /v1/programs/{id}/history Retrieve transaction history for a program's wallet. Returns the transaction history for a program's wallet. Each entry represents a fund, burn, or credit draw operation against the wallet for a given asset. Fund operations increase the balance, burn operations decrease it, and credit draws are deducted automatically when rules issue credits from a `PREFUNDED` asset. Filter by ingestion time with `from` / `to`, or by the originating event's occurrence time with `event_from` / `event_to`. Each event-driven transaction includes an `event_timestamp` field. For usage patterns and examples, see the [Programs guide](/guides/programs). # Link asset to program Source: https://docs.scrip.dev/api-reference/programs/link-asset-to-program POST /v1/programs/{programId}/assets Link an existing asset to a program. Links an existing organization-level asset to a program. This is how you share a single asset across multiple programs. When you create an asset with a `program_id`, the link happens automatically. This endpoint is for adding an asset to additional programs after initial creation. Pass the asset's `id` in the request body. The link is permanent once any ledger entries exist for that asset in the program. If no ledger entries exist, the asset can be unlinked. Linking an already-linked asset returns an error. For usage patterns and examples, see the [Programs guide](/guides/programs). # List program assets Source: https://docs.scrip.dev/api-reference/programs/list-program-assets GET /v1/programs/{programId}/assets List assets linked to a program. Lists all assets currently linked to a program. Assets are organization-level resources, and the same asset can be linked to multiple programs. The response includes each asset's configuration: `inventory_mode`, `issuance_policy`, and `scale`. Use this to see which assets a program can reference in its rules. For usage patterns and examples, see the [Programs guide](/guides/programs). # List programs Source: https://docs.scrip.dev/api-reference/programs/list-programs GET /v1/programs List all programs for your organization. Returns a paginated list of all programs in your organization. Filter by `status`, use `search` to find programs by name, and `sort_by` / `sort_dir` to control ordering. Archived programs are excluded by default unless you filter by status or pass `include_archived=true`. For usage patterns and examples, see the [Programs guide](/guides/programs). # Programs Source: https://docs.scrip.dev/api-reference/programs/overview Top-level containers for incentive logic A program is the top-level container in Scrip. It holds rules, links to assets, and scopes participants. You can run multiple programs in parallel to isolate campaigns like "Q4 Referral Bonus" from "Customer Loyalty." ## Endpoints * Create, list, get, and update programs * Fund and burn program wallet balances for `PREFUNDED` assets * Link and unlink assets * Set enrollment behavior via `on_unknown_participant` (`CREATE` or `REJECT`) * Change program `status` between `ACTIVE`, `SUSPENDED`, and `ARCHIVED` For usage patterns and examples, see the [Programs guide](/guides/programs). # Update a program Source: https://docs.scrip.dev/api-reference/programs/update-a-program PATCH /v1/programs/{id} Partially update a program. Updates a program's `name`, `description`, `on_unknown_participant`, `status`, or `redemption_target_type`. Only include the fields you want to change; omitted fields are left unchanged. Status can be set to `ACTIVE`, `SUSPENDED`, or `ARCHIVED`. A `SUSPENDED` program rejects new incoming events but preserves all data. An `ARCHIVED` program also rejects events and is hidden from list results. Both can be moved back to `ACTIVE` at any time. Setting `redemption_target_type` to `LEDGER_ENTITY` requires `redemption_target_entity_id` in the same request. Setting it to `SYSTEM_REDEMPTION` or `SYSTEM_BREAKAGE` must omit `redemption_target_entity_id` and clears any existing reference. Changes only affect redemptions created after the update: existing redemptions and their reversals continue to use the target captured at the time they were created. For usage patterns and examples, see the [Programs guide](/guides/programs). For redemption target options, see [Redemption Targets](/guides/redemptions#redemption-targets). # Get redemption Source: https://docs.scrip.dev/api-reference/redemptions/get-redemption GET /v1/redemptions/{id} Retrieve a redemption by ID. Returns a single redemption by its ID. The response includes the asset, amount, description, associated reward (for catalog redemptions), and reversal state. The `status` field is one of `COMPLETED`, `PARTIALLY_REVERSED`, or `FULLY_REVERSED`. Check `reversed_amount` to see how much headroom remains before issuing a partial reversal. For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # List participant redemptions Source: https://docs.scrip.dev/api-reference/redemptions/list-participant-redemptions GET /v1/participants/{id}/redemptions List redemptions for a participant. Lists all redemptions for a participant, including both raw redemptions and catalog redemptions. Filter by `status` or `reward_catalog_item_id`, and use `from` / `to` to scope results to a time window. Results are ordered by creation time, most recent first. Each entry contains the redemption ID, asset, amount, status, and for catalog redemptions, the `reward_id` and `quantity`. For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # List redemption reversals Source: https://docs.scrip.dev/api-reference/redemptions/list-redemption-reversals GET /v1/redemptions/{id}/reversals List reversals for a redemption. Lists all reversals applied to a redemption. Each entry includes the amount or quantity reversed, the `reason`, and a timestamp. A redemption can have multiple partial reversals. Results are returned in chronological order. Use this to audit reversal history or check remaining headroom before issuing another partial reversal. For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # Redemptions Source: https://docs.scrip.dev/api-reference/redemptions/overview Spending participant balances for rewards or value A redemption debits a participant's balance, either as a raw amount or against a catalog reward. Every redemption produces a journal entry and supports full or partial reversal. Create and list redemptions under `/v1/participants/{id}/redemptions`. Get a specific redemption or manage reversals at `/v1/redemptions/{id}`. ## Endpoints * Redeem a raw amount of an asset * Redeem a catalog item by `reward_id` * List and get redemptions for a participant * List reversals on a redemption * Reverse a redemption (full or partial by amount) For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # Redeem a catalog item Source: https://docs.scrip.dev/api-reference/redemptions/redeem-a-catalog-item POST /v1/participants/{id}/redemptions/items Redeem a reward catalog item for a participant. Redeems a specific reward from the program's catalog on behalf of a participant. For `UNIT_BASED` rewards, specify `quantity` (defaults to 1); for `AMOUNT_BASED` rewards, specify `amount`. The total cost deducted from the participant's balance is calculated as `quantity * unit_cost` for unit-based rewards. The program must be `ACTIVE` (not archived or suspended), and the reward must belong to the specified program. Ensure the reward's `status` is `ACTIVE` and within its `available_from`/`available_until` window before calling this endpoint. Inventory is tracked automatically. Each successful redemption increments `redeemed_count` both globally and per participant. When a reward's `redeemed_count` reaches its `max_total`, the reward auto-transitions to `OUT_OF_STOCK` status and further redemption attempts will fail. Per-participant limits are enforced via `max_per_participant` if configured on the reward. Pass an `idempotency_key` to safely retry requests. Duplicate requests return `200` with the existing redemption instead of `201`. For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # Redeem points/assets Source: https://docs.scrip.dev/api-reference/redemptions/redeem-points-assets POST /v1/participants/{id}/redemptions Redeem an amount of an asset from a participant's `AVAILABLE` balance. Debits an arbitrary amount from a participant's `AVAILABLE` balance for a given asset. This is a raw redemption, meaning it is not tied to any catalog item. You must provide `program_id`, `asset_id`, `amount`, and a `description` explaining the redemption. The participant must be active, the program must be `ACTIVE` (not archived or suspended), and the asset must be linked to the program. The call fails with `422` if the participant's `AVAILABLE` balance is insufficient to cover the requested `amount`. On success, the debited amount is credited to the program's configured redemption target account (default: `SYSTEM_REDEMPTION`). Use this endpoint for custom redemption flows that fall outside the reward catalog, such as pay-with-points at checkout or ad-hoc deductions. Pass an `idempotency_key` to safely retry requests. Duplicate requests return `200` with the existing redemption instead of `201`. For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # Reverse a redemption Source: https://docs.scrip.dev/api-reference/redemptions/reverse-a-redemption POST /v1/redemptions/{id}/reverse Reverse all or part of a redemption, returning funds to the participant. Reverses a redemption, either fully or partially. To reverse the full amount, omit the `amount` and `quantity` fields. For a partial reversal, specify the `amount` (for raw and `AMOUNT_BASED` redemptions) or `quantity` (for `UNIT_BASED` catalog redemptions) to reverse. The `reason` field is required and must be between 1 and 500 characters. For `UNIT_BASED` catalog items, reversals decrement the reward's `redeemed_count`, which means a reward that was `OUT_OF_STOCK` can transition back to `ACTIVE` if the reversal brings the count below `max_total`. For assets configured in `LOT` mode, reversals restore the specific lots that were consumed, following LIFO (last-in, first-out) order. Multiple partial reversals are allowed on a single redemption. The cumulative reversed amount cannot exceed the original redemption amount. Check `reversed_amount` on the redemption to verify headroom before issuing a partial reversal. A redemption that has already been fully reversed returns `409` with code `already_reversed`. Pass an `idempotency_key` to safely retry requests. Duplicate requests return `200` with the existing reversal instead of `201`. For usage patterns and examples, see the [Redemptions guide](/guides/redemptions). # Get a journal entry Source: https://docs.scrip.dev/api-reference/reporting/get-a-journal-entry GET /v1/journal-entries/{id} Retrieve a journal entry by ID, including its double-entry postings. Returns a single journal entry by ID, including all of its postings. Each posting contains the account, asset, signed amount, and bucket. Postings within a journal entry always sum to zero, reflecting the double-entry nature of the ledger. Each entry includes an `entry_hash` field, a SHA-256 seal that chains it to the previous entry in the organization's ledger. This hash covers the entry's metadata and all postings, making the ledger tamper-evident. Use this endpoint to inspect the full breakdown of a specific transaction. The `event_id` on the entry links back to the event that triggered it, and the `reference_id` (when present) links hold and release operations together for correlation. For usage patterns and examples, see the [Reporting guide](/guides/reporting). # Get ledger summary Source: https://docs.scrip.dev/api-reference/reporting/get-ledger-summary GET /v1/reports/ledger-summary Returns aggregated ledger statistics per asset. Optionally filter by `program_id`. Provide both `from` and `to` to include period-scoped rollforward fields in the response. In period mode, `period_redeemed` is gross redemptions for the window; reversals and similar adjustments are reflected in `other_adjustments_net`. Returns aggregated balances and flows for each asset in your ledger. The response includes `total_issued`, `total_redeemed`, `total_expired`, `total_forfeited`, `current_balance`, and `participant_count` per asset. Pass `program_id` to scope the summary to a single program. Omit it to get org-wide totals across all programs. All values are computed in real time from the ledger, so they reflect the current state without any delay. For usage patterns and examples, see the [Reporting guide](/guides/reporting). # Get program activity Source: https://docs.scrip.dev/api-reference/reporting/get-program-activity GET /v1/reports/program-activity Returns activity metrics per program. Use `since` to limit which programs are returned (only programs with activity after the timestamp). When `from` and `to` are provided, `event_count`, `total_issued` and `total_redeemed` are scoped to that period; otherwise they are all-time totals. Journal count, unique participants, and last activity are always all-time. Sub-entity counts (rule_count, reward_count, tier_count, asset_count) reflect current non-archived state. Compares activity across programs in a single request. For each program, the response includes `event_count`, `journal_count`, `total_issued`, `total_redeemed`, `unique_participants`, and `last_activity_at`. Use the `since` parameter to limit results to programs with activity after a given timestamp. This is useful for filtering out dormant programs and focusing on the ones that matter. Programs with no activity in the specified window are excluded from the response. For usage patterns and examples, see the [Reporting guide](/guides/reporting). # List journal entries Source: https://docs.scrip.dev/api-reference/reporting/list-journal-entries GET /v1/journal-entries List journal entries (double-entry audit trail). Lists journal entries, which form the complete audit trail of all ledger activity. Each journal entry represents a single transaction with one or more balanced postings. Results are paginated and returned in reverse-chronological order. You can filter by `program_id`, `participant_id`, `external_id`, `group_id`, `asset_id`, `bucket`, `event_id`, `rule_id`, `action_type`, `reference_id`, time range, and amount range. Entity filters (`participant_id`, `external_id`, `group_id`) are mutually exclusive; include at most one per request. Amount filters apply to the signed posting value, where credits are positive and debits are negative. Use `action_type` to isolate specific ledger operations (for example `HOLD`, `RELEASE`, or `FORFEIT`). Use `rule_id` to isolate journal entries created by a specific rule. Use `reference_id` to find all journal entries associated with a specific hold, release, or settle correlation. For usage patterns and examples, see the [Reporting guide](/guides/reporting). # Reporting Source: https://docs.scrip.dev/api-reference/reporting/overview Ledger summaries and program activity data Reporting endpoints return aggregate views of program activity and the double-entry ledger. Use them to pull balance totals across programs and assets, or to browse individual journal entries for auditing. Ledger summaries and activity metrics are under `/v1/reports`. Journal entries are at `/v1/journal-entries`. ## Endpoints * Get ledger summaries filtered by program and asset * Get program activity metrics over a time range * List and get journal entries with their postings For usage patterns and examples, see the [Reporting guide](/guides/reporting). # Create a reward Source: https://docs.scrip.dev/api-reference/rewards/create-a-reward POST /v1/programs/{programId}/rewards Create a new reward in the program's catalog. Creates a new reward in a program's catalog. A reward requires a `name`, `redemption_type`, `asset_id`, `unit_cost`, and `status`. You can also include `description`, `category`, and a freeform `metadata` object. The `asset_id` must reference an asset that is linked to the program. `unit_cost` must be a positive decimal string. Both `redemption_type` and `asset_id` are immutable after creation. `redemption_type` controls how participants redeem: `UNIT_BASED` rewards are redeemed in discrete quantities with optional inventory caps (`max_total`, `max_per_participant`), while `AMOUNT_BASED` rewards allow variable amounts with no inventory limits. Set `available_from` and `available_until` to define a redemption window (`available_from` must be before `available_until`). Most rewards should start in `DRAFT` status until configuration is finalized, then transition to `ACTIVE` when ready. Reward names must be unique within a program. A `409` is returned with code `name_exists` if the name is already taken. Rewards cannot be created in archived programs. For usage patterns and examples, see the [Rewards Catalog guide](/guides/rewards-catalog). # Get a reward Source: https://docs.scrip.dev/api-reference/rewards/get-reward GET /v1/programs/{programId}/rewards/{rewardId} Retrieve a specific reward by ID. Returns a single reward by its ID, including its full configuration and current inventory state. The response includes `redeemed_count` (total global redemptions) alongside any configured `max_total` and `max_per_participant` limits. For usage patterns and examples, see the [Rewards Catalog guide](/guides/rewards-catalog). # List rewards Source: https://docs.scrip.dev/api-reference/rewards/list-rewards GET /v1/programs/{programId}/rewards List rewards for a program. Returns all rewards in a program's catalog. Archived rewards are excluded by default; pass `include_archived=true` to include them. Filter by `status`, use `search` to find rewards by name, and `sort_by` / `sort_dir` to control ordering. For usage patterns and examples, see the [Rewards Catalog guide](/guides/rewards-catalog). # Rewards Source: https://docs.scrip.dev/api-reference/rewards/overview Catalog items available for redemption A reward is a catalog item priced in a specific asset. Rewards can be `UNIT_BASED` (fixed cost per unit with optional inventory caps via `max_total` and `max_per_participant`) or `AMOUNT_BASED` (variable amount with a minimum). Participants redeem rewards through the redemptions endpoints. Rewards are scoped to a program. All endpoints are under `/v1/programs/{programId}/rewards`. ## Endpoints * Create, list, get, and update rewards within a program * Set `unit_cost`, inventory caps, and availability windows (`available_from`, `available_until`) * Change reward `status` between `DRAFT`, `ACTIVE`, `OUT_OF_STOCK`, and `ARCHIVED` For usage patterns and examples, see the [Rewards Catalog guide](/guides/rewards-catalog). # Update a reward Source: https://docs.scrip.dev/api-reference/rewards/update-a-reward PATCH /v1/programs/{programId}/rewards/{rewardId} Partially update a reward. Updates a reward's configuration. Only the fields included in the request body are changed; omitted fields remain as-is. You can modify `name`, `description`, `category`, `unit_cost`, `max_total`, `max_per_participant`, `status`, `available_from`, `available_until`, and `metadata`. The `redemption_type` and `asset_id` fields are immutable and cannot be updated. Reward names must be unique within a program. A `409` is returned with code `name_exists` if the name conflicts with another reward. To archive a reward, set `status` to `ARCHIVED`. For usage patterns and examples, see the [Rewards Catalog guide](/guides/rewards-catalog). # Create a rule Source: https://docs.scrip.dev/api-reference/rules/create-a-rule POST /v1/rules Create a rule that evaluates a CEL condition against incoming events and executes actions when matched. Creates a rule within a program. A rule consists of a CEL `condition` that is evaluated against each incoming event and an `actions` array that executes when the condition matches. Use `description` to add a human-readable summary of what the rule does. Common actions include crediting balances, incrementing counters, and setting tags. The `order` field controls evaluation priority. Lower values are evaluated first. If omitted, `order` is auto-assigned above the current highest value in the program. No two active rules in the same program can share the same `order`. Leave gaps between values (e.g. 10, 20, 30) so you can insert new rules without reordering. Set `stop_after_match` to `true` to prevent lower-priority rules from firing when this rule matches. `active_from` and `active_to` define an optional time window using RFC 3339 timestamps. 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`, so historical imports won't trigger a rule whose window has already passed. Use these to layer promotional rules on top of permanent base rules. `budgets` cap how much a rule can issue per asset over a given period. Each budget specifies an `asset_id`, a `limit`, and an optional `schedule_type` (`CRON` or `INTERVAL`) that controls automatic resets. Omitting the schedule type creates a lifetime budget that never resets on its own. When a budget is exhausted, the rule is skipped entirely: all of its actions are rolled back and the evaluation is recorded as `skipped` with a `budget_exceeded` reason. You can manually reset a budget via the [reset budget endpoint](/api-reference/rules/reset-a-rule-budget). A rule is created in `ACTIVE` status by default. You can also set it to `SUSPENDED` at creation. Rules cannot be created under an archived program. For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Get a rule Source: https://docs.scrip.dev/api-reference/rules/get-a-rule GET /v1/rules/{id} Retrieve a rule by ID. Returns a single rule by ID, including its full configuration: `condition`, `actions`, `description`, `order`, `stop_after_match`, time window settings, and status. If the rule has `budgets` configured, the response includes the current budget state for each asset: `consumed` amount and `next_reset_at` timestamp. This is the primary way to check how much of a rule's budget has been spent. To reset a budget manually, use the [reset budget endpoint](/api-reference/rules/reset-a-rule-budget). For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Get rule change history Source: https://docs.scrip.dev/api-reference/rules/get-rule-change-history GET /v1/rules/{id}/history Retrieve the audit trail of changes for a specific rule. Returns the audit trail of changes for a specific rule. Each entry records a `change_type`, a timestamp, who made the change, and a `snapshot` of the full rule at that point in time. The `changes` object highlights which fields were modified. Use `change_type` to filter results: `created`, `updated`, `archived`, `suspended`, or `activated`. Use `from` and `to` (RFC 3339) to narrow results to a specific time window. For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # List rules Source: https://docs.scrip.dev/api-reference/rules/list-rules GET /v1/rules List all rules for your organization. Returns all rules in your organization. Pass `program_id` to scope results to a single program. Archived rules are excluded by default; pass `include_archived=true` or filter by `status` to control which rules are returned. Use `search` to find rules by name. Rules are returned in `order` sequence by default, which reflects their evaluation priority. Use `sort_by` and `sort_dir` to change the ordering. For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Rules Source: https://docs.scrip.dev/api-reference/rules/overview Automated reward logic triggered by events A rule pairs a CEL `condition` with a list of `actions`. When an event is processed, rules evaluate in `order` within the program. Matching rules execute their actions in the same transaction. Each rule can include an optional `description`, time windows, and budget constraints. ## Endpoints * **CRUD**: create, list, get, and update rules * **Validate**: check a CEL condition for syntax errors without creating a rule * **Simulate**: dry-run a rule against a test event payload * **Reset budget**: reset per-asset `budgets` on a rule * **Change history**: retrieve the audit trail for a rule For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Reset a rule budget Source: https://docs.scrip.dev/api-reference/rules/reset-a-rule-budget POST /v1/rules/{id}/reset-budget Reset the consumed amount for a specific budget on a rule, identified by `asset_id`. Resets a rule's budget for a specific asset. Pass the target `asset_id` as a query parameter. The `consumed` amount is set back to zero and the `next_reset_at` timestamp advances to the next scheduled reset period. For lifetime budgets (those without a `schedule_type`), the consumed amount resets but no future `next_reset_at` is scheduled. This is useful when you need to manually refill a budget mid-cycle or correct a budget that was consumed by erroneous events. For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Simulate a rule Source: https://docs.scrip.dev/api-reference/rules/simulate-a-rule POST /v1/rules/{id}/simulate Dry-run a rule against sample event data. No ledger changes occur. Dry-runs a rule against sample data without persisting any changes. No balances are modified, no counters are incremented, and no events are recorded. The `event` field is required and should mirror the shape of a real event your system would send. The `participant_state` field is optional and lets you provide mock `tags`, `counters`, and `attributes` so the condition can reference participant data. The response contains two top-level objects: * **`rule`**: static metadata about the rule: `id`, `name`, `condition`, `order`, and `stop_after_match` * **`evaluation`**: the simulation outcome: * `matched`: whether the condition evaluated to `true` * `status`: `evaluated` on success, or `condition_failed` if the CEL expression errored * `reason`: error message, present only when `status` is `condition_failed` * `results`: present only when `matched` is `true`. Each entry pairs the original `action` definition with a `result` containing computed values Action results vary by type: | Action type | Result fields | | ----------------------------------------------- | ---------------------------------------------------------- | | `CREDIT`, `DEBIT`, `HOLD`, `RELEASE`, `FORFEIT` | `amount`, `asset_symbol`, `description` | | `COUNTER` | `current_value`, `projected_value`, `value`, `description` | | `TAG` | `current_tags`, `would_add`, `description` | | `UNTAG` | `current_tags`, `would_remove`, `description` | | `SET_ATTRIBUTE` | `current_value`, `would_change`, `description` | | `SET_TIER` | `description` | | `SCHEDULE_EVENT` | `description` | | `BROADCAST` | `description` | `VOID_HOLD` actions are not yet supported in simulation. For asset actions, `amount` reflects the resolved CEL expression (if dynamic) and `asset_symbol` is the symbol of the target asset. Participant-state projections (`current_value`, `would_add`, etc.) are only included when the action targets the event participant. Use this to validate rule logic before deploying, or to debug why a rule isn't matching in production. To check CEL syntax without needing an existing rule, use the [validate endpoint](/api-reference/rules/validate-a-cel-condition). For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Update a rule Source: https://docs.scrip.dev/api-reference/rules/update-a-rule PATCH /v1/rules/{id} Partially update a rule. At least one field must be provided. If `actions` is included, it must contain at least one action (empty array is rejected). Partially updates a rule's configuration. You can modify `name`, `description`, `condition`, `actions`, `order`, `stop_after_match`, `active_from`, `active_to`, `budgets`, or `status`. Only the fields included in the request body are changed; omitted fields remain untouched. The `budgets` field is a full replacement when present. The provided array replaces all existing budgets. Omitting the field leaves budgets unchanged. To remove all budgets, send an empty `budgets` array. To suspend a rule without archiving it, set `status` to `SUSPENDED`. To archive a rule, set `status` to `ARCHIVED`. Changing the `order` field will fail with a `409` if another active rule in the same program already occupies that position. Archived rules cannot be modified. Rules belonging to an archived program also cannot be modified. For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Validate a CEL condition Source: https://docs.scrip.dev/api-reference/rules/validate-a-cel-condition POST /v1/rules/validate Check whether a CEL expression is syntactically valid without creating a rule. Checks whether a CEL expression is syntactically valid without creating a rule. Pass the `condition` string in the request body. The response returns a `valid` boolean and a `message` with details. This endpoint only validates syntax. It does not verify that field references correspond to actual data in your events or participant state. For example, `event.amount > 100` will pass validation even if your events never include an `amount` field. Use the [simulate endpoint](/api-reference/rules/simulate-a-rule) to test a rule against realistic data. For usage patterns and examples, see the [Writing Rules guide](/guides/writing-rules). # Archive a tier Source: https://docs.scrip.dev/api-reference/tiers/archive-a-tier DELETE /v1/programs/{programId}/tiers/{key} Archives a tier. Archiving is one-way: an archived tier stops all new assignment and evaluation — SET_TIER rule actions, automatic qualification, and manual assignment — while existing participant tier state is preserved as historical. The tier and its levels remain readable. Archives a tier, the lifecycle-end action for the tier API. The `key` path parameter identifies which tier to archive, scoped to the program in `programId`. A program route only archives a program-scoped tier; it will not archive an organization-level tier that a program inherits. Archiving is one-way. An archived tier has `status` set to `ARCHIVED` and `archived_at` set to the time of the call, and it cannot be returned to `ACTIVE`. An archived tier stops all new assignment and evaluation: * `SET_TIER` rule actions targeting the tier are skipped. The event still completes. * Automatic qualification and period-end re-evaluation no longer assign the tier. * Manual assignment (`PUT`) and tier updates (`PATCH`) are rejected with `409 Conflict` and code `tier_archived`. Existing participant tier state is left untouched and preserved as historical. The tier and its levels remain readable through `GET` requests. Returns the archived tier on success. Archiving an already-archived tier returns `409 Conflict` with code `tier_archived`. An unknown program or tier returns `404 Not Found`. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Create a tier Source: https://docs.scrip.dev/api-reference/tiers/create-a-tier POST /v1/programs/{programId}/tiers Creates a new tier with levels within a program. Creates a new tier within a program. A tier is a ranked progression track where participants hold exactly one level at a time. Every tier needs a unique `key` and at least one level. Each level has its own `key` and a `rank` that sets its position in the hierarchy (higher rank = higher tier). Both the tier `key` and level `key` are immutable after creation. Keys must be lowercase alphanumeric with underscores, starting with a letter (pattern: `^[a-z][a-z0-9_]*$`). Each level can also include `color` (hex code for UI display), `icon_url`, and a freeform `benefits` object. Tiers cannot be created under an archived program. ### Lifecycle The `lifecycle` object controls how the tier behaves over time. Omit it to create a **rules-only tier** where changes are driven entirely by `SET_TIER` rule actions or direct API calls. When present, `lifecycle` has five parts: * **`retention`** controls whether the tier is re-evaluated on a periodic schedule (`PERIOD_BASED`) or expires after a `duration` of inactivity (`ACTIVITY_REFRESH`). With `ACTIVITY_REFRESH`, each event processed for the participant resets the timer. * **`qualification_period`** sets the evaluation cycle for `PERIOD_BASED` retention: `CALENDAR_YEAR` (Jan 1 to Dec 31), `FIXED_YEAR` (custom start date via `start_month` and `start_day`), or `NONE`. * **`status_validity`** extends a status grant past the period end. `extend_months` keeps status valid for that many months beyond the boundary. `0` or omitted means status expires exactly at period end. * **`downgrade_policy`** determines what happens when qualification lapses. `DROP_TO_QUALIFYING` re-evaluates and drops to the highest level the participant still qualifies for. `DROP_ONE` drops exactly one rank. `HOLD` keeps the current level indefinitely. Set `grace_days` to defer downgrades and `min_level` to establish a floor. Omit the whole object to default to `DROP_TO_QUALIFYING`. * **`counters`** controls whether qualifying counters reset to zero (`NONE`) or carry over the excess above the current level's threshold (`EXCESS`) at period end. The `qualifying` array lists which counter keys are subject to rollover. ### Qualification Each level can define `qualification` criteria for counter-based auto-advancement. After rules fire for an event, Scrip evaluates each level's thresholds and upgrades the participant to the highest level they qualify for. Set `mode` to `ALL` (every criterion must pass) or `ANY` (at least one). Each criterion checks a counter key against a `threshold` using an `operator`: `>=`, `>`, `==`, `<=`, or `<`. `threshold` defaults to `0` when omitted, so set it explicitly for a meaningful criterion. Auto-qualification only upgrades. Downgrades happen through the lifecycle system at period end or timer expiration. ### Benefits `benefits` is a freeform JSON object on each level. Scrip stores it and returns it with the participant's tier state. Use it to drive behavior in your application: a `points_multiplier`, `free_shipping` flag, or `discount_percent`. Scrip does not interpret the benefits payload. It is a data contract between your tier configuration and your application. For full examples, lifecycle walkthroughs, and tier state in CEL expressions, see the [Tiers guide](/guides/tiers). # Get a tier Source: https://docs.scrip.dev/api-reference/tiers/get-a-tier GET /v1/programs/{programId}/tiers/{key} Returns a tier and all its levels, looked up by the tier's `key`. Returns the full definition of a single tier, identified by its `key`. The response includes all `levels` with their `rank`, `qualification` criteria, and `benefits`, along with the tier's `lifecycle` configuration. For a listing of all tiers in a program, use the [list endpoint](/api-reference/tiers/list-tiers) instead. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Get participant tier Source: https://docs.scrip.dev/api-reference/tiers/get-participant-tier GET /v1/participants/{id}/state/tiers/{key} Returns the participant's current level for a single tier. Returns the participant's current level for a single tier. The `key` path parameter identifies which tier to look up (e.g., `status`), and `program_id` is required as a query parameter. The participant must be enrolled in the specified program. If the participant has no level assigned for the given tier, a `404` is returned. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # List participant tiers Source: https://docs.scrip.dev/api-reference/tiers/list-participant-tiers GET /v1/participants/{id}/state/tiers Returns the participant's tier levels for a program. The `program_id` query parameter is required. Returns a participant's current tier levels for a program. The `program_id` query parameter is required. The participant must be enrolled in the specified program. Each entry in the `data` array represents the participant's standing in one tier, including their current `level`, `rank`, any `benefits` granted by that level, and timestamps for `acquired_at` and `expires_at`. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # List tiers Source: https://docs.scrip.dev/api-reference/tiers/list-tiers GET /v1/programs/{programId}/tiers Returns all tiers and their levels for the given program. Returns all tiers and their levels for a program. Each tier in the response includes its full configuration: `levels`, `qualification` criteria, and `lifecycle` settings. To retrieve the full definition of a single tier, use the [get endpoint](/api-reference/tiers/get-a-tier) with its `key` instead. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Tiers Source: https://docs.scrip.dev/api-reference/tiers/overview Ranked progression tracks with qualification, retention, and downgrade policies A tier defines a ranked progression track within a program. Each tier contains ordered levels (e.g., Silver, Gold, Platinum), where participants hold exactly one level at a time. Levels can advance automatically when counter thresholds are met, be set explicitly through `SET_TIER` rule actions, or be managed directly via the API. The optional `lifecycle` configuration controls how tiers behave over time: retention mode, qualification periods, downgrade policies, and counter rollover. Without a `lifecycle`, tiers operate in rules-only mode where all changes come from rules or API calls. ## Tier definition endpoints * **Create** a tier with levels, qualification criteria, and lifecycle config * **List** and **get** tier definitions within a program * **Update** a tier's display name, lifecycle, and levels * **Archive** a tier to end its lifecycle and stop new assignment ## Participant tier endpoints * **List** a participant's current tier levels for a program * **Get** a participant's level for a single tier * **Set** a participant's tier level directly, with optional expiration * **Remove** a participant's tier assignment For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Remove participant tier Source: https://docs.scrip.dev/api-reference/tiers/remove-participant-tier DELETE /v1/participants/{id}/state/tiers/{key} Remove a participant's current tier level, downgrading them to base (rank 0). Removes a participant's current tier level, resetting them to base (rank 0). The `key` path parameter identifies which tier to clear (e.g., `status`), and `program_id` is required as a query parameter. Any pending tier expiration tasks for this assignment are also cancelled. Returns `204 No Content` on success. The participant must be active and enrolled in the specified program. The program must also be active. Inactive participants return `409 Conflict` with code `participant_inactive`. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Set participant tier Source: https://docs.scrip.dev/api-reference/tiers/set-participant-tier PUT /v1/participants/{id}/state/tiers/{key} Directly set a participant's tier to a specific level, optionally with an expiration. Sets a participant's tier level directly. The `key` path parameter identifies which tier to update (e.g., `status`), and `program_id` is required as a query parameter. The request body must include a `level` matching a defined level key on the tier. You can optionally provide an `expires_at` timestamp (RFC 3339) to schedule automatic removal of the tier assignment. Any existing expiration task for this tier is replaced. The participant must be active and enrolled in the specified program. The program must also be active. Inactive participants return `409 Conflict` with code `participant_inactive`. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Update a tier Source: https://docs.scrip.dev/api-reference/tiers/update-a-tier PATCH /v1/programs/{programId}/tiers/{key} Partially update a tier or its levels. Partially updates a tier's configuration. You can modify `display_name`, `lifecycle` settings, and `levels`. Only the fields you include in the request body are changed; omitted fields are left as-is. When updating `levels`, existing levels are matched by `key` and updated in place. You can update `display_name`, `rank`, `color`, `icon_url`, `benefits`, and `qualification` on existing levels. New keys create new levels (`rank` is required for new levels). A `409` is returned if a level key or rank conflicts with an existing level. Changes to `qualification` thresholds take effect for future evaluations but do not retroactively re-evaluate participants who have already qualified. The tier `key` is immutable and cannot be changed after creation. Tiers in archived programs cannot be updated. ### Clearing configuration The `lifecycle` object and a level's `qualification` object distinguish two cases on update: * Omitting the field, or sending `null`, leaves the stored config unchanged. * Sending an empty object (`{}`) clears it. `"lifecycle": {}` turns the tier rules-only. `"qualification": {}` removes a level's auto-advancement. A populated object replaces the stored config entirely. It is not deep-merged, so include every field you want to keep. For usage patterns and examples, see the [Tiers guide](/guides/tiers). # Create a transfer Source: https://docs.scrip.dev/api-reference/transfers/create-a-transfer POST /v1/transfers Atomically transfer assets from one participant to one or more recipients. Moves funds from one participant or group to one or more recipients. The operation is zero-sum: the source's `AVAILABLE` balance is debited by the exact total credited across all recipients. You can include up to 100 recipients in a single call. A `description` is required (1-500 characters). The source is identified by exactly one of `source_external_id`, `source_group_id`, or `source_participant_id`. Each recipient also requires exactly one of `external_id`, `group_id`, or `participant_id`. The asset must be linked to the program. Self-transfers (source and recipient are the same entity) return `400` with code `self_transfer`. The transfer is atomic. If any leg fails (insufficient balance, inactive participant, inactive program), the entire transfer is rolled back and nothing moves. Both the source and every recipient must have `ACTIVE` status, and the program itself must be `ACTIVE`. Pass an `idempotency_key` to safely retry requests. If a transfer with the same key already exists but the parameters differ, a `409` is returned with code `idempotency_conflict`. For assets using `LOT` mode, lots are consumed FIFO from the source and new lots are created for each recipient. The lot vintage resets on transfer, so the recipient's lot `created_at` reflects the transfer time, not the original issuance. For usage patterns and examples, see the [Transfers guide](/guides/transfers). # Transfers Source: https://docs.scrip.dev/api-reference/transfers/overview Moving value between participants A transfer moves an asset balance from one participant or group to one or more recipients. Transfers are zero-sum: the source is debited by the total credited to all recipients. Each transfer produces a journal entry. ## Endpoints * Create a transfer between participants, groups, or a mix of both * Specify multiple recipients with individual amounts in a single request For usage patterns and examples, see the [Transfers guide](/guides/transfers). # Create a webhook endpoint Source: https://docs.scrip.dev/api-reference/webhooks/create-a-webhook-endpoint POST /v1/webhook-endpoints Register a URL to receive webhook notifications for selected event types. Registers a new webhook endpoint. You must provide an HTTPS URL and at least one event type (or `*` for all events). The response includes a `secret` starting with `whsec_` — store it immediately, as it cannot be retrieved later. URLs are validated at creation time. The hostname must resolve to a public IP address. Private ranges, loopback addresses, and reserved hostnames (`localhost`, `*.local`, `*.internal`) are rejected. For signature verification, event types, and payload shapes, see the [Webhooks guide](/guides/webhooks). # Delete a webhook endpoint Source: https://docs.scrip.dev/api-reference/webhooks/delete-a-webhook-endpoint DELETE /v1/webhook-endpoints/{id} Soft-delete an endpoint by setting its status to ARCHIVED. Archived endpoints stop receiving deliveries. Archives a webhook endpoint. Archived endpoints stop receiving new deliveries and are excluded from list and get responses. Existing delivery records are retained for inspection. This is a soft delete. The endpoint's data is preserved but it cannot be reactivated. To temporarily stop deliveries, [update](/api-reference/webhooks/update-a-webhook-endpoint) the endpoint's status to `DISABLED` instead. For usage patterns and examples, see the [Webhooks guide](/guides/webhooks). # Get a delivery Source: https://docs.scrip.dev/api-reference/webhooks/get-a-webhook-delivery GET /v1/webhook-deliveries/{id} Retrieve a single delivery attempt including response body and error details. Returns a single delivery by ID. The response includes full debugging details: `last_response_body` (truncated to 4 KB), `last_response_status`, and `last_error` for network-level failures. Use this to diagnose why a delivery failed. Check `last_response_status` for HTTP errors and `last_error` for connection or timeout issues. For usage patterns and examples, see the [Webhooks guide — Debugging Deliveries](/guides/webhooks#debugging-deliveries). # Get a webhook endpoint Source: https://docs.scrip.dev/api-reference/webhooks/get-a-webhook-endpoint GET /v1/webhook-endpoints/{id} Retrieve a webhook endpoint by ID. The signing secret is not included. Returns a single webhook endpoint by ID. The `secret` field is not included. It is only returned on [create](/api-reference/webhooks/create-a-webhook-endpoint) and [rotate secret](/api-reference/webhooks/rotate-webhook-endpoint-secret). For usage patterns and examples, see the [Webhooks guide](/guides/webhooks). # List deliveries Source: https://docs.scrip.dev/api-reference/webhooks/list-webhook-deliveries GET /v1/webhook-endpoints/{id}/deliveries List delivery attempts for a specific webhook endpoint. Returns delivery attempts for a specific webhook endpoint, ordered by creation date (newest first). Filter by `status` to find deliveries in a specific state: `PENDING`, `SENDING`, `DELIVERED`, or `FAILED`. Use this endpoint to monitor delivery health and identify failures that may need manual retry. For retry policy and response handling details, see the [Webhooks guide — Retry Policy](/guides/webhooks#retry-policy). # List webhook endpoints Source: https://docs.scrip.dev/api-reference/webhooks/list-webhook-endpoints GET /v1/webhook-endpoints List all webhook endpoints for your organization. Returns all webhook endpoints for your organization, ordered by creation date (newest first). Archived endpoints are excluded. Filter by `status` to find only `ACTIVE` or `DISABLED` endpoints. The `secret` field is never included in list responses. For usage patterns and examples, see the [Webhooks guide](/guides/webhooks). # Webhooks Source: https://docs.scrip.dev/api-reference/webhooks/overview Real-time HTTP notifications for domain events A webhook endpoint registers a URL that receives signed HTTP notifications when events occur in your organization: balance changes, redemptions, tier transitions, event processing results, and more. Each delivery is signed with HMAC-SHA256 and retried with exponential backoff. Endpoints are managed at the organization level under `/v1/webhook-endpoints`. Deliveries are tracked per endpoint under `/v1/webhook-endpoints/{id}/deliveries`, with a direct lookup at `/v1/webhook-deliveries/{id}`. ## Endpoints * Create, list, get, update, and delete webhook endpoints * Rotate an endpoint's signing secret * List and inspect delivery attempts * Retry a failed delivery For usage patterns, signature verification, event types, and payload shapes, see the [Webhooks guide](/guides/webhooks). # Retry a delivery Source: https://docs.scrip.dev/api-reference/webhooks/retry-a-webhook-delivery POST /v1/webhook-deliveries/{id}/retry Reset a FAILED delivery back to PENDING for immediate redelivery. Only works on deliveries in FAILED status. Resets a `FAILED` delivery back to `PENDING` with `attempt_count` reset to 0 and a fresh set of 8 retry attempts. The delivery is scheduled for immediate redelivery. Only deliveries in `FAILED` status can be retried. Attempting to retry a delivery in any other status returns `409 Conflict` with code `not_retryable`. For the full retry schedule and response handling behavior, see the [Webhooks guide — Retry Policy](/guides/webhooks#retry-policy). # Rotate endpoint secret Source: https://docs.scrip.dev/api-reference/webhooks/rotate-webhook-endpoint-secret POST /v1/webhook-endpoints/{id}/rotate-secret Generate a new signing secret. The old secret is immediately invalidated. Generates a new signing secret for the endpoint. The old secret is invalidated immediately. The response includes the new `secret` — store it and update your verification code before in-flight deliveries arrive. Any deliveries signed with the old secret that haven't been verified yet will fail signature checks on your end. Coordinate the rotation with a brief window where your handler accepts both old and new signatures, or rotate during a low-traffic period. For signature verification details, see the [Webhooks guide — Signature Verification](/guides/webhooks#signature-verification). # Update a webhook endpoint Source: https://docs.scrip.dev/api-reference/webhooks/update-a-webhook-endpoint PATCH /v1/webhook-endpoints/{id} Partial update. Only provided fields are changed; omitted fields are preserved. Partially updates a webhook endpoint. Only the fields you include in the request body are changed; omitted fields are preserved. You can update the `url`, `description`, `enabled_events`, `status` (`ACTIVE` or `DISABLED`), and `metadata`. To rotate the signing secret, use the [rotate secret](/api-reference/webhooks/rotate-webhook-endpoint-secret) endpoint instead. For usage patterns and examples, see the [Webhooks guide](/guides/webhooks). # Authentication Source: https://docs.scrip.dev/authentication API key authentication, request headers, rate limits, and error handling All API requests require an API key. You can create and manage keys from the [Scrip dashboard](https://app.scrip.dev). ## Using Your API Key API keys use the `sk_` prefix. Pass your key in the `Authorization` header: ```bash theme={null} curl https://api.scrip.dev/v1/programs \ -H "Authorization: Bearer sk_your_api_key" ``` You can also use the `X-API-Key` header: ```bash theme={null} curl https://api.scrip.dev/v1/programs \ -H "X-API-Key: sk_your_api_key" ``` Each API key has full read and write access to all resources in your organization. There are no scoped or read-only keys at this time. Keep your API keys secret. Do not expose them in client-side code or commit them to version control. ## Rate Limits Requests are rate-limited per organization: | Limit | Value | Meaning | | -------------- | ------------------ | ----------------------------------------------------------- | | Sustained rate | 10 requests/second | Steady throughput the API allows continuously | | Burst | 30 requests | Maximum requests allowed in a short spike before throttling | All API keys within the same organization share the same rate limit. When exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header. Every response includes rate limit headers: | Header | Description | | ----------------------- | ----------------------------------------------------------------- | | `X-RateLimit-Limit` | Maximum burst capacity | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the bucket is fully replenished | | `Retry-After` | Seconds to wait before retrying (only present on `429` responses) | Need higher throughput? [Contact us](mailto:support@scrip.dev) about enterprise rate limits. ## Errors | Status | Code | Meaning | | ------ | ------------------- | ---------------------------------------------------------------------------------------------------------- | | `401` | `unauthorized` | API key is missing, invalid, or has been revoked. Check that your `Authorization` header is set correctly. | | `403` | `forbidden` | The API key is valid but does not have access to this resource. | | `429` | `too_many_requests` | Rate limit exceeded | Error responses follow a standard shape: ```json theme={null} { "code": "unauthorized", "message": "invalid or revoked API key" } ``` # Cashback Card Source: https://docs.scrip.dev/examples/cashback-card Category multipliers, spend thresholds, and monthly resets Build a cashback credit card program where participants earn different rates based on purchase category, with a higher rate that kicks in after they hit a monthly spend threshold. ## What you'll build | Use case | How it works | | --------------------- | ----------------------------------------------------------------------------- | | Category multipliers | 5% on dining, 3% on groceries, 1% on everything else | | Spend threshold bonus | Rate jumps from 1% to 3% after \$2,500/month in total spend | | Retroactive bonus | When crossing the threshold, a one-time bonus covers the delta on prior spend | | Monthly reset | Counters reset on the 1st of each month via automation | The end result is 8 rules and 1 automation. ## Assumptions This guide assumes you already have: * A **program** created * An **asset** linked to that program (`CASHBACK_USD`, scale 2, `UNLIMITED`, `SIMPLE`) See the [quickstart](/quickstart) if you need help with setup. ## The rules ### 1. Track monthly spend Every rule in this program needs to know how much the participant has spent this month. We track two counters: total spend (for the threshold check) and non-category spend (for the retroactive bonus calculation). ```json theme={null} { "name": "track_monthly_spend", "order": 50, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "COUNTER", "key": "monthly_spend", "value": "event.amount" } ] } ``` ```json theme={null} { "name": "track_monthly_base_spend", "order": 55, "condition": "event.type == \"purchase\" && event.amount > 0 && !(event.mcc in [\"5812\", \"5813\", \"5814\", \"5411\", \"5422\"])", "actions": [ { "type": "COUNTER", "key": "monthly_base_spend", "value": "event.amount" } ] } ``` `monthly_spend` tracks everything (for the \$2,500 threshold). `monthly_base_spend` tracks only non-category purchases - this is what the retroactive bonus pays out on. Counter values in conditions are **snapshots** - they reflect the state before the current event's actions execute. These rules increment the counters in the database, but all later rules still see the pre-increment values. This is important for threshold detection. ### 3. Retroactive bonus at \$2,500 When a participant crosses the \$2,500 monthly threshold, they should retroactively earn an extra 2% on their non-category spend so far. This makes up the difference between the 1% they already earned and the 3% high-spender rate. ```json theme={null} { "name": "threshold_retroactive_bonus", "order": 60, "condition": "event.type == \"purchase\" && event.amount > 0 && get(participant.counters, \"monthly_spend\", 0.0) < 2500.0 && (get(participant.counters, \"monthly_spend\", 0.0) + event.amount) >= 2500.0", "actions": [ { "type": "CREDIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "round(get(participant.counters, 'monthly_base_spend', 0.0) * 0.02, 2)", "description": "Retroactive 2% bonus on prior non-category spend" } ] } ``` The condition has two parts that make it fire exactly once per month: * `snapshot < 2500` - hasn't crossed yet * `(snapshot + event.amount) >= 2500` - this event crosses it The credit amount uses `monthly_base_spend` (not `monthly_spend`) so the retroactive bonus only applies to purchases that earned the 1% base rate. Dining and grocery purchases already earned their full 5%/3% - paying an extra 2% on those would overshoot. Don't use `stop_after_match` here. The current purchase still needs to flow through the earning rules below to earn its own cashback. ### 4. Dining - 5% ```json theme={null} { "name": "dining_cashback", "order": 100, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.amount > 0 && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "description": "Dining 5% cashback" } ] } ``` MCC codes: 5812 (restaurants), 5813 (bars), 5814 (fast food). `stop_after_match: true` ensures a dining purchase earns 5% and only 5% - it won't also match the grocery or base rules. ### 5. Groceries - 3% ```json theme={null} { "name": "grocery_cashback", "order": 200, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.amount > 0 && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "description": "Grocery 3% cashback" } ] } ``` MCC codes: 5411 (grocery stores), 5422 (freezer/meat lockers). Same pattern as dining - `stop_after_match` prevents the base and high-spender rules from stacking on top. ### 6. High-spender - 3% on everything else After crossing the \$2,500 monthly threshold, all remaining non-category purchases earn 3% instead of 1%. ```json theme={null} { "name": "high_spender_cashback", "order": 300, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.amount > 0 && (get(participant.counters, \"monthly_spend\", 0.0) + event.amount) >= 2500.0", "actions": [ { "type": "CREDIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "description": "High-spender 3% cashback" } ] } ``` This uses `(snapshot + event.amount) >= 2500` so the threshold-crossing purchase itself earns at the higher rate. ### 7. Base - 1% on everything else The catch-all for purchases below the threshold that don't match a category. ```json theme={null} { "name": "base_cashback", "order": 1000, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "description": "Base 1% cashback" } ] } ``` Only fires when no higher-priority rule matched - dining (100), grocery (200), and high-spender (300) all use `stop_after_match`. ### 8. Monthly counter reset ```json theme={null} { "name": "monthly_counter_reset", "order": 2000, "condition": "event.type == \"monthly_reset\"", "actions": [ { "type": "COUNTER", "key": "monthly_spend", "value": "-get(participant.counters, 'monthly_spend', 0.0)" }, { "type": "COUNTER", "key": "monthly_base_spend", "value": "-get(participant.counters, 'monthly_base_spend', 0.0)" } ] } ``` Triggered by the automation below. Subtracting the current value zeros both counters. ## Automation Create one automation to fire the monthly reset: ```json theme={null} { "name": "Monthly Counter Reset", "trigger": { "type": "cron", "cron_expression": "0 0 1 * *" }, "scope": "participants", "participant_filter": "get(participant.counters, \"monthly_spend\", 0.0) > 0", "event_name": "monthly_reset", "payload": { "type": "monthly_reset" } } ``` On the 1st of each month at midnight UTC, this fans out a `monthly_reset` event to every participant with a non-zero spend counter. The filter avoids unnecessary events for inactive participants. ## Rule evaluation flow Here's how a single purchase flows through the rules: ``` purchase event arrives │ ├─ [50] track_monthly_spend always fires, increments total counter ├─ [55] track_monthly_base_spend increments base counter (non-category only) ├─ [60] threshold_retroactive_bonus fires once when crossing $2,500 │ ├─ [100] dining_cashback 5% if MCC matches → STOP ├─ [200] grocery_cashback 3% if MCC matches → STOP ├─ [300] high_spender_cashback 3% if monthly_spend >= $2,500 → STOP └─ [1000] base_cashback 1% catch-all ``` Rules 100-1000 are mutually exclusive via `stop_after_match`. Rules 50-60 always evaluate regardless of category. ## Example event Your backend sends this when a card transaction settles: ```json theme={null} { "program_id": "{{PROGRAM_ID}}", "external_id": "user_123", "idempotency_key": "txn_abc123_settled", "event_data": { "type": "purchase", "amount": 85.00, "mcc": "5812", "merchant_name": "Corner Bistro", "transaction_id": "txn_abc123" } } ``` Only `type`, `amount`, and `mcc` are used by rule conditions. Include whatever else you need for your own analytics. ## Edge cases and watchouts ### Rounding With `scale: 2`, amounts are stored to the cent. Use `round(expr, 2)` in every CREDIT to avoid precision issues: ```javascript theme={null} round(event.amount * 0.05, 2) // good - $85.00 * 0.05 = $4.25 event.amount * 0.05 // risky - may produce $4.250000000001 ``` ### The threshold-crossing purchase The event that pushes `monthly_spend` past \$2,500 earns at the 3% rate (high-spender), **not** 1%. This is because `high_spender_cashback` checks `(snapshot + event.amount) >= 2500`, which is true for the crossing purchase. The retroactive bonus also fires on the same event, covering all prior spend. ### Category purchases don't care about the threshold A dining purchase always earns 5% whether the participant has spent \$500 or \$5,000 this month. The `stop_after_match` on `dining_cashback` (order 100) fires before the threshold rules (300, 1000) are even evaluated. The threshold only affects non-category purchases. ### Why two counters? `monthly_base_spend` exists so the retroactive bonus only pays out on purchases that earned 1%. Without it, a participant who spent \$2,000 on dining before crossing the threshold would get an extra 2% on those dining purchases - bumping them from 5% to 7%, which isn't intended. The retroactive bonus should only upgrade 1% purchases to 3%. ### Counter resets and tier status On the 1st of each month, the counter drops to zero. There's no "tier" to maintain - the high-spender rate is purely counter-driven. A participant who spent \$10,000 last month starts fresh at 1% the next month. ### Refund handling This example doesn't include refund rules. In production, you'd add rules to: 1. Debit cashback earned on the refunded transaction 2. Reduce the `monthly_spend` counter so the threshold status stays accurate A refund rule might look like: ```json theme={null} { "name": "refund_clawback", "order": 500, "condition": "event.type == \"refund\" && event.original_cashback > 0", "actions": [ { "type": "DEBIT", "asset_id": "{{CASHBACK_USD_ASSET_ID}}", "amount": "event.original_cashback" }, { "type": "COUNTER", "key": "monthly_spend", "value": "-event.original_amount" } ] } ``` Your backend would include `original_cashback` and `original_amount` from the original transaction record. ### Excluded transaction types The base rule doesn't exclude any MCC codes. In practice, you'd want to block non-qualifying transactions like cash advances or wire transfers: ```javascript theme={null} event.type == "purchase" && event.amount > 0 && !(event.mcc in ["6010", "6011", "6051", "4829", "7995"]) ``` ### Large MCC lists The CEL `in` operator works well for short lists (5-20 entries). If you need to match against hundreds of merchants or categories, move the classification into your backend and pass a flag like `event.is_dining: true` in the event payload instead. # Common Patterns Source: https://docs.scrip.dev/examples/common-patterns Copy-paste recipes for the most common reward behaviors Self-contained rule snippets for behaviors that come up in almost every program. Each pattern assumes you already have a program and asset configured - see the [quickstart](/quickstart) if you need help with setup. ## Category multipliers A credit card that pays different rates by purchase category. | Category | Rate | MCC codes | | --------------- | ---- | ---------------- | | Dining | 5% | 5812, 5813, 5814 | | Groceries | 3% | 5411, 5422 | | Everything else | 1% | - | Each category is its own rule, ordered from most specific to least. `stop_after_match` on the category rules prevents a dining purchase from also earning the 1% base rate. ```json theme={null} { "name": "dining_5pct", "order": 100, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)" } ] } ``` ```json theme={null} { "name": "grocery_3pct", "order": 200, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)" } ] } ``` ```json theme={null} { "name": "base_1pct", "order": 1000, "condition": "event.type == \"purchase\"", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)" } ] } ``` **How it works:** Three rules, evaluated in order. A dining purchase matches at 100 and stops - it never sees the grocery or base rules. A non-category purchase skips dining and grocery, then falls through to the 1% catch-all. The catch-all has no `stop_after_match` because there's nothing below it to block. The CEL `in` operator works well for short lists (5-20 entries). For hundreds of merchants, move classification into your backend and pass a flag like `event.is_dining: true` instead. ## Sign-up bonus "Spend \$4,000 in the first 3 months, earn 60,000 points." This is the most common card acquisition offer. It combines three checks: the participant must still be within their intro window, their cumulative spend must cross the threshold, and the bonus can only pay out once. `duration_hours` enforces the time window, a counter snapshot detects the threshold crossing, and a TAG prevents double-claiming. | What | How | | ------------------------- | -------------------------------------------------------- | | Track qualifying spend | Counter `intro_spend`, only during intro window | | Enforce time window | `duration_hours(now - timestamp(...)) <= 2160` (90 days) | | Detect threshold crossing | `(snapshot + event.amount) >= 4000.0` | | Prevent double-claiming | TAG `SIGNUP_BONUS_CLAIMED` | Your backend sets `enrolled_at` as a participant attribute when you create the participant. ```json theme={null} { "name": "track_intro_spend", "order": 50, "condition": "event.type == \"purchase\" && event.amount > 0 && duration_hours(now - timestamp(participant.attributes.enrolled_at)) <= 2160", "actions": [ { "type": "COUNTER", "key": "intro_spend", "value": "event.amount" } ] } ``` ```json theme={null} { "name": "signup_bonus", "order": 100, "condition": "event.type == \"purchase\" && event.amount > 0 && !(\"SIGNUP_BONUS_CLAIMED\" in participant.tags) && duration_hours(now - timestamp(participant.attributes.enrolled_at)) <= 2160 && (get(participant.counters, \"intro_spend\", 0.0) + event.amount) >= 4000.0", "actions": [ { "type": "TAG", "tag": "SIGNUP_BONUS_CLAIMED" }, { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "60000" } ] } ``` **How it works:** The tracking rule at order 50 accumulates spend only during the 90-day intro window. The bonus rule at order 100 checks three things: no TAG yet, still within 90 days, and spend just crossed \$4,000. `duration_hours(now - timestamp(participant.attributes.enrolled_at))` converts the time since enrollment to hours. `2160` hours = 90 days. If you prefer calendar months, track the deadline as an attribute and compare directly: `now < timestamp(participant.attributes.intro_deadline)`. ## Streak challenge A fitness app rewards users who check in 7 days in a row. Scrip tracks consecutive activity entirely within the rules engine. It stores the last checkin timestamp as a participant attribute and uses `duration_hours` to determine whether the next checkin is consecutive (24-48h gap), a broken streak (48h+), or the very first one. No backend date math needed. | Rule | Purpose | | --------------------- | --------------------------------------------------------------- | | `continue_streak` | Increment streak counter if checkin is consecutive (24-48h gap) | | `break_streak` | Reset counter to 1 if user skipped a day (48h+ gap) | | `start_streak` | Start a new streak on first-ever checkin | | `update_last_checkin` | Store the current timestamp for next comparison | | `streak_reward_7` | Pay out bonus when streak hits 7 | Scrip computes the gap between checkins using `duration_hours(now - timestamp(participant.attributes.last_checkin))` - no backend calculation needed. Your backend just sends a simple `checkin` event. ```json theme={null} { "name": "continue_streak", "order": 100, "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 24.0 && duration_hours(now - timestamp(participant.attributes.last_checkin)) < 48.0", "actions": [ { "type": "COUNTER", "key": "streak", "value": "1" } ] } ``` ```json theme={null} { "name": "break_streak", "order": 110, "condition": "event.type == \"checkin\" && has(participant.attributes.last_checkin) && duration_hours(now - timestamp(participant.attributes.last_checkin)) >= 48.0", "actions": [ { "type": "COUNTER", "key": "streak", "value": "-get(participant.counters, 'streak', 0.0) + 1.0" } ] } ``` ```json theme={null} { "name": "start_streak", "order": 120, "condition": "event.type == \"checkin\" && !has(participant.attributes.last_checkin)", "actions": [ { "type": "COUNTER", "key": "streak", "value": "1" } ] } ``` ```json theme={null} { "name": "update_last_checkin", "order": 500, "condition": "event.type == \"checkin\"", "actions": [ { "type": "SET_ATTRIBUTE", "key": "last_checkin", "value": "string(now)" } ] } ``` ```json theme={null} { "name": "streak_reward_7", "order": 200, "condition": "event.type == \"checkin\" && get(participant.counters, 'streak', 0.0) + 1.0 == 7.0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "100" } ] } ``` **How it works:** * **Consecutive day** = 24-48 hours since last checkin (increment streak) * **Skipped a day** = 48+ hours since last checkin (reset to 1) * **First checkin** = no `last_checkin` attribute yet (start at 1) * `update_last_checkin` fires on every checkin at order 500 to stamp the current time * `break_streak` resets the counter to 1 (not 0) because today's checkin starts a new streak Counter values in conditions are **snapshots** - they reflect the state *before* the current event's actions execute. If a rule at order 100 increments `streak`, a rule at order 200 still sees the old value. Use `get(participant.counters, 'streak', 0.0) + 1.0` to check the post-increment value. The 24-48 hour window assumes roughly one checkin per day. If your app allows multiple checkins in a single day (under 24h apart), none of the streak rules will match and the streak counter won't change. Adjust the lower bound if you need sub-day checkins to count. ## Referral credit A new user completes their first ride, and both they and the friend who referred them earn \$10. A single event credits two different participants. The referee gets credited normally as the event participant, while the referrer is resolved from a field in the event payload using a dynamic target. | Participant | Role | How they're credited | | ------------------------ | ----------------- | ----------------------------------------------------- | | Referee (new user) | Event participant | Normal CREDIT - no target needed | | Referrer (existing user) | Dynamic target | CREDIT with `target.external_id: "event.referrer_id"` | The referee's TAG at order 100 gates the referrer bonus at order 200, so both are skipped on duplicate events. ```json theme={null} { "name": "referee_bonus", "order": 100, "condition": "event.type == \"referral_completed\" && !(\"REFERRAL_USED\" in participant.tags)", "actions": [ { "type": "TAG", "tag": "REFERRAL_USED" }, { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "10.00", "description": "Referee welcome bonus" } ] } ``` ```json theme={null} { "name": "referrer_bonus", "order": 200, "condition": "event.type == \"referral_completed\" && \"REFERRAL_USED\" in participant.tags", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "10.00", "target": { "external_id": "event.referrer_id" }, "description": "Referrer bonus" } ] } ``` **How it works:** The `target.external_id` field is a CEL expression - it evaluates `event.referrer_id` to find the referrer participant. The referrer must be enrolled in the same program. If `event.referrer_id` resolves to a user who isn't enrolled in the program, the action will fail and the entire event will error. Validate referrer enrollment in your backend before sending the event. ## One-time welcome reward "Complete your first purchase and earn \$25." Unlike the sign-up bonus (which tracks cumulative spend over a time window), this fires on any single qualifying purchase. A TAG gates the condition so the bonus only pays out once. | What | How | | -------------- | ---------------------------------------------- | | Trigger | First `purchase` event with `amount > 0` | | One-time guard | TAG `FIRST_PURCHASE_DONE` checked in condition | | Payout | Fixed \$25 CREDIT | ```json theme={null} { "name": "first_purchase_bonus", "order": 100, "condition": "event.type == \"purchase\" && event.amount > 0 && !(\"FIRST_PURCHASE_DONE\" in participant.tags)", "actions": [ { "type": "TAG", "tag": "FIRST_PURCHASE_DONE" }, { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "25.00", "description": "First purchase bonus" } ] } ``` **How it works:** Same TAG-gating pattern as the [sign-up bonus](#sign-up-bonus). The first purchase passes the `!(... in participant.tags)` check, sets `FIRST_PURCHASE_DONE`, and earns the bonus. Every subsequent purchase sees the tag and the condition evaluates to false. ## Introductory rate with expiration A card that pays 5% for the first 90 days, then drops to the standard 1%. An `INTRO_ACTIVE` tag marks the window, a scheduled event removes it when the window closes, and `UNTAG` cleans up the flag so the standard rate takes over. | Rule | Purpose | | ---------------- | ---------------------------------------------------- | | `activate_intro` | TAG participant on signup, schedule expiration event | | `intro_5pct` | 5% earn rate while `INTRO_ACTIVE` tag exists | | `end_intro` | UNTAG `INTRO_ACTIVE` when the scheduled event fires | | `base_1pct` | 1% catch-all after intro ends | ```json theme={null} { "name": "activate_intro", "order": 50, "condition": "event.type == \"signup\"", "actions": [ { "type": "TAG", "tag": "INTRO_ACTIVE" }, { "type": "SCHEDULE_EVENT", "event_name": "intro_expired", "delay": "2160h" } ] } ``` ```json theme={null} { "name": "intro_5pct", "order": 100, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.amount > 0 && \"INTRO_ACTIVE\" in participant.tags", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)" } ] } ``` ```json theme={null} { "name": "end_intro", "order": 100, "condition": "event.type == \"intro_expired\"", "actions": [ { "type": "UNTAG", "tag": "INTRO_ACTIVE" } ] } ``` ```json theme={null} { "name": "base_1pct", "order": 200, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)" } ] } ``` **How it works:** On signup, the participant gets tagged `INTRO_ACTIVE` and a `intro_expired` event is scheduled 90 days out. During the intro window, purchases match the 5% rule first and `stop_after_match` prevents the 1% rule from also firing. When the scheduled event fires, `UNTAG` removes the flag and subsequent purchases fall through to the 1% catch-all. This approach is more flexible than `active_from`/`active_to` time windows because the intro period is per-participant, starting from their individual signup date. ## Spend threshold unlock A card that pays 1% normally, but bumps to 3% after the participant spends \$2,500 in a month. A counter snapshot checks whether the pre-event total plus the current amount has crossed the line, and a cron automation zeros the counter monthly. | Rule | Purpose | | --------------------- | ------------------------------------------------------------- | | `track_monthly_spend` | Accumulate spend in a counter | | `high_spender_3pct` | 3% if `(snapshot + event.amount) >= 2500`, `stop_after_match` | | `base_1pct` | 1% catch-all for below-threshold purchases | | `reset_monthly_spend` | Zero the counter on the first of each month | ```json theme={null} { "name": "track_monthly_spend", "order": 50, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "COUNTER", "key": "monthly_spend", "value": "event.amount" } ] } ``` ```json theme={null} { "name": "high_spender_3pct", "order": 100, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.amount > 0 && (get(participant.counters, \"monthly_spend\", 0.0) + event.amount) >= 2500.0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)" } ] } ``` ```json theme={null} { "name": "base_1pct", "order": 200, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)" } ] } ``` Add a reset rule and a cron automation to zero the counter at the start of each month: ```json theme={null} { "name": "reset_monthly_spend", "order": 2000, "condition": "event.type == \"monthly_reset\"", "actions": [ { "type": "COUNTER", "key": "monthly_spend", "value": "-get(participant.counters, 'monthly_spend', 0.0)" } ] } ``` The automation that triggers the reset: ```bash theme={null} POST /v1/programs/{programId}/automations { "name": "Monthly Counter Reset", "trigger": { "type": "cron", "cron_expression": "0 0 1 * *" }, "scope": "participants", "participant_filter": "get(participant.counters, \"monthly_spend\", 0.0) > 0", "event_name": "monthly_reset", "payload": { "type": "monthly_reset" } } ``` **How it works:** The threshold check uses `(snapshot + event.amount) >= 2500.0` so the purchase that crosses the threshold earns at the higher rate. Once above 2500, every subsequent purchase that month also earns 3%. On the first of the month, the cron automation generates a `monthly_reset` event for every participant with a non-zero spend counter, and the reset rule zeros the counter by subtracting its current value. See [Automations](/guides/automations) for details on cron setup. ## Promotional window Run a "Summer Double Points" campaign from June 1 to September 1. Set `active_from` and `active_to` on the rule and the engine handles the rest. No condition logic needed. | Field | Value | Effect | | ------------- | ---------------------- | ---------------------------------- | | `active_from` | `2025-06-01T00:00:00Z` | Rule ignored before this timestamp | | `active_to` | `2025-09-01T00:00:00Z` | Rule ignored after this timestamp | You can create the rule weeks in advance. Events outside the window skip the rule automatically. ```json theme={null} { "name": "summer_double_points", "order": 100, "active_from": "2025-06-01T00:00:00Z", "active_to": "2025-09-01T00:00:00Z", "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.02, 2)", "description": "Summer double points bonus" } ] } ``` **How it works:** You create the rule anytime, even weeks before the campaign starts. The engine checks `active_from` and `active_to` against the current wall-clock time and only evaluates the condition when the current time falls within the window. `active_from` and `active_to` are compared 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. ## Expiring points Points that expire 12 months after they're earned. Each purchase starts its own expiration clock. A January purchase expires in January of next year, not when the program year ends. LOT inventory mode creates a separate lot per CREDIT, each with its own countdown. | Config | Value | Notes | | ---------------------- | ------- | ------------------------------- | | Asset `inventory_mode` | `LOT` | Required for expiration to work | | `expires_at` on CREDIT | `8760h` | 12 months in hours | ```json theme={null} { "name": "earn_points", "order": 100, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 1.0, 0)", "expires_at": "8760h", "description": "Points earned (expire in 12 months)" } ] } ``` **How it works:** A January purchase expires in January of next year, a March purchase in March, and so on. When a lot expires, the balance is automatically forfeited. The asset must use `inventory_mode: LOT` for expiration to work. `SIMPLE` mode assets ignore `expires_at`. See [Lots and Expiration](/guides/lots-and-expiration) for details on FIFO ordering and partial expiration. ## Milestone bonus (Nth action) A coffee shop that gives a free drink every 10th purchase. A counter and the modulo operator handle the detection. Because conditions see the counter snapshot (pre-increment value), you check `snapshot + 1` to detect the milestone at the right moment. | Rule | Purpose | | --------------------------- | ------------------------------------ | | `track_purchases` | Increment `purchase_count` counter | | `every_10th_purchase_bonus` | Fire when `(snapshot + 1) % 10 == 0` | ```json theme={null} { "name": "track_purchases", "order": 50, "condition": "event.type == \"purchase\"", "actions": [ { "type": "COUNTER", "key": "purchase_count", "value": "1" } ] } ``` ```json theme={null} { "name": "every_10th_purchase_bonus", "order": 100, "condition": "event.type == \"purchase\" && (get(participant.counters, \"purchase_count\", 0.0) + 1.0) % 10.0 == 0.0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "50", "description": "Every 10th purchase bonus" } ] } ``` **How it works:** The tracking rule at order 50 increments `purchase_count` on every purchase. The bonus rule at order 100 adds 1 to the snapshot and checks if the result is divisible by 10. On the 10th, 20th, 30th purchase (and so on), the modulo evaluates to 0 and the bonus fires. For a **one-time milestone** (e.g., 100th purchase only), replace the modulo with an exact check: ``` get(participant.counters, "purchase_count", 0.0) + 1.0 == 100.0 ``` ## Capped reward per transaction 10% cashback on every purchase, but capped at \$50 per transaction. `math.least` and `math.greatest` in CEL let you express the cap directly in the amount formula. | Purchase amount | Calculated reward | After cap | | --------------- | ----------------- | --------- | | \$200 | \$20 | \$20 | | \$500 | \$50 | \$50 | | \$1,000 | \$100 | **\$50** | ```json theme={null} { "name": "capped_cashback", "order": 100, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(math.least(event.amount * 0.10, 50.0), 2)", "description": "10% cashback, max $50 per transaction" } ] } ``` **How it works:** `math.least(event.amount * 0.10, 50.0)` evaluates both expressions and returns the smaller value. Below \$500, the percentage is less than \$50 so the participant gets the full 10%. At \$500 and above, the cap kicks in. For a **floor** instead of a cap, use `math.greatest`: ``` math.greatest(event.amount * 0.01, 1.0) // at least $1 per transaction ``` ## Reversal / refund A cardholder disputes a \$85.00 dinner charge. The issuer sends a reversal event with the amount and merchant details - the same fields as the original purchase. Scrip debits the cashback that would have been earned on that amount. | Field | Example | Purpose | | -------------- | ------------ | ---------------------------------------- | | `event.type` | `"reversal"` | Distinguishes from a purchase | | `event.amount` | `85.00` | Amount being reversed (can be partial) | | `event.mcc` | `"5812"` | Used to determine the original earn rate | You need one reversal rule per earn rate, mirroring your earning rules: ```json theme={null} { "name": "reversal_dining", "order": 100, "stop_after_match": true, "condition": "event.type == \"reversal\" && event.amount > 0 && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "description": "Reversal of dining 5% cashback" } ] } ``` ```json theme={null} { "name": "reversal_grocery", "order": 200, "stop_after_match": true, "condition": "event.type == \"reversal\" && event.amount > 0 && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "description": "Reversal of grocery 3% cashback" } ] } ``` ```json theme={null} { "name": "reversal_base", "order": 1000, "condition": "event.type == \"reversal\" && event.amount > 0", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "description": "Reversal of base 1% cashback" } ] } ``` **How it works:** Same `stop_after_match` cascade as the earning rules, but with DEBIT instead of CREDIT. A \$40 partial refund on a dining purchase debits `round(40 * 0.05, 2)` = \$2.00. The issuer doesn't need to pre-calculate the cashback amount - just send the reversal amount and MCC. DEBIT fails if the participant's available balance is insufficient. If the participant has already spent their cashback, the reversal event will error. Your backend should handle this case (e.g., retry later, or accept the loss). This approach works well for category-based rates where the rate is deterministic from the MCC. For threshold-based rates (e.g., 1% vs 3% depending on monthly spend), the reversal may not perfectly match the original earn rate if the participant's counter has changed since the original purchase. If precision matters, have your backend include the original earn rate in the reversal event. ## Tiered earn rates A hotel loyalty program where Silver members earn 1x points per dollar, Gold earns 1.5x, and Platinum earns 2x. Participants advance automatically based on annual spend. Each tier level carries a `multiplier` benefit that rules reference directly in the amount expression, so you don't need separate rules per level. | What | How | | ---------------------- | ------------------------------------------------------------------------------------ | | Track qualifying spend | Counter `ytd_spend`, incremented on every purchase | | Auto-advance tiers | Qualification criteria on each level (Silver: $500, Gold: $2,000, Platinum: \$5,000) | | Dynamic earn rate | `participant.tiers.loyalty.benefits.multiplier` in the CREDIT amount | | Annual reset | `CALENDAR_YEAR` qualification period, counters reset to 0 | First, create the tier type with levels, qualification criteria, and a `multiplier` benefit on each level: ```bash theme={null} POST /v1/programs/{programId}/tiers { "key": "loyalty", "display_name": "Loyalty Status", "levels": [ { "key": "silver", "rank": 1, "display_name": "Silver", "qualification": { "mode": "ALL", "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 500}] }, "benefits": {"multiplier": 1.0} }, { "key": "gold", "rank": 2, "display_name": "Gold", "qualification": { "mode": "ALL", "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 2000}] }, "benefits": {"multiplier": 1.5} }, { "key": "platinum", "rank": 3, "display_name": "Platinum", "qualification": { "mode": "ALL", "criteria": [{"counter": "ytd_spend", "operator": ">=", "threshold": 5000}] }, "benefits": {"multiplier": 2.0} } ], "lifecycle": { "retention": {"mode": "PERIOD_BASED"}, "qualification_period": {"type": "CALENDAR_YEAR"}, "downgrade_policy": {"mode": "DROP_TO_QUALIFYING"}, "counters": {"qualifying": ["ytd_spend"], "rollover": "NONE"} } } ``` Then three rules: one to track spend (which drives qualification), one to earn at the tier rate, and a catch-all for participants who haven't reached a tier yet. ```json theme={null} { "name": "track_ytd_spend", "order": 50, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "COUNTER", "key": "ytd_spend", "value": "event.amount" } ] } ``` ```json theme={null} { "name": "tiered_earn", "order": 100, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.amount > 0 && has(participant.tiers.loyalty)", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * participant.tiers.loyalty.benefits.multiplier, 0)" } ] } ``` ```json theme={null} { "name": "base_earn", "order": 200, "condition": "event.type == \"purchase\" && event.amount > 0", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 1.0, 0)" } ] } ``` **How it works:** The `track_ytd_spend` rule at order 50 accumulates annual spend. After all rules fire for the event, Scrip auto-evaluates tier qualification against the updated counter. If the participant crosses a threshold, they advance. The `tiered_earn` rule at order 100 pulls the earn rate from `participant.tiers.loyalty.benefits.multiplier`, so adding a new tier level or changing a multiplier is a tier config change, not a rule change. `has(participant.tiers.loyalty)` ensures participants without a tier fall through to `base_earn` at order 200 for the default 1x rate. Tier advancement happens after all rules evaluate for the current event. A purchase that pushes `ytd_spend` past the Gold threshold qualifies the participant, but the current event still earns at the pre-advancement rate. The new multiplier applies starting with the next event. At the end of each calendar year, the `PERIOD_BASED` lifecycle re-evaluates tiers. Participants who no longer meet their level's criteria are downgraded to the highest level they still qualify for (`DROP_TO_QUALIFYING`). The `NONE` rollover resets `ytd_spend` to 0. See [Tiers](/guides/tiers) for lifecycle details. # Stripe Issuing Source: https://docs.scrip.dev/examples/stripe-issuing Connect Stripe Issuing card transactions to your rewards program Build a rewards program for cardholders using [Stripe Issuing](https://docs.stripe.com/issuing). Stripe sends webhook events when card transactions are authorized and settled, your middleware transforms and forwards them, and Scrip handles the rest. ## Choose your approach Stripe Issuing supports two integration patterns depending on whether you need to show pending rewards before a transaction settles. | | Settlement-only | Auth + Settlement | | -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **When to use** | You only need to award rewards after the transaction settles | You want to show pending rewards immediately when the cardholder taps their card | | **Stripe events** | `issuing_transaction.created` | `issuing_authorization.created` + `issuing_authorization.updated` + `issuing_transaction.created` | | **Scrip asset mode** | `SIMPLE` | `LOT` (required for `reference_id` lot stamping) | | **Complexity** | \~60 lines of middleware, 6 rules | \~100 lines of middleware, 10 rules | | **Trade-off** | Simpler. No pending state to manage. | Cardholders see rewards in real time. Voided authorizations are cancelled immediately. The system reconciles automatically if the settlement amount differs from the authorization (tips, partial captures). | **Start with settlement-only** if you're unsure. You can add authorization holds later without changing your existing rules — the settlement rules continue to work as-is when there are no held lots to reconcile. ## Architecture ```mermaid theme={null} sequenceDiagram participant Stripe as Stripe Issuing participant MW as Your Server participant Scrip as Scrip Stripe->>MW: issuing_transaction.created MW->>MW: Verify Stripe signature MW-->>Stripe: 200 OK MW->>MW: Transform payload MW->>Scrip: POST /v1/events Scrip-->>MW: 202 Accepted Scrip->>Scrip: Evaluate rules Scrip->>Scrip: Credit rewards ``` The middleware is small, about 60 lines of code. It verifies the Stripe signature, extracts the fields Scrip needs, and forwards the event. No business logic lives here; all reward calculations happen in Scrip rules. **What you need:** | Stripe side | Scrip side | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | A [Stripe Issuing](https://docs.stripe.com/issuing) integration with active cards | A program created for your card rewards | | A webhook endpoint (configured in [Webhook setup](#webhook-setup)) | An asset linked to that program (e.g., `CASHBACK_USD`, `SIMPLE` mode) | | | Participants enrolled with `external_id` set to the Stripe cardholder ID (e.g., `ich_...`) | | | An API key for your middleware to call the Scrip API | ```mermaid theme={null} sequenceDiagram participant Stripe as Stripe Issuing participant MW as Your Server participant Scrip as Scrip Stripe->>MW: issuing_authorization.created MW->>MW: Verify & transform MW->>Scrip: POST /v1/events (type: auth) Scrip->>Scrip: Hold provisional rewards Note over Stripe,Scrip: Merchant captures (hours to days later) Stripe->>MW: issuing_transaction.created MW->>MW: Verify & transform MW->>Scrip: POST /v1/events (type: settlement) Scrip->>Scrip: Reconcile held → available ``` The middleware handles two Stripe event types. Authorization events mint provisional rewards into the `HELD` bucket. When the merchant captures, the settlement event triggers auto-reconciliation — held lots are consumed and the final amount is credited to `AVAILABLE`. If the amounts differ (tips, partial captures), the delta is handled automatically. **What you need:** | Stripe side | Scrip side | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | A [Stripe Issuing](https://docs.stripe.com/issuing) integration with active cards | A program created for your card rewards | | A webhook endpoint (configured in [Webhook setup](#webhook-setup)) | An asset linked to that program in **`LOT` mode** (e.g., `CASHBACK_USD`, `LOT` mode, `UNLIMITED`) | | | Participants enrolled with `external_id` set to the Stripe cardholder ID (e.g., `ich_...`) | | | An API key for your middleware to call the Scrip API | See the [quickstart](/quickstart) if you need help with initial setup. Your middleware identifies cardholders by passing the Stripe `cardholder` ID as the Scrip `external_id`. This means each Scrip participant's `external_id` must match their Stripe cardholder ID: ```bash theme={null} curl -X POST https://api.scrip.dev/v1/participants \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "program_id": "{{PROGRAM_ID}}", "external_id": "ich_1MzFMzK8F4fqH0lBmFq8CjbU", "display_name": "Jane Doe" }' ``` If you'd rather use your own user IDs, have your middleware look up the mapping before forwarding to Scrip. The Stripe cardholder object's [`metadata`](https://docs.stripe.com/api/issuing/cardholders/object#issuing_cardholder_object-metadata) field is a good place to store your internal user ID. ## Webhook setup In the [Stripe Dashboard](https://dashboard.stripe.com/webhooks) or via the API, create a webhook endpoint that subscribes to `issuing_transaction.created`: ```bash theme={null} curl https://api.stripe.com/v1/webhook_endpoints \ -u "$STRIPE_SECRET_KEY:" \ -d url="https://your-server.com/webhooks/stripe" \ -d "enabled_events[]"="issuing_transaction.created" ``` Store the webhook signing secret (`whsec_...`). You'll use it to verify incoming requests. You only need `issuing_transaction.created`. This single event covers both captures (settlement) and refunds. The transaction's `type` field distinguishes them. Authorizations (`issuing_authorization.*`) are not settlement and can be reversed, expired, or never captured, which makes them unreliable for reward calculations. See [Stripe's transaction lifecycle](https://docs.stripe.com/issuing/purchases/transactions) for details. In the [Stripe Dashboard](https://dashboard.stripe.com/webhooks) or via the API, create a webhook endpoint that subscribes to authorization, transaction, and (optionally) authorization update events: ```bash theme={null} curl https://api.stripe.com/v1/webhook_endpoints \ -u "$STRIPE_SECRET_KEY:" \ -d url="https://your-server.com/webhooks/stripe" \ -d "enabled_events[]"="issuing_authorization.created" \ -d "enabled_events[]"="issuing_authorization.updated" \ -d "enabled_events[]"="issuing_transaction.created" ``` Store the webhook signing secret (`whsec_...`). You'll use it to verify incoming requests. | Event | When it fires | | ------------------------------- | ---------------------------------------------------------- | | `issuing_authorization.created` | Card is authorized | | `issuing_authorization.updated` | Authorization is reversed or closed without capture | | `issuing_transaction.created` | Merchant captures (settles) the payment or issues a refund | `issuing_authorization.updated` is optional. Without it, uncaptured authorizations expire based on the `expires_at` set in your auth rules (`720h` in the examples below). With it, you can void held rewards immediately when an authorization is reversed. See [Uncaptured authorizations](#uncaptured-authorizations) for details on both approaches. ## Middleware You need a small service between Stripe and Scrip. It receives Stripe webhook events, verifies the signature, transforms the payload, and forwards it to `POST /v1/events`. | Stripe field | Scrip field | Transformation | | ----------------------------------------- | ----------------------------- | -------------------------------------------------------------- | | `data.object.id` | `idempotency_key` | Use directly (e.g., `ipi_1Mz...`) | | `data.object.cardholder` | `external_id` | Use directly (e.g., `ich_1Mz...`) | | `data.object.created` | `event_timestamp` | Convert Unix timestamp to RFC 3339 | | `data.object.type` | `event_data.type` | Map `"capture"` → `"purchase"`, `"refund"` → `"refund"` | | `data.object.amount` | `event_data.amount` | `abs(amount) / 100`. Stripe uses cents, negative for captures. | | `data.object.currency` | `event_data.currency` | Uppercase (e.g., `"usd"` → `"USD"`) | | `data.object.merchant_data.category_code` | `event_data.mcc` | Use directly (e.g., `"5812"`) | | `data.object.merchant_data.name` | `event_data.merchant_name` | Use directly | | `data.object.merchant_data.city` | `event_data.merchant_city` | Use directly | | `data.object.merchant_data.country` | `event_data.merchant_country` | Use directly | | `data.object.authorization` | `event_data.authorization_id` | Use directly. Links refunds to the original purchase. | | `data.object.wallet` | `event_data.wallet` | `"apple_pay"`, `"google_pay"`, `"samsung_pay"`, or `null` | The fields above are the ones used by the example rules in this guide. Your middleware can include any additional fields in `event_data` that are useful for your program. Stripe's [Transaction object](https://docs.stripe.com/api/issuing/transactions/object) includes purchase details, network data, and more. Anything you add to `event_data` is available in rule conditions as `event.`. ```javascript Node.js theme={null} const express = require("express"); const Stripe = require("stripe"); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const app = express(); const SCRIP_API_KEY = process.env.SCRIP_API_KEY; const SCRIP_PROGRAM_ID = process.env.SCRIP_PROGRAM_ID; const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; app.post( "/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => { // 1. Verify the Stripe signature let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers["stripe-signature"], STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error("Signature verification failed:", err.message); return res.status(400).send("Invalid signature"); } // 2. Only handle issuing transaction events if (event.type !== "issuing_transaction.created") { return res.json({ received: true }); } const txn = event.data.object; // 3. Transform and forward to Scrip const scripEvent = { program_id: SCRIP_PROGRAM_ID, external_id: txn.cardholder, idempotency_key: txn.id, event_timestamp: new Date(txn.created * 1000).toISOString(), event_data: { type: txn.type === "capture" ? "purchase" : "refund", amount: Math.abs(txn.amount) / 100, currency: txn.currency.toUpperCase(), mcc: txn.merchant_data.category_code, merchant_name: txn.merchant_data.name, merchant_city: txn.merchant_data.city, merchant_country: txn.merchant_data.country, authorization_id: txn.authorization, stripe_transaction_id: txn.id, wallet: txn.wallet, }, }; const response = await fetch("https://api.scrip.dev/v1/events", { method: "POST", headers: { Authorization: `Bearer ${SCRIP_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(scripEvent), }); if (!response.ok) { const body = await response.text(); console.error("Scrip API error:", response.status, body); // Return 500 so Stripe retries this webhook return res.status(500).send("Failed to forward event"); } res.json({ received: true }); } ); app.listen(3000); ``` ```python Python theme={null} import os, stripe from flask import Flask, request, jsonify from datetime import datetime, timezone import requests app = Flask(__name__) stripe.api_key = os.environ["STRIPE_SECRET_KEY"] STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] SCRIP_API_KEY = os.environ["SCRIP_API_KEY"] SCRIP_PROGRAM_ID = os.environ["SCRIP_PROGRAM_ID"] @app.route("/webhooks/stripe", methods=["POST"]) def stripe_webhook(): # 1. Verify the Stripe signature try: event = stripe.Webhook.construct_event( request.data, request.headers["Stripe-Signature"], STRIPE_WEBHOOK_SECRET, ) except (ValueError, stripe.error.SignatureVerificationError): return "Invalid signature", 400 # 2. Only handle issuing transaction events if event["type"] != "issuing_transaction.created": return jsonify(received=True) txn = event["data"]["object"] # 3. Transform and forward to Scrip scrip_event = { "program_id": SCRIP_PROGRAM_ID, "external_id": txn["cardholder"], "idempotency_key": txn["id"], "event_timestamp": datetime.fromtimestamp( txn["created"], tz=timezone.utc ).isoformat(), "event_data": { "type": "purchase" if txn["type"] == "capture" else "refund", "amount": abs(txn["amount"]) / 100, "currency": txn["currency"].upper(), "mcc": txn["merchant_data"]["category_code"], "merchant_name": txn["merchant_data"]["name"], "merchant_city": txn["merchant_data"]["city"], "merchant_country": txn["merchant_data"]["country"], "authorization_id": txn.get("authorization"), "stripe_transaction_id": txn["id"], "wallet": txn.get("wallet"), }, } resp = requests.post( "https://api.scrip.dev/v1/events", headers={ "Authorization": f"Bearer {SCRIP_API_KEY}", "Content-Type": "application/json", }, json=scrip_event, ) if not resp.ok: print(f"Scrip API error: {resp.status_code} {resp.text}") return "Failed to forward event", 500 return jsonify(received=True) ``` The middleware handles three Stripe event types. Authorization events carry the same merchant data as transactions (MCC, merchant name, etc.), so reward rates can be calculated at auth time. Authorization updates detect voids. **Authorization events** (`issuing_authorization.created`): | Stripe field | Scrip field | Transformation | | ----------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------ | | `data.object.id` | `event_data.authorization_id` | Use directly (e.g., `iauth_1Mz...`). Also used as part of the idempotency key. | | `data.object.cardholder` | `external_id` | Use directly (e.g., `ich_1Mz...`) | | `data.object.created` | `event_timestamp` | Convert Unix timestamp to RFC 3339 | | (hardcoded) | `event_data.type` | Always `"auth"` | | `data.object.amount` | `event_data.amount` | `amount / 100`. Stripe uses cents. Auth amounts are positive. | | `data.object.currency` | `event_data.currency` | Uppercase (e.g., `"usd"` → `"USD"`) | | `data.object.merchant_data.category_code` | `event_data.mcc` | Use directly (e.g., `"5812"`) | | `data.object.merchant_data.name` | `event_data.merchant_name` | Use directly | | `data.object.merchant_data.city` | `event_data.merchant_city` | Use directly | | `data.object.merchant_data.country` | `event_data.merchant_country` | Use directly | **Transaction events** (`issuing_transaction.created`): | Stripe field | Scrip field | Transformation | | ----------------------------------------- | ----------------------------- | -------------------------------------------------------------- | | `data.object.id` | `idempotency_key` | Use directly (e.g., `ipi_1Mz...`) | | `data.object.cardholder` | `external_id` | Use directly | | `data.object.created` | `event_timestamp` | Convert Unix timestamp to RFC 3339 | | `data.object.type` | `event_data.type` | Map `"capture"` → `"settlement"`, `"refund"` → `"refund"` | | `data.object.amount` | `event_data.amount` | `abs(amount) / 100`. Stripe uses cents, negative for captures. | | `data.object.authorization` | `event_data.authorization_id` | Use directly. Links settlement to the original auth. | | `data.object.merchant_data.category_code` | `event_data.mcc` | Use directly | | `data.object.merchant_data.name` | `event_data.merchant_name` | Use directly | | `data.object.wallet` | `event_data.wallet` | `"apple_pay"`, `"google_pay"`, `"samsung_pay"`, or `null` | Note that captures are mapped to `"settlement"` (not `"purchase"`) so your rules can distinguish between the authorization and settlement stages. **Authorization update events** (`issuing_authorization.updated`): | Stripe field | Scrip field | Transformation | | ------------------------ | ----------------------------- | ----------------------------------------------------------------------------- | | `data.object.id` | `event_data.authorization_id` | Use directly. Same ID as the original auth. | | `data.object.cardholder` | `external_id` | Use directly | | (computed) | `event_data.type` | `"void"` when `status` is `reversed` or `closed` without a linked transaction | | `data.object.amount` | `event_data.amount` | `amount / 100` | Only forward this event when the authorization is voided (reversed or closed without capture). Other status updates (e.g., amount changes on incremental auths) can be ignored. ```javascript Node.js theme={null} const express = require("express"); const Stripe = require("stripe"); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const app = express(); const SCRIP_API_KEY = process.env.SCRIP_API_KEY; const SCRIP_PROGRAM_ID = process.env.SCRIP_PROGRAM_ID; const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; app.post( "/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent( req.body, req.headers["stripe-signature"], STRIPE_WEBHOOK_SECRET ); } catch (err) { console.error("Signature verification failed:", err.message); return res.status(400).send("Invalid signature"); } let scripEvent; if (event.type === "issuing_authorization.created") { const auth = event.data.object; scripEvent = { program_id: SCRIP_PROGRAM_ID, external_id: auth.cardholder, idempotency_key: `auth_${auth.id}`, event_timestamp: new Date(auth.created * 1000).toISOString(), event_data: { type: "auth", amount: auth.amount / 100, currency: auth.currency.toUpperCase(), mcc: auth.merchant_data.category_code, merchant_name: auth.merchant_data.name, merchant_city: auth.merchant_data.city, merchant_country: auth.merchant_data.country, authorization_id: auth.id, }, }; } else if (event.type === "issuing_authorization.updated") { const auth = event.data.object; // Only forward when the authorization is voided if (auth.status !== "reversed" && auth.status !== "closed") { return res.json({ received: true }); } scripEvent = { program_id: SCRIP_PROGRAM_ID, external_id: auth.cardholder, idempotency_key: `void_${auth.id}`, event_timestamp: new Date().toISOString(), event_data: { type: "void", amount: auth.amount / 100, authorization_id: auth.id, }, }; } else if (event.type === "issuing_transaction.created") { const txn = event.data.object; scripEvent = { program_id: SCRIP_PROGRAM_ID, external_id: txn.cardholder, idempotency_key: txn.id, event_timestamp: new Date(txn.created * 1000).toISOString(), event_data: { type: txn.type === "capture" ? "settlement" : "refund", amount: Math.abs(txn.amount) / 100, currency: txn.currency.toUpperCase(), mcc: txn.merchant_data.category_code, merchant_name: txn.merchant_data.name, merchant_city: txn.merchant_data.city, merchant_country: txn.merchant_data.country, authorization_id: txn.authorization, stripe_transaction_id: txn.id, wallet: txn.wallet, }, }; } else { return res.json({ received: true }); } const response = await fetch("https://api.scrip.dev/v1/events", { method: "POST", headers: { Authorization: `Bearer ${SCRIP_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(scripEvent), }); if (!response.ok) { const body = await response.text(); console.error("Scrip API error:", response.status, body); return res.status(500).send("Failed to forward event"); } res.json({ received: true }); } ); app.listen(3000); ``` ```python Python theme={null} import os, stripe from flask import Flask, request, jsonify from datetime import datetime, timezone import requests app = Flask(__name__) stripe.api_key = os.environ["STRIPE_SECRET_KEY"] STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] SCRIP_API_KEY = os.environ["SCRIP_API_KEY"] SCRIP_PROGRAM_ID = os.environ["SCRIP_PROGRAM_ID"] @app.route("/webhooks/stripe", methods=["POST"]) def stripe_webhook(): try: event = stripe.Webhook.construct_event( request.data, request.headers["Stripe-Signature"], STRIPE_WEBHOOK_SECRET, ) except (ValueError, stripe.error.SignatureVerificationError): return "Invalid signature", 400 scrip_event = None if event["type"] == "issuing_authorization.created": auth = event["data"]["object"] scrip_event = { "program_id": SCRIP_PROGRAM_ID, "external_id": auth["cardholder"], "idempotency_key": f"auth_{auth['id']}", "event_timestamp": datetime.fromtimestamp( auth["created"], tz=timezone.utc ).isoformat(), "event_data": { "type": "auth", "amount": auth["amount"] / 100, "currency": auth["currency"].upper(), "mcc": auth["merchant_data"]["category_code"], "merchant_name": auth["merchant_data"]["name"], "merchant_city": auth["merchant_data"]["city"], "merchant_country": auth["merchant_data"]["country"], "authorization_id": auth["id"], }, } elif event["type"] == "issuing_authorization.updated": auth = event["data"]["object"] # Only forward when the authorization is voided if auth["status"] not in ("reversed", "closed"): return jsonify(received=True) scrip_event = { "program_id": SCRIP_PROGRAM_ID, "external_id": auth["cardholder"], "idempotency_key": f"void_{auth['id']}", "event_timestamp": datetime.now(tz=timezone.utc).isoformat(), "event_data": { "type": "void", "amount": auth["amount"] / 100, "authorization_id": auth["id"], }, } elif event["type"] == "issuing_transaction.created": txn = event["data"]["object"] scrip_event = { "program_id": SCRIP_PROGRAM_ID, "external_id": txn["cardholder"], "idempotency_key": txn["id"], "event_timestamp": datetime.fromtimestamp( txn["created"], tz=timezone.utc ).isoformat(), "event_data": { "type": "settlement" if txn["type"] == "capture" else "refund", "amount": abs(txn["amount"]) / 100, "currency": txn["currency"].upper(), "mcc": txn["merchant_data"]["category_code"], "merchant_name": txn["merchant_data"]["name"], "merchant_city": txn["merchant_data"]["city"], "merchant_country": txn["merchant_data"]["country"], "authorization_id": txn.get("authorization"), "stripe_transaction_id": txn["id"], "wallet": txn.get("wallet"), }, } else: return jsonify(received=True) resp = requests.post( "https://api.scrip.dev/v1/events", headers={ "Authorization": f"Bearer {SCRIP_API_KEY}", "Content-Type": "application/json", }, json=scrip_event, ) if not resp.ok: print(f"Scrip API error: {resp.status_code} {resp.text}") return "Failed to forward event", 500 return jsonify(received=True) ``` Return a 2xx to Stripe quickly. If forwarding to Scrip fails, return 500 so Stripe retries the delivery. Stripe retries with exponential backoff for up to 3 days. See [Stripe's retry behavior](https://docs.stripe.com/webhooks#retry-logic). ## Rules With the middleware in place, Stripe events arrive with a consistent `event_data` shape. Here's a complete rule set for a cashback card with category multipliers and refund handling. **Earning rules** Award cashback when `event_data.type` is `"purchase"`: ```json theme={null} { "name": "dining_5pct", "order": 100, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "description": "Dining 5% cashback" } ] } ``` ```json theme={null} { "name": "grocery_3pct", "order": 200, "stop_after_match": true, "condition": "event.type == \"purchase\" && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "description": "Grocery 3% cashback" } ] } ``` ```json theme={null} { "name": "base_1pct", "order": 1000, "condition": "event.type == \"purchase\"", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "description": "Base 1% cashback" } ] } ``` Dining matches first at order 100 and `stop_after_match` prevents the base rule from stacking. See [Category multipliers](/examples/common-patterns#category-multipliers) for a deeper walkthrough. **Reversal rules** When a cardholder receives a refund, the cashback they earned on that purchase should be clawed back. Reversal rules mirror the earning rules with DEBIT instead of CREDIT, using the same multiplier per MCC so the debit matches what was originally awarded: ```json theme={null} { "name": "refund_dining", "order": 110, "stop_after_match": true, "condition": "event.type == \"refund\" && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "allow_negative": true, "description": "Refund: dining 5% clawback" } ] } ``` ```json theme={null} { "name": "refund_grocery", "order": 210, "stop_after_match": true, "condition": "event.type == \"refund\" && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "allow_negative": true, "description": "Refund: grocery 3% clawback" } ] } ``` ```json theme={null} { "name": "refund_base", "order": 1010, "condition": "event.type == \"refund\"", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "allow_negative": true, "description": "Refund: base 1% clawback" } ] } ``` The reversal amount is calculated from the refund amount and MCC, using the same multiplier as the earning rule. Stripe includes the MCC on both captures and refunds, so the rates always match. The refund rules above use `"allow_negative": true` so the debit succeeds even if the cardholder has already spent their cashback. The balance goes negative and is offset by future earnings. Without `allow_negative`, a DEBIT fails when the available balance is insufficient, and the refund event would be marked `FAILED`. See [Negative Balances](/guides/balance-operations#negative-balances) for more on this behavior. **Authorization rules** When a card is authorized, credit provisional rewards into the `HELD` bucket. The `reference_id` stamps the held lots with the authorization ID so they can be matched at settlement. The `expires_at` ensures uncaptured authorizations are cleaned up automatically. ```json theme={null} { "name": "auth_dining_5pct", "order": 100, "stop_after_match": true, "condition": "event.type == \"auth\" && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "bucket": "HELD", "reference_id": "event.authorization_id", "expires_at": "720h", "description": "Pending: dining 5% cashback" } ] } ``` ```json theme={null} { "name": "auth_grocery_3pct", "order": 200, "stop_after_match": true, "condition": "event.type == \"auth\" && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "bucket": "HELD", "reference_id": "event.authorization_id", "expires_at": "720h", "description": "Pending: grocery 3% cashback" } ] } ``` ```json theme={null} { "name": "auth_base_1pct", "order": 1000, "condition": "event.type == \"auth\"", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "bucket": "HELD", "reference_id": "event.authorization_id", "expires_at": "720h", "description": "Pending: base 1% cashback" } ] } ``` The cardholder's balance now shows pending rewards in the `held` field. These are visible but not spendable. **Settlement rules** When the merchant captures the transaction, credit to `AVAILABLE` with the same `reference_id`. The system automatically reconciles the held lots against the settlement amount. If the amounts differ (tip added, partial capture), the delta is handled without any extra logic. ```json theme={null} { "name": "settle_dining_5pct", "order": 100, "stop_after_match": true, "condition": "event.type == \"settlement\" && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "reference_id": "event.authorization_id", "description": "Dining 5% cashback" } ] } ``` ```json theme={null} { "name": "settle_grocery_3pct", "order": 200, "stop_after_match": true, "condition": "event.type == \"settlement\" && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "reference_id": "event.authorization_id", "description": "Grocery 3% cashback" } ] } ``` ```json theme={null} { "name": "settle_base_1pct", "order": 1000, "condition": "event.type == \"settlement\"", "actions": [ { "type": "CREDIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "reference_id": "event.authorization_id", "description": "Base 1% cashback" } ] } ``` If the `authorization_id` is `null` (force capture with no prior auth), the settlement rules still work — the system falls back to a standard credit since there are no held lots to reconcile. See [Balance Operations: Auth / Settlement Pattern](/guides/balance-operations#auth--settlement-pattern) for details on how auto-reconciliation works. **Reversal rules** Refund handling is the same as settlement-only. Refunds arrive after settlement, so there are no held lots involved: ```json theme={null} { "name": "refund_dining", "order": 110, "stop_after_match": true, "condition": "event.type == \"refund\" && event.mcc in [\"5812\", \"5813\", \"5814\"]", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.05, 2)", "allow_negative": true, "description": "Refund: dining 5% clawback" } ] } ``` ```json theme={null} { "name": "refund_grocery", "order": 210, "stop_after_match": true, "condition": "event.type == \"refund\" && event.mcc in [\"5411\", \"5422\"]", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.03, 2)", "allow_negative": true, "description": "Refund: grocery 3% clawback" } ] } ``` ```json theme={null} { "name": "refund_base", "order": 1010, "condition": "event.type == \"refund\"", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * 0.01, 2)", "allow_negative": true, "description": "Refund: base 1% clawback" } ] } ``` The refund rules use `"allow_negative": true` so the debit succeeds even if the cardholder has already spent their cashback. See [Negative Balances](/guides/balance-operations#negative-balances). **Void rule** When an authorization is reversed before settlement, cancel the provisional rewards. A single rule handles all MCCs because `VOID_HOLD` voids the entire accrual by `reference_id`: ```json theme={null} { "name": "void_auth", "order": 50, "condition": "event.type == \"void\"", "actions": [ { "type": "VOID_HOLD", "asset_id": "{{ASSET_ID}}", "reference_id": "event.authorization_id" } ] } ``` This returns the held value to the program wallet (prefunded) or system issuance (unlimited) and removes the pending balance from the cardholder's view. If the authorization was already settled or the held lots already expired, the void is a no-op. See [Balance Operations: Void Hold](/guides/balance-operations#void-hold) for details on how void hold works. **Excluding non-qualifying transactions** Block cash advances, wire transfers, and gambling from earning rewards by adding MCC exclusions to your rules: ```javascript theme={null} event.type == "purchase" && !(event.mcc in ["6010", "6011", "6012", "6051", "4829", "7995"]) ``` | MCC | Category | | ---------------- | ----------------------------------------- | | 6010, 6011, 6012 | Cash advances / ATM withdrawals | | 6051 | Quasi-cash (money orders, wire transfers) | | 4829 | Wire transfers | | 7995 | Gambling | Add these to every earning rule's condition, or create a `stop_after_match` rule at a low order number that matches excluded MCCs and takes no actions. This swallows the event before it reaches earning rules. ## Transaction lifecycle ```mermaid theme={null} flowchart TD A[Cardholder taps card] --> B["Authorization created issuing_authorization.created Status: PENDING"] B --> C["Merchant captures payment (typically within 24h; hotels up to 31 days)"] C --> D["Transaction created issuing_transaction.created type: capture"] D --> E["Send purchase event to Scrip"] D --> F["[Optional] Merchant issues refund"] F --> G["Transaction created issuing_transaction.created type: refund"] G --> H["Send refund event to Scrip"] ``` Authorizations are not final. They can be reversed, expired, partially captured, or over-captured. Awarding points on authorization would require reversing them on every one of these outcomes. Waiting for the `capture` transaction avoids this complexity entirely. ```mermaid theme={null} flowchart TD A[Cardholder taps card] --> B["Authorization created issuing_authorization.created"] B --> C["Send auth event to Scrip → Provisional rewards held"] B --> D{Merchant action} D -->|Captures| E["Transaction created issuing_transaction.created type: capture"] E --> F["Send settlement event to Scrip → Held rewards reconciled to available"] E --> G["[Optional] Merchant issues refund"] G --> H["Transaction created issuing_transaction.created type: refund"] H --> I["Send refund event to Scrip → Cashback debited"] D -->|Reverses| J["Authorization updated issuing_authorization.updated status: reversed"] J --> K["Send void event to Scrip → Held rewards voided"] D -->|Never captures| L["Authorization expires → Held lots expire automatically"] ``` The authorization and settlement events both flow through your middleware to Scrip. Between the two, the cardholder sees pending rewards in their `held` balance. When the merchant captures, the held rewards move to `available`. If the merchant reverses the authorization, a `VOID_HOLD` rule cancels the provisional rewards immediately. If the authorization is never captured and no reversal arrives, the held lots expire based on the `expires_at` set in the auth rule. The Stripe [Transaction object](https://docs.stripe.com/api/issuing/transactions/object) includes an `authorization` field that references the original authorization ID. This link is present on both captures and refunds. | Scenario | `authorization` field | | ----------------------------- | -------------------------------------------- | | Normal capture | Set to the authorization ID | | Normal refund | Usually set to the original authorization ID | | Force capture (no prior auth) | `null` | | Unlinked refund | `null` | Your middleware passes `authorization_id` through in `event_data`. You can reference it in rule conditions if needed. For example, to skip refund processing on force captures: ```javascript theme={null} event.type == "refund" && event.authorization_id != null ``` Stripe says linking refunds to authorizations is ["an inexact science"](https://docs.stripe.com/issuing/purchases/transactions#refunds). Some refunds arrive with `authorization: null`. For category-based reward rates, this doesn't matter. The refund carries its own MCC and the correct debit rate can be calculated independently. For more complex scenarios (threshold-based rates, tiered earn rates), see [Edge cases](#edge-cases). ## Edge cases **Partial refunds.** Stripe refunds can be for less than the original capture amount. The refund transaction's `amount` reflects only the refunded portion, and your reversal rules calculate the debit from that amount. No special handling needed. A \$40 refund on a \$100 dining purchase debits \$2.00 (5% of \$40). **Multi-capture transactions.** Airlines, hotels, and car rental companies can create multiple capture transactions against a single authorization. Each capture arrives as a separate `issuing_transaction.created` event with a unique `transaction.id`, so they map to separate Scrip events with distinct idempotency keys. **Force captures.** Some merchants settle without a prior authorization (e.g., offline transactions). These arrive as `type: "capture"` with `authorization: null`. Award points normally. The transaction is still a valid settled purchase. For the auth + settlement approach, the settlement rules still work — when `authorization_id` is `null`, there are no held lots to reconcile, so the system performs a standard credit. **Refund reversals.** Stripe can reverse a refund if it was issued in error. This appears as a transaction with `type: "refund"` and a **negative** `amount`. Your middleware should detect this case and map it to a `"purchase"` type instead: ```javascript theme={null} // In your middleware's transform logic let eventType; if (txn.type === "capture") { eventType = "purchase"; // or "settlement" for auth + settlement } else if (txn.type === "refund" && txn.amount < 0) { // Refund reversal: treat as a purchase (re-award points) eventType = "purchase"; // or "settlement" for auth + settlement } else { eventType = "refund"; } ``` **Threshold-based earn rates and refunds.** If your program uses spend thresholds (e.g., 1% normally, 3% after \$2,500/month), a refund processed weeks later may not reverse at the same rate the original purchase earned. Two approaches: accept the approximation (the difference is usually small), or have your middleware include the original earn rate in the refund event as `event.original_earn_rate` and use a priority refund rule that references it: ```json theme={null} { "name": "refund_with_original_rate", "order": 50, "stop_after_match": true, "condition": "event.type == \"refund\" && has(event.original_earn_rate)", "actions": [ { "type": "DEBIT", "asset_id": "{{ASSET_ID}}", "amount": "round(event.amount * event.original_earn_rate, 2)", "allow_negative": true } ] } ``` This rule takes priority (order 50) when `original_earn_rate` is present, and the standard MCC-based refund rules handle cases where it isn't. **Disputes.** Stripe Issuing [disputes](https://docs.stripe.com/issuing/purchases/disputes) follow a separate lifecycle. If a cardholder disputes a transaction and wins, the funds return to the issuing balance but no `issuing_transaction.created` event fires. Instead, Stripe emits `issuing_dispute.*` events. If you need to reverse points on successful disputes, subscribe to `issuing_dispute.closed` in your Stripe webhook and forward it as a refund event when `dispute.status === "won"`. For many programs, dispute-based reversals aren't necessary. The volume is low and the complexity isn't worth it. ### Uncaptured authorizations *Auth + settlement only.* An authorization that is never captured (reversed by the merchant or expired by Stripe) does not produce an `issuing_transaction.created` event. Two approaches handle this: **Active void (recommended).** The middleware and rules in this guide already handle this case. When `authorization.status` changes to `reversed` or `closed`, the middleware forwards a void event and the `void_auth` rule cancels the provisional rewards via `VOID_HOLD`. This removes the held balance from the cardholder's view and returns value to the program wallet. **Passive expiration.** If you choose not to subscribe to `issuing_authorization.updated`, the held lots created on auth have an `expires_at` (set to `720h` in the example rules). They expire and are forfeited to breakage automatically. This is simpler but leaves stale held balances visible until expiration. ## Testing Stripe Issuing supports [test mode](https://docs.stripe.com/issuing/testing). Create test cardholders and simulate transactions: ```bash theme={null} # Create a test cardholder curl https://api.stripe.com/v1/issuing/cardholders \ -u "$STRIPE_SECRET_KEY:" \ -d name="Test User" \ -d email="test@example.com" \ -d "billing[address][line1]"="123 Main St" \ -d "billing[address][city]"="San Francisco" \ -d "billing[address][state]"="CA" \ -d "billing[address][postal_code]"="94105" \ -d "billing[address][country]"="US" \ -d type=individual \ -d status=active \ -d "individual[first_name]"="Test" \ -d "individual[last_name]"="User" \ -d "individual[card_issuing][user_terms_acceptance][date]"="$(date +%s)" \ -d "individual[card_issuing][user_terms_acceptance][ip]"="127.0.0.1" # Create a test transaction (force capture) curl https://api.stripe.com/v1/test_helpers/issuing/transactions/create_force_capture \ -u "$STRIPE_SECRET_KEY:" \ -d amount=8500 \ -d currency=usd \ -d card="$TEST_CARD_ID" \ -d "merchant_data[category]"=eating_places_restaurants \ -d "merchant_data[name]"="Corner Bistro" \ -d "merchant_data[city]"="San Francisco" \ -d "merchant_data[country]"="US" ``` This creates a real `issuing_transaction.created` webhook event in test mode, which flows through your middleware to Scrip. You can also test rules independently of Stripe by sending events directly to Scrip: ```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_ID}}", "external_id": "ich_test_cardholder", "idempotency_key": "test_txn_001", "event_timestamp": "2026-03-01T10:30:00Z", "event_data": { "type": "purchase", "amount": 85.00, "currency": "USD", "mcc": "5812", "merchant_name": "Corner Bistro", "authorization_id": "iauth_test_001", "stripe_transaction_id": "ipi_test_001" } }' ``` Look up the participant by external ID, then check their balance: ```bash theme={null} # Find the participant by external ID curl "https://api.scrip.dev/v1/participants?external_id=ich_test_cardholder" \ -H "Authorization: Bearer $SCRIP_API_KEY" # Use the returned id to check balances curl https://api.scrip.dev/v1/participants/{id}/balances \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` See [Testing](/guides/testing) for more on Scrip's testing tools. ## Example A cardholder buys an \$85 lunch at a restaurant (MCC 5812). The card is charged \$85. Stripe creates an authorization and holds the funds on the issuing balance. No event is sent to Scrip yet. The next day, the merchant captures the authorization. Stripe creates a Transaction object and fires `issuing_transaction.created`: ```json theme={null} { "id": "evt_1QH7...", "type": "issuing_transaction.created", "data": { "object": { "id": "ipi_1QH7...", "type": "capture", "amount": -8500, "currency": "usd", "cardholder": "ich_1MzF...", "authorization": "iauth_1MzF...", "created": 1709312400, "merchant_data": { "category_code": "5812", "name": "Corner Bistro", "city": "San Francisco", "country": "US" }, "wallet": null } } } ``` Your server receives the webhook, verifies the Stripe signature, and maps the payload to a Scrip event. The amount converts from cents to dollars (`-8500` → `85.00`), the type maps from `"capture"` to `"purchase"`, and the cardholder ID becomes the `external_id`: ```json theme={null} { "program_id": "prg_a1b2c3...", "external_id": "ich_1MzF...", "idempotency_key": "ipi_1QH7...", "event_timestamp": "2026-03-01T15:00:00Z", "event_data": { "type": "purchase", "amount": 85.00, "currency": "USD", "mcc": "5812", "merchant_name": "Corner Bistro", "merchant_city": "San Francisco", "merchant_country": "US", "authorization_id": "iauth_1MzF...", "stripe_transaction_id": "ipi_1QH7...", "wallet": null } } ``` `dining_5pct` rule matches (`event.type == "purchase"` and MCC 5812). Credits \$4.25 (5% of \$85). `stop_after_match` prevents the base rule from also firing. Event status transitions to `COMPLETED`. \$40 refunded. Stripe fires another `issuing_transaction.created` with `type: "refund"`. Middleware forwards it. `refund_dining` rule matches and debits \$2.00 (5% of \$40). A cardholder buys an \$85 lunch at a restaurant (MCC 5812). The bill includes a \$15 tip added after authorization. The card is authorized for \$85. Stripe fires `issuing_authorization.created`. Your middleware transforms and forwards it as an auth event: ```json theme={null} { "program_id": "prg_a1b2c3...", "external_id": "ich_1MzF...", "idempotency_key": "auth_iauth_1MzF...", "event_timestamp": "2026-03-01T14:00:00Z", "event_data": { "type": "auth", "amount": 85.00, "currency": "USD", "mcc": "5812", "merchant_name": "Corner Bistro", "authorization_id": "iauth_1MzF..." } } ``` `auth_dining_5pct` matches. Credits \$4.25 (5% of \$85) to `HELD` with `reference_id: "iauth_1MzF..."`. The cardholder sees \$4.25 as pending rewards. The next day, the merchant captures \$100 (\$85 + \$15 tip). Stripe fires `issuing_transaction.created`. Your middleware forwards it as a settlement event: ```json theme={null} { "program_id": "prg_a1b2c3...", "external_id": "ich_1MzF...", "idempotency_key": "ipi_1QH7...", "event_timestamp": "2026-03-01T15:00:00Z", "event_data": { "type": "settlement", "amount": 100.00, "currency": "USD", "mcc": "5812", "merchant_name": "Corner Bistro", "authorization_id": "iauth_1MzF...", "stripe_transaction_id": "ipi_1QH7..." } } ``` `settle_dining_5pct` matches. Credits \$5.00 (5% of \$100) to `AVAILABLE` with `reference_id: "iauth_1MzF..."`. The system auto-reconciles: the \$4.25 in held lots is consumed, and \$5.00 is credited to available. The \$0.75 delta is sourced from the program wallet (or system issuance for unlimited assets). ```json theme={null} {"available": "5.00", "held": "0.00", "deferred": "0.00"} ``` The pending rewards are gone, replaced by the final settled amount. \$40 refunded (the meal, not the tip). Stripe fires `issuing_transaction.created` with `type: "refund"`. Middleware forwards it. `refund_dining` matches and debits \$2.00 (5% of \$40). ## Next steps This guide covers the core integration. To build on it: * Add spend thresholds, retroactive bonuses, and monthly resets with the [Cashback Card](/examples/cashback-card) example * Browse more rule recipes like sign-up bonuses, streaks, and tiered earn rates in [Common Patterns](/examples/common-patterns) * Learn how events flow through the processing pipeline in [Event Processing](/guides/event-processing) * Read about the [auth / settlement pattern](/guides/balance-operations#auth--settlement-pattern) for a deeper look at how auto-reconciliation works under the hood For Stripe-specific reference, see the [Issuing Transactions API](https://docs.stripe.com/api/issuing/transactions) and [transaction lifecycle docs](https://docs.stripe.com/issuing/purchases/transactions). # Asset Configuration Source: https://docs.scrip.dev/guides/asset-configuration Inventory modes, issuance policies, and precision settings An asset defines the unit of value participants earn and spend. Points, cashback dollars, hotel nights, referral credits, promotional tokens. Each asset has its own ledger, and every credit has a corresponding debit somewhere in the system. Three settings define how an asset behaves: **inventory mode**, **issuance policy**, and **scale**. All three are set at creation and cannot be changed afterward. ## Creating an Asset ```bash theme={null} curl -X POST https://api.scrip.dev/v1/assets \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "program_id": "{program_id}", "name": "Loyalty Points", "symbol": "PTS", "inventory_mode": "SIMPLE", "issuance_policy": "UNLIMITED", "scale": 0 }' ``` | Field | Required | Description | | ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `program_id` | Yes | Links the asset to a program on creation | | `name` | Yes | Display name (1-255 characters) | | `symbol` | Yes | Short code like `PTS`, `NIGHTS`, `USD` (1-16 alphanumeric characters, unique per org) | | `inventory_mode` | Yes | `SIMPLE` or `LOT` | | `issuance_policy` | Yes | `UNLIMITED` or `PREFUNDED` | | `scale` | Yes | Decimal precision, `0` to `18` | | `max_transaction_amount` | No | Optional per-transaction ceiling. Any single credit or debit exceeding this value is rejected. Positive decimal string. | Assets are created at the organization level and automatically linked to the specified program. They can be [shared across programs](#asset-sharing). ## Inventory Mode | Mode | Behavior | Use when | | -------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `SIMPLE` | Tracks a single balance per account. Credits and debits adjust the total. | Basic points or credits without expiration | | `LOT` | Each credit creates a separate lot with optional expiration and vesting dates. Debits consume lots in FIFO order. | You need point expiration, vesting periods, or vintage tracking | Use `LOT` when you need per-credit lifecycle tracking. For example, an airline program where points expire 18 months after they're earned. Each credit creates a lot with its own `expires_at`, and expired lots are automatically excluded when the participant spends. If you just need a running balance, `SIMPLE` is simpler and faster. See [Lots & Expiration](/guides/lots-and-expiration) for more on how lots work. ## Issuance Policy | Policy | Behavior | Use when | | ----------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | | `UNLIMITED` | Credits mint new value on demand. No program wallet funding needed. | Open-ended programs where supply doesn't need to be capped | | `PREFUNDED` | Credits draw from the program wallet, which must be funded first. Credits fail if the wallet is empty. | Fixed budgets or strict supply control | Most programs start with `UNLIMITED`. Use `PREFUNDED` when you have a hard cap, like a "\$50,000 Summer Promo" where the program wallet is funded once and credits stop when it's empty. See [Programs](/guides/programs#the-program-wallet) for how to fund and burn. ## Scale The number of decimal places for all amounts on this asset. Any value from `0` to `18`. | Scale | Example | Common use | | ----- | ------------ | ----------------------------------------------------- | | `0` | `100` | Whole units: loyalty points, nights, referral credits | | `2` | `49.99` | Currency: dollars, euros, cashback | | `8` | `0.00000001` | High-precision tokens | Amounts with more decimal places than the configured `scale` are rounded (half-up) to fit. For example, crediting `"1.009"` on an asset with `scale: 2` records `"1.01"`. No error is returned. Use the `round()` function in CEL expressions when you need explicit control over precision before the amount reaches the ledger. **Working in cents:** If your issuer-processor sends amounts in minor units (smallest currency denomination, e.g., 200 = \$2.00), use `scale: 0` and treat the asset as cents. Your application handles the display conversion. Alternatively, use `scale: 2` and pass dollar amounts directly. ## Examples The table below shows common asset configurations. These are just examples to illustrate how the settings combine. | Asset | Symbol | Scale | Mode | Policy | Description | | ------------------- | -------- | ----- | -------- | ----------- | ------------------------------------------ | | Loyalty points | `PTS` | `0` | `SIMPLE` | `UNLIMITED` | Earn and redeem whole points | | Cashback | `USD` | `2` | `SIMPLE` | `UNLIMITED` | Dollar-denominated rewards | | Expiring points | `PTS` | `0` | `LOT` | `UNLIMITED` | Points that expire after 12 months | | Vesting rewards | `TOKENS` | `2` | `LOT` | `UNLIMITED` | Credits that unlock after a waiting period | | Promotional credits | `PROMO` | `2` | `SIMPLE` | `PREFUNDED` | Fixed-budget campaign with a capped pool | ## Asset Sharing Assets can be linked to multiple programs. This lets participants earn the same asset across different campaigns. ```bash theme={null} POST /v1/programs/{programId}/assets {"asset_id": "{asset_id}"} ``` When using `PREFUNDED` assets, each program maintains its own wallet balance independently. Funding one program doesn't affect another. ## Updating Assets You can update an asset's `name`, `symbol`, `status`, and `max_transaction_amount` after creation. `inventory_mode`, `issuance_policy`, and `scale` are locked because they affect ledger behavior. ```bash theme={null} PATCH /v1/assets/{id} {"name": "Premium Points", "max_transaction_amount": "5000.00"} ``` Set `max_transaction_amount` to `null` to remove the ceiling. # Automations Source: https://docs.scrip.dev/guides/automations Scheduled and state-driven event generation Automations generate events automatically. Each automation combines a trigger type (when it fires) with a scope (who receives the event). You can use them for monthly bonuses, one-time promotions, birthday rewards, or any event that should happen without an API call from your application. ## Trigger Types and Scopes | Trigger Type | Description | | ------------------- | ---------------------------------------------------------- | | `cron` | Fires on a recurring schedule (e.g., first of every month) | | `one_time` | Fires once at a specific timestamp | | `immediate` | Fires as soon as the automation is created | | `participant_state` | Fires when a participant's state matches a condition | | Scope | Description | | -------------- | ------------------------------------------------------------------------------------------------------------- | | `program` | Sends a single event to the program. Rules evaluate once. | | `participants` | Fans out an event to every matching participant. Each event evaluates rules against that participant's state. | Not all combinations are valid: | | `program` | `participants` | | ------------------- | --------- | -------------- | | `cron` | Yes | Yes | | `one_time` | Yes | Yes | | `immediate` | No | Yes | | `participant_state` | No | Yes | When the automation fires, it creates an event with `event_data.type` set to `event_name` and any `payload` fields merged in. That event enters the rules engine like any other event. ## Cron Automations Cron automations fire on a recurring schedule defined by a standard cron expression. ```bash theme={null} POST /v1/programs/{programId}/automations { "name": "Weekly Digest", "trigger": { "type": "cron", "cron_expression": "0 9 * * 1", "timezone": "America/Chicago" }, "scope": "participants", "event_name": "weekly_digest" } ``` | Field | Required | Description | | ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `name` | Yes | Display name (unique per program) | | `trigger.type` | Yes | `cron` | | `trigger.cron_expression` | Yes | Standard cron expression. Inline timezone prefixes (`CRON_TZ=`, `TZ=`) are not allowed; use `timezone` instead. | | `trigger.timezone` | No | [tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) timezone (e.g., `America/New_York`). Defaults to `UTC`. | | `scope` | Yes | `program` or `participants` | | `event_name` | Yes | The event type sent when the automation fires | | `payload` | No | JSON merged into the event's `event_data` | The automation tracks `last_run_at` and `last_error` for observability. After repeated consecutive failures, the automation is disabled with status `failed`. ## One-Time Automations One-time automations fire a single event at a specific timestamp, then transition to `completed`. ```bash theme={null} POST /v1/programs/{programId}/automations { "name": "New Year Kickoff", "trigger": { "type": "one_time", "trigger_at": "2026-01-01T00:00:00Z" }, "scope": "participants", "event_name": "new_year_bonus", "payload": {"bonus": 100} } ``` | Field | Required | Description | | -------------------- | -------- | ----------------------------------------------------------- | | `trigger.trigger_at` | Yes | RFC 3339 timestamp. Must be in the future at creation time. | ## Immediate Automations Immediate automations begin fan-out as soon as they are created. They are always `participants` scope. This is what the `BROADCAST` rule action creates under the hood. ```bash theme={null} POST /v1/programs/{programId}/automations { "name": "Flash Sale Announcement", "trigger": { "type": "immediate" }, "scope": "participants", "event_name": "flash_sale", "payload": {"discount_pct": 20} } ``` ## Participant State Automations Participant state automations fire events when individual participants meet a condition. Each qualifying participant gets its own subscription with an independent trigger schedule. They are always `participants` scope. ```bash theme={null} POST /v1/programs/{programId}/automations { "name": "Birthday Reward", "trigger": { "type": "participant_state", "schedule_type": "ATTRIBUTE_DATE", "schedule_config": { "attribute_key": "birthday", "date_format": "MM-DD" } }, "scope": "participants", "event_name": "birthday", "participant_filter": "'birthday' in participant.attributes" } ``` ### Schedule Types The `schedule_type` determines how each participant's trigger time is computed. Trigger types use lowercase (`cron`, `one_time`, `immediate`, `participant_state`), while schedule types use uppercase (`ATTRIBUTE_DATE`, `INTERVAL`, `CRON`, `THRESHOLD`). **ATTRIBUTE\_DATE** reads a date from a participant attribute and triggers on that date. `MM-DD` format recurs yearly (birthdays, anniversaries). `YYYY-MM-DD` fires once on the exact date (trial expirations, contract renewals). If the attribute is missing or unparseable, that participant is skipped. ```json theme={null} { "schedule_type": "ATTRIBUTE_DATE", "schedule_config": { "attribute_key": "birthday", "date_format": "MM-DD", "offset": "-168h" } } ``` This triggers 7 days *before* each participant's birthday (negative offset). A participant with `birthday: "03-15"` would trigger on March 8th each year. | Field | Description | | --------------- | ------------------------------------------------------------------------------------------ | | `attribute_key` | Participant attribute containing the date value | | `date_format` | `MM-DD` (recurring annual) or `YYYY-MM-DD` (one-time) | | `offset` | Optional duration offset from the date. Positive triggers after, negative triggers before. | **INTERVAL** triggers at a fixed duration relative to each participant's subscription. The timer is independent per participant: if participant A subscribes on January 1 and participant B subscribes on January 15, a 30-day interval fires January 31 for A and February 14 for B. ```json theme={null} { "schedule_type": "INTERVAL", "schedule_config": { "interval": "720h" } } ``` | Field | Description | | ---------- | -------------------------------------------------------- | | `interval` | Duration (e.g., `"720h"` for 30 days, `"24h"` for 1 day) | **CRON** is a calendar-aligned schedule shared across participants. Unlike INTERVAL, all subscribed participants fire at the same wall-clock times regardless of when they were subscribed. ```json theme={null} { "schedule_type": "CRON", "schedule_config": { "expression": "0 9 * * 1" } } ``` | Field | Description | | ------------ | ------------------------ | | `expression` | Standard cron expression | **THRESHOLD** triggers when a participant's counter crosses a value. The optional `delay` gives the participant time to take further action before the event fires. ```json theme={null} { "schedule_type": "THRESHOLD", "schedule_config": { "counter_key": "lifetime_spend", "operator": ">=", "threshold": 1000, "delay": "24h" } } ``` This fires 24 hours after a participant's `lifetime_spend` counter reaches 1000. If the counter drops below 1000 during the delay, a `guard_condition` can prevent the event from firing. | Field | Description | | ------------- | --------------------------------------------------------- | | `counter_key` | Counter to evaluate | | `operator` | `>=`, `>`, `==`, `<=`, `<` | | `threshold` | Numeric value to compare against | | `delay` | Duration to wait after the condition is met before firing | ### Filters and Guards Participant state automations run in two phases, each with an optional CEL expression. **`participant_filter`** runs during evaluation and controls which participants get subscriptions. A birthday automation only makes sense for participants who have a `birthday` attribute: ```json theme={null} { "participant_filter": "'birthday' in participant.attributes" } ``` Participants where the expression returns `false` are skipped. If a previously qualifying participant no longer matches on re-evaluation, their subscription is cancelled. **`guard_condition`** runs at trigger time, right before the event fires. Use it when state may have changed between evaluation and trigger: ```json theme={null} { "guard_condition": "get(participant.counters, 'lifetime_spend', 0.0) >= 1000.0" } ``` If the guard returns `false`, the event is skipped for that participant but the subscription remains active for future evaluation. Neither field can reference `event.*` since there is no event context during automation evaluation. ### Subscriptions Each qualifying participant gets a subscription: the per-participant record of when their event should fire. A birthday automation with three qualifying participants creates three subscriptions, each with a different `next_trigger_at` based on that participant's `birthday` attribute. Scrip periodically re-evaluates the automation to pick up new participants and drop those who no longer match. You can also trigger a re-evaluation manually: ```bash theme={null} POST /v1/programs/{programId}/automations/{automationId}/refresh-subscriptions ``` To inspect individual subscriptions: ```bash theme={null} GET /v1/programs/{programId}/automations/{automationId}/subscriptions ``` ## Lifecycle Every automation has a `status` that controls whether it can fire: | Status | Meaning | | ----------- | ------------------------------------------------------------------------------------------ | | `active` | Ready to fire on schedule. Can be triggered, paused, or archived. | | `paused` | Will not fire until reactivated via `PATCH` with `status: active`. | | `completed` | One-time and immediate automations move here after firing. Terminal. | | `failed` | Disabled after repeated consecutive failures. Terminal. | | `archived` | Soft-deleted via the delete endpoint, or by canceling a program-scoped one-time. Terminal. | ### Status transitions ```mermaid theme={null} stateDiagram-v2 [*] --> active active --> paused : pause paused --> active : reactivate active --> completed : fires (one-time / immediate) active --> failed : repeated failures active --> archived : delete or cancel paused --> archived : delete ``` Only `active` and `paused` are reversible. The other three statuses are terminal. To re-run a completed one-time automation, create a new one. ### Execution status (participant-scoped only) Participant-scoped automations track fan-out progress with a separate `execution_status`. This is distinct from `status` because a recurring automation stays `active` across multiple fan-out cycles, while `execution_status` tracks the current cycle: | Execution Status | Meaning | | ---------------- | --------------------------------------------------------------------------- | | `idle` | Not running. Ready to be triggered or picked up by the scheduler. | | `pending` | Queued for fan-out. | | `executing` | Fan-out in progress. | | `completed` | Fan-out finished. Resets to `idle` for recurring automations. | | `failed` | Fan-out failed or was canceled. Resets to `idle` for recurring automations. | Manual trigger requires `execution_status` to be `idle`. If a fan-out is already running, wait for it to complete or cancel it first. ```json theme={null} { "status": "active", "execution_status": "executing", "participants_total": 12500, "participants_processed": 8340 } ``` ### Cancel behavior Canceling a participant-scoped fan-out sets `execution_status` to `failed`. Participants already processed keep their events; remaining participants are skipped. The automation stays `active` and fires again on its next schedule. Canceling a program-scoped one-time archives the automation. Since `archived` is terminal, the automation cannot be triggered afterward. ## Rule-Created Automations The `SCHEDULE_EVENT` and `BROADCAST` rule actions create automations automatically. These appear with `source: rule_action` and are deduplicated across event replays. See [Rule Actions](/guides/rule-actions#scheduling-actions) for details. ## Managing Automations List, update, trigger, and delete automations through the API. The list endpoint supports filtering by `trigger_type`, `scope`, `status`, and `source` (`api` or `rule_action`). ```bash theme={null} GET /v1/programs/{programId}/automations GET /v1/programs/{programId}/automations/{automationId} PATCH /v1/programs/{programId}/automations/{automationId} DELETE /v1/programs/{programId}/automations/{automationId} POST /v1/programs/{programId}/automations/{automationId}/trigger POST /v1/programs/{programId}/automations/{automationId}/cancel POST /v1/programs/{programId}/automations/{automationId}/refresh-subscriptions ``` # Balance Operations Source: https://docs.scrip.dev/guides/balance-operations Adjust, hold, release, and forfeit balances directly While rules handle most balance changes automatically, you can also modify balances directly via the API for customer service, corrections, and manual workflows. ## Adjust Credit or debit a participant's balance: ```bash theme={null} POST /v1/participants/{id}/balances/adjust { "program_id": "program-uuid", "asset_id": "asset-uuid", "type": "CREDIT", "amount": "500", "description": "Customer service goodwill credit" } ``` | Field | Required | Description | | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to adjust | | `type` | Yes | `CREDIT` or `DEBIT` | | `amount` | Yes | Positive amount | | `allow_negative` | No | `DEBIT` only. When `true`, allows the debit to overdraw the balance below zero. Used for clawbacks. Defaults to `false`. | | `bucket` | No | `AVAILABLE` (default) or `HELD` | | `description` | Yes | Reason for the adjustment (1-500 characters) | Amounts must be positive. Use `type` to control direction. `DEBIT` fails if the available balance is insufficient, unless `allow_negative` is set to `true`. If `amount` has more decimal places than the asset's `scale`, the request is rejected with a 400 (`invalid_scale`). Round in your client before calling adjust. ### Negative Balances Set `allow_negative` to `true` on a `DEBIT` to let the balance go below zero. This is useful when you need to recover value that a participant has already spent: * **Refund clawbacks.** A cardholder earned cashback on a purchase that was later refunded. The cashback has already been redeemed, so the available balance is zero. A negative-balance debit records the debt. * **Chargeback recovery.** A payment is disputed and reversed, but the associated reward points were already used. The debit brings the balance negative until the participant earns enough to offset it. * **Corrections.** An incorrect credit was issued and the participant has already spent part of it. A negative-balance debit corrects the ledger without waiting for funds to replenish. ```bash theme={null} POST /v1/participants/{id}/balances/adjust { "program_id": "program-uuid", "asset_id": "asset-uuid", "type": "DEBIT", "amount": "25", "allow_negative": true, "description": "Clawback: refund on order #4821" } ``` For `LOT`-mode assets, the system consumes whatever lots are available first, then posts the remaining amount as an overdraft. The balance goes negative by the uncovered portion. `allow_negative` also works in [rule actions](/guides/rule-actions#debit). This lets your rules engine handle clawbacks automatically, for example debiting cashback when a refund event arrives, even if the participant's balance is zero. `allow_negative` only applies to adjustments and rule-based debits. Transfers and redemptions always require sufficient funds. ## Hold Reserve funds by moving them from `AVAILABLE` to `HELD`: ```bash theme={null} POST /v1/participants/{id}/balances/hold { "program_id": "program-uuid", "asset_id": "asset-uuid", "amount": "500", "reference_id": "auth_78945", "description": "Auth hold - txn 78945" } ``` | Field | Required | Description | | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to hold | | `amount` | Yes | Positive amount to reserve | | `reference_id` | No | Correlation ID for this hold (`LOT` mode only). Stamps held lots so a future release can target them. 1-255 characters, alphanumeric plus `._:@-`. | | `description` | Yes | Reason for the hold (1-500 characters) | Held funds are not spendable. Use holds for: * Authorization holds (reserve rewards until the transaction settles) * Fraud review (freeze funds pending investigation) * Pending approvals (hold until manual review completes) ## Release Move held funds back to `AVAILABLE`: ```bash theme={null} POST /v1/participants/{id}/balances/release { "program_id": "program-uuid", "asset_id": "asset-uuid", "reference_id": "auth_78945", "description": "Settlement confirmed" } ``` | Field | Required | Description | | -------------- | -------- | -------------------------------------------------------------------------------------- | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to release | | `amount` | No | Amount to release. If omitted, all matching held lots are released. | | `reference_id` | No | Release only lots stamped with this reference during a previous hold (`LOT` mode only) | | `description` | Yes | Reason for the release (1-500 characters) | When `reference_id` is provided, only lots stamped with that reference during a previous hold are targeted. You can also filter by lot age using `earned_from` and `earned_to` (RFC 3339 timestamps) for batch-releasing held balances. `LOT` mode only. ## Forfeit Remove funds permanently from a participant's balance: ```bash theme={null} POST /v1/participants/{id}/balances/forfeit { "program_id": "program-uuid", "asset_id": "asset-uuid", "amount": "100", "bucket": "AVAILABLE", "description": "Expired points" } ``` | Field | Required | Description | | ------------- | -------- | ---------------------------------------------------------------------- | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to forfeit | | `amount` | Yes | Positive amount | | `bucket` | Yes | `AVAILABLE` or `HELD`. Specifies which balance bucket to forfeit from. | | `description` | Yes | Reason for the forfeit (1-500 characters) | Forfeited funds move to the `SYSTEM_BREAKAGE` account. ## Void Hold Cancel HELD lots that were credited directly into HELD (e.g., pending authorization rewards) by `reference_id`, returning value to the original source account: ```bash theme={null} POST /v1/participants/{id}/balances/void-hold { "program_id": "program-uuid", "asset_id": "asset-uuid", "reference_id": "auth_12345", "description": "Auth reversal" } ``` | Field | Required | Description | | -------------- | -------- | --------------------------------------------------- | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to void (`LOT` mode only) | | `reference_id` | Yes | Correlation ID matching the original CREDIT to HELD | | `description` | Yes | Reason for the void (1-500 characters) | Void hold is the correct operation for auth reversals. It cancels lots that were credited directly into the HELD bucket and returns value to the source (program wallet for `PREFUNDED` assets, `SYSTEM_ISSUANCE` for `UNLIMITED`). Void hold only processes lots that were created directly in HELD via CREDIT. Lots moved to HELD via a HOLD operation (participant funds) are excluded for safety — returning those to the source would effectively confiscate participant value. Use [release](#release) to return participant-held funds to AVAILABLE. `amount`, `lot_ids`, `earned_from`, and `earned_to` are not supported — the entire provisional accrual matching the `reference_id` is voided. ## Auth / Settlement Pattern Card transactions follow a two-step lifecycle: the issuer-processor authorizes the transaction first, and the merchant settles (captures) it later — sometimes hours or days after. The settlement amount can differ from the authorization due to tips, partial captures, or currency conversion. Most programs can skip authorizations and credit rewards on settlement only. This is the simplest approach, and the [Stripe Issuing](/examples/stripe-issuing) example uses it. But if your program needs to show **pending rewards** to cardholders in real time — before the transaction settles — you can use the auth/settle pattern to hold provisional rewards at authorization and reconcile them when the merchant captures. This is the dual-message pattern common in card processing: both the authorization and settlement events flow into Scrip, and the system handles the difference between them automatically. This pattern requires a `LOT`-mode asset. `reference_id` stamps individual lots at authorization so they can be matched at settlement. For `SIMPLE`-mode assets, use amount-based holds and releases without `reference_id`. When a cardholder taps their card, your issuer-processor fires an authorization webhook. Your backend forwards it as an event with `event.type == "auth"`. A rule credits points into the `HELD` bucket, stamping the lots with the authorization ID: ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "event.amount", "bucket": "HELD", "reference_id": "event.authorization_id"} ``` The cardholder's balance now shows pending rewards in the `held` field. These are not spendable. Alternatively, if the participant already has an `AVAILABLE` balance and you want to reserve existing points rather than mint new ones: ```json theme={null} {"type": "HOLD", "asset_id": "...", "amount": "event.amount", "reference_id": "event.authorization_id"} ``` When the merchant captures the transaction, your backend sends a second event with `event.type == "settlement"`. Two approaches: **Auto-reconcile with CREDIT.** Credit to `AVAILABLE` with the same `reference_id`. The system finds the held lots, consumes them, and creates new available lots for the settlement amount. If the amounts differ, the delta is handled automatically — extra funds are sourced from the program wallet (or system issuance for unlimited assets), and any excess is returned. ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "event.settlement_amount", "reference_id": "event.authorization_id"} ``` This is the recommended approach when the settlement amount may differ from the authorization. If no held lots exist for the `reference_id` (e.g., the auth event was missed or not sent), the system falls back to a standard credit. **Simple release.** If the settlement amount always matches the authorization, a release is sufficient. This moves the held lots back to `AVAILABLE` without reconciliation: ```json theme={null} {"type": "RELEASE", "asset_id": "...", "reference_id": "event.authorization_id"} ``` When `amount` is omitted with a `reference_id`, all lots held under that reference are released. If the authorization is reversed before settling, use `VOID_HOLD` to cancel the provisional rewards. This returns value to the source account (program wallet or system issuance): ```json theme={null} {"type": "VOID_HOLD", "asset_id": "...", "reference_id": "event.authorization_id"} ``` For authorizations that simply expire without settling, set `expires_at` on the original CREDIT (e.g., `"720h"`), which automatically forfeits uncaptured holds after the expiration window. See the [Stripe Issuing](/examples/stripe-issuing#uncaptured-authorizations) example for details on handling uncaptured authorizations. ### How auto-reconciliation works When a `CREDIT` to `AVAILABLE` includes a `reference_id` that matches existing `HELD` lots, the system reconciles automatically: | Settlement vs. auth | What happens | | --------------------------------------------------------- | -------------------------------------------------------------- | | Equal | Held lots consumed, same amount credited to `AVAILABLE` | | Settlement > auth (over-capture, e.g., tip added) | Extra amount sourced from program wallet or system issuance | | Settlement \< auth (under-capture, e.g., partial capture) | Excess returned to program wallet or system issuance | | No held lots found for the `reference_id` | Standard credit applied with `reference_id` stamped on the lot | ### When to use this pattern | Scenario | Approach | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Show pending rewards at authorization, reconcile at settlement | Auth/settle pattern (this section) | | Credit rewards only after settlement | Credit on settlement directly — no holds needed. See [Stripe Issuing](/examples/stripe-issuing). | | Reserve existing points for a pending operation (e.g., redemption approval) | [Hold](#hold) and [release](#release) without `reference_id` | ## Lot Preservation For `LOT`-mode assets, hold and release operations preserve lot metadata. When a lot moves from `AVAILABLE` to `HELD` (or back), its `expires_at`, `matures_at`, `created_at`, and lot ID all remain unchanged. The lot keeps its identity across bucket transitions. ## Inactive Participants Most balance operations (adjust, hold, release) are blocked for `SUSPENDED` and `CLOSED` participants. The API returns a 409 error with code `participant_inactive`. Forfeit and void hold are the exceptions: they are allowed on `CLOSED` participants so you can clean up remaining balances after account closure. Both are still blocked for `SUSPENDED` participants. # CEL Expressions Source: https://docs.scrip.dev/guides/cel-expressions Write rule conditions and dynamic amounts with CEL Scrip uses [CEL (Common Expression Language)](https://github.com/google/cel-spec) for rule conditions and dynamic amount calculations. You write conditions to control when a rule fires and expressions to compute how much to credit, debit, or hold. Conditions return a boolean. Amount expressions return a number. ```javascript theme={null} // Condition (returns boolean) event.type == "purchase" && event.amount > 50.0 // Amount (returns number) event.amount * 0.10 ``` ## Operators | Operator | Example | | --------------- | ------------------------------------------------------------ | | Equality | `event.type == "purchase"` | | Inequality | `event.type != "refund"` | | Comparison | `event.amount > 100.0`, `event.amount >= 50.0` | | Logical AND | `event.type == "purchase" && event.amount > 0` | | Logical OR | `event.category == "dining" \|\| event.category == "travel"` | | Negation | `!("vip" in participant.tags)` | | Containment | `"vip" in participant.tags` | | List membership | `event.category in ["dining", "travel"]` | | Ternary | `event.tier == "gold" ? 100.0 : 50.0` | Use `==` for equality, not `=`. Using `=` is a syntax error. ## Available Variables When a rule evaluates, these variables are available in your conditions and amount expressions. ### `event` The `event_data` payload from your API request. Fields depend on what you send, so every expression you write is specific to your event schema. ```javascript theme={null} event.type == "purchase" event.amount > 50.0 event.category in ["dining", "travel", "entertainment"] event.items.size() > 0 event.items[0].sku == "ABC123" ``` Numeric values in your payload work directly in arithmetic: ```javascript theme={null} event.amount * 0.10 // works, amount is already a number event.quantity * 5.0 // works ``` If a numeric value is sent as a string in your payload, cast it with `double()`: ```javascript theme={null} double(event.amount) * 0.10 // only needed if amount is "49.99" not 49.99 ``` #### Optional fields Not every event has the same shape. If a condition references a field that isn't in the payload, the entire condition evaluates to `false` and the rule is skipped. Use `has()` to check for a field before accessing it: ```javascript theme={null} has(event.referrer_id) && event.referrer_id != "" has(event.coupon_code) && event.coupon_code == "SUMMER25" ``` ### `participant` The participant's current state at the time of evaluation. Most rules use this to check tags, counters, or attributes before granting rewards. ```javascript theme={null} participant.tags // list of strings participant.counters // map of string to number participant.attributes // map of string to string participant.tiers // map of string to tier state ``` #### Tags ```javascript theme={null} "vip" in participant.tags !("welcome_bonus" in participant.tags) ``` #### Counters Use `get()` to read counter values with a default. If the counter hasn't been set, `get()` returns the default instead of failing. ```javascript theme={null} get(participant.counters, "purchase_count", 0.0) >= 10.0 get(participant.counters, "lifetime_spend", 0.0) > 1000.0 ``` #### Attributes ```javascript theme={null} get(participant.attributes, "region", "") == "US" get(participant.attributes, "plan", "free") in ["pro", "enterprise"] ``` ### `program` The program's current state. Supports the same `tags`, `counters`, and `attributes` as participants. Use this for global logic that isn't tied to any one participant, like a program-wide redemption cap. ```javascript theme={null} program.id // UUID string "milestone_reached" in program.tags get(program.counters, "total_issued", 0.0) < 100000.0 get(program.attributes, "region", "") == "US" ``` ### `groups` List of groups the participant belongs to. Each entry has `id`, `name`, `tags`, `counters`, `attributes`, and `tiers`. ```javascript theme={null} groups.size() > 0 groups.size() > 0 && groups[0].name == "VIP Customers" groups.size() > 0 && get(groups[0].counters, "team_purchases", 0.0) < 100.0 ``` A participant can belong to multiple groups, so `groups` is a list. If your program uses one group per participant, `groups[0]` works as a shorthand. ### `now` The event's `event_timestamp` as a CEL timestamp. Scrip uses the event timestamp rather than wall-clock time so that evaluation stays deterministic across retries and reprocessing. ```javascript theme={null} now > timestamp("2025-01-01T00:00:00Z") now < timestamp("2025-12-31T23:59:59Z") ``` ## Quick Reference | Data | Pattern | | -------------------- | --------------------------------------------------- | | Event field (number) | `event.amount > 50.0` | | Event field (string) | `event.type == "purchase"` | | Optional event field | `has(event.referrer_id) && event.referrer_id != ""` | | Tag check | `"vip" in participant.tags` | | Counter | `get(participant.counters, "key", 0.0) >= 10.0` | | Attribute | `get(participant.attributes, "key", "") == "US"` | | Timestamp comparison | `now > timestamp("2025-01-01T00:00:00Z")` | | Program counter | `get(program.counters, "key", 0.0) < 1000.0` | ## Helper Functions Scrip adds a few helpers on top of standard CEL for safe map access, rounding, and time calculations. ### `get(map, key, default)` Reads a value from a map, returning `default` if the key doesn't exist. This is the most common helper in practice. Always use `get()` for counters and attributes, since accessing a missing key directly causes the entire condition to evaluate to `false`, which can prevent the other side of an `||` expression from evaluating. ```javascript theme={null} get(participant.counters, "spend", 0.0) > 100.0 get(participant.attributes, "plan", "") in ["pro", "enterprise"] ``` ### `round(value, scale)` Rounds a number to the specified decimal places. ```javascript theme={null} round(event.amount * 0.03, 2) // 49.99 * 0.03 = 1.4997 → 1.50 round(event.amount, 0) // 49.99 → 50.0 ``` ### `duration_hours(duration)` Converts a CEL duration to hours. You get a duration by subtracting two timestamps. Divide by 24 for days, multiply by 60 for minutes. ```javascript theme={null} // More than 30 days since activation duration_hours(now - timestamp(event.activation_date)) / 24.0 > 30.0 // Less than 1 hour old duration_hours(now - timestamp(event.created_at)) < 1.0 ``` ### `has(field)` Returns `true` if a field exists on an object. Use this for event fields that only appear on some event types. ```javascript theme={null} // Only match if the event includes a coupon code has(event.coupon_code) && event.coupon_code == "SUMMER25" ``` ### `timestamp(string)` Parses an RFC 3339 string (e.g., `"2025-06-01T00:00:00Z"`) into a CEL timestamp for comparison and arithmetic. ```javascript theme={null} timestamp(event.purchase_date) >= timestamp("2025-06-01T00:00:00Z") now - timestamp(event.activation_date) // produces a duration ``` ## Extensions Scrip enables the CEL math and sets extension libraries. ### Math Min/max capping, absolute value, and rounding. Useful for bounding dynamic amounts to a floor or ceiling. ```javascript theme={null} math.least(event.amount * 0.1, 50.0) // cap: 10% of amount, max 50 math.greatest(event.amount * 0.05, 10.0) // floor: 5% of amount, min 10 math.abs(-42.0) // 42.0 math.ceil(3.2) // 4.0 math.floor(3.8) // 3.0 ``` ### Sets Membership checks across lists for tag-based conditions. ```javascript theme={null} sets.contains(participant.tags, ["vip", "gold"]) // participant has ALL of these tags sets.intersects(participant.tags, ["vip", "silver"]) // participant has ANY of these tags ``` ## Dynamic Amounts Rule actions that move assets (`CREDIT`, `DEBIT`, `HOLD`, etc.) have an `amount` field. You can set it to a static number or a CEL expression. If the value parses as a number, it's used directly. Otherwise, Scrip evaluates it as a CEL expression against the event data. ```json theme={null} { "name": "Cashback Reward", "condition": "event.type == 'purchase'", "actions": [ { "type": "CREDIT", "asset_id": "...", "amount": "round(event.amount * 0.03, 2)" } ] } ``` In this example, a \$105 purchase evaluates `round(105.0 * 0.03, 2)` and credits 3.15 points. | Amount value | Behavior | | -------------------------------------------- | --------------------------------------------------- | | `"100"` | Static. Credits exactly 100. | | `"event.amount * 0.10"` | 10% of the event amount | | `"round(event.amount * 0.03, 2)"` | 3% of the event amount, rounded to 2 decimal places | | `"math.least(event.amount * 0.1, 50.0)"` | 10% of the event amount, capped at 50 | | `"math.greatest(event.amount * 0.05, 10.0)"` | 5% of the event amount, minimum 10 | | `"event.tier == 'gold' ? 100.0 : 50.0"` | 100 for gold tier, 50 for everyone else | The result is rounded to the asset's configured `scale` before being applied. ## Common Patterns These patterns come up in most programs. Each one is a complete condition you can use directly or adapt. ### One-time gate Grant a reward once, then tag the participant to prevent re-triggering. ```javascript theme={null} event.type == "signup" && !("welcome_bonus" in participant.tags) ``` ### Threshold crossing Detect the event that pushes a counter past a target. Counters reflect pre-event state, so check the current value plus the incoming amount. ```javascript theme={null} get(participant.counters, "spend", 0.0) < 1000.0 && (get(participant.counters, "spend", 0.0) + event.amount) >= 1000.0 ``` ### Milestone (Nth occurrence) Fire on exactly the Nth event of a type. The counter hasn't incremented yet, so add 1 to get the count including this event. ```javascript theme={null} event.type == "ride_complete" && (get(participant.counters, "ride_count", 0.0) + 1.0) == 10.0 ``` ### Date range Restrict a rule to events within a specific window. ```javascript theme={null} timestamp(event.purchase_date) >= timestamp("2025-06-01T00:00:00Z") && timestamp(event.purchase_date) < timestamp("2025-07-01T00:00:00Z") ``` ### Days since a date Check elapsed time between two dates. `duration_hours` returns hours, so divide by 24 for days. ```javascript theme={null} duration_hours(now - timestamp(event.activation_date)) / 24.0 <= 30.0 ``` ### Capped bonus Reward a percentage of the event amount but cap the maximum payout. ```javascript theme={null} math.least(event.amount * 0.1, 50.0) ``` ### Category-specific Only match events in certain categories. ```javascript theme={null} event.type == "purchase" && event.category in ["dining", "travel"] ``` ### Segment-specific Use tags to restrict rules to a participant segment. ```javascript theme={null} event.type == "purchase" && "vip" in participant.tags ``` ### Exclude already-rewarded Prevent a participant from claiming a promotion more than once. ```javascript theme={null} event.type == "purchase" && !("promo_claimed" in participant.tags) ``` ## Common Gotchas | Mistake | Fix | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `event.type = "purchase"` | Use `==` not `=` | | `event.amount` when amount is a string | Use `double(event.amount)` | | Referencing `event.field` that may not exist | Condition returns `false`. Use `has(event.field)` if the rule should still match. | | Checking state updated earlier in the same event | Conditions use the event-start snapshot. For counters, use the [threshold crossing](#threshold-crossing) or [Nth occurrence](#milestone-nth-occurrence) pattern. For tags, attributes, or tiers, put the dependent action in the same rule or trigger it on a later event. See [State Snapshot Evaluation Behavior](/guides/writing-rules#state-snapshot-evaluation-behavior). | # Core Concepts Source: https://docs.scrip.dev/guides/core-concepts How programs, assets, participants, events, rules, and the double-entry ledger fit together Scrip is built around six concepts: programs, assets, participants, events, rules, and a double-entry ledger. The table below summarizes each one, and the end-to-end example at the bottom shows how they connect. | Concept | What it is | Guide | | --------------- | ------------------------------------------------------- | ------------------------------------- | | **Program** | Container for rules, assets, and participants | [Programs](/guides/programs) | | **Asset** | Unit of value: points, cashback, nights, credits | [Assets](/guides/asset-configuration) | | **Participant** | A user in your system, identified by your `external_id` | [Participants](/guides/participants) | | **Event** | A signal from your app that triggers rule evaluation | [Events](/guides/event-processing) | | **Rule** | A CEL condition and a list of actions | [Rules](/guides/writing-rules) | | **Ledger** | Double-entry record of every balance change | [Ledger](/guides/ledger) | ## Programs A program is the top-level container. It holds your rules, links to your assets, and scopes your participants. Most teams create one program per use case: "Customer Loyalty," "Referral Rewards," "Driver Incentives." Events are always sent to a specific program, and rules belong to exactly one program. ## Assets An asset is the unit of value you're tracking. Points, cashback dollars, nights stayed, referral credits. You configure how each asset behaves: * **Inventory mode:** `SIMPLE` tracks a single aggregate balance. `LOT` tracks each credit individually with its own expiration and vesting dates, so points can expire in the order they were earned. * **Issuance policy:** `UNLIMITED` mints new value on every credit (no cap). `PREFUNDED` draws from a fixed program wallet, so credits fail when the wallet is empty. Use `PREFUNDED` when you need to enforce a budget. ## Participants Participants are your users. You identify them with your own `external_id` so they stay in sync with your application. Participants can be created explicitly or automatically on first event. Each participant carries state that rules can read and write: * **Tags:** Boolean flags like `VIP` or `FIRST_PURCHASE`. * **Counters:** Numeric values like `lifetime_spend` or `purchase_count`. * **Attributes:** Key-value strings like `region: "US"`. ## Events Events are the inputs. Your application sends one whenever something happens that might affect a participant: a purchase, a signup, a referral, a cancellation. ```json theme={null} { "program_id": "...", "external_id": "user_123", "idempotency_key": "order-456-completed", "event_data": { "type": "purchase", "amount": 105.00 } } ``` Events process asynchronously. The API confirms receipt and a worker evaluates rules in the background. The `idempotency_key` ensures exactly-once processing: if you retry the same event with the same key, you get back the original response instead of processing it again. Reusing a key with a different payload returns `409 Conflict`. ## Rules A rule is a condition and a list of actions. The condition is a [CEL](/guides/cel-expressions) expression (Common Expression Language, a lightweight expression syntax) evaluated against the event and the participant's current state. When it returns true, the actions fire. ```json theme={null} { "name": "Cashback on large purchases", "condition": "event.type == 'purchase' && event.amount >= 100.0", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "10"} ] } ``` Rules evaluate in order. Multiple rules can fire on the same event. If you want only the first matching rule to fire, set `stop_after_match: true` on that rule to skip the remaining rules. ## The Ledger Every balance change is recorded as a journal entry with two sides: a debit and a credit. When a participant earns points, the credit goes to the participant and the corresponding debit comes from the program wallet. Nothing is ever mutated in place. This gives you a complete audit trail: you can trace any balance back to the event and rule that created it. ## End-to-End Example Using the "Cashback on large purchases" rule from above, here's what happens when a \$105 purchase comes in: `user_123` made a \$105 purchase. You send it to Scrip with `external_id: "user_123"` and `event_data: {type: "purchase", amount: 105.00}`. Scrip looks up the participant matching `user_123` and loads their current state: tags, counters, and attributes. The condition `event.type == 'purchase' && event.amount >= 100.0` is checked. 105 >= 100, so it matches. \$10 is credited to the participant's balance. A journal entry debits the program wallet by \$10 and credits `user_123` by \$10. `user_123` now has \$10.00 available to redeem. The [Quickstart](/quickstart) walks through this exact flow with real API calls. # Event Processing Source: https://docs.scrip.dev/guides/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 | | `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 rules, not the rules as they existed at that time. See [Time-Windowed Rules](/guides/writing-rules#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`](/guides/webhooks#event-payloads) 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 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. 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. 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 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](/guides/participants#whats-allowed-by-status). 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. Failed events retry automatically with exponential backoff (2s, 4s, 8s, 16s, 32s) up to 5 attempts. You can also 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. 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: ```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 | 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). ## 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 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`](/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. # Groups Source: https://docs.scrip.dev/guides/groups Shared wallets and state for teams, families, and organizational pools Groups let multiple participants share a wallet. A group is its own ledger entity with independent balances, separate from any individual participant. Useful for families, teams, or organizational pools. Groups exist at the organization level and are not scoped to a single program. Balance operations are program-scoped (you specify `program_id` when adjusting), but the group itself can participate across programs. ## Creating a Group ```bash theme={null} curl -X POST https://api.scrip.dev/v1/groups \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Smith Family", "members": [ {"participant_id": "{alice_id}", "role": "ADMIN"}, {"participant_id": "{bob_id}", "role": "MEMBER"} ] }' ``` | Field | Required | Description | | --------- | -------- | --------------------------------------------------------- | | `name` | Yes | Display name (1-255 characters) | | `members` | Yes | Initial members. At least one must have the `ADMIN` role. | ## Membership ### Adding members ```bash theme={null} POST /v1/groups/{id}/members { "members": [ {"participant_id": "{participant_id_1}", "role": "MEMBER"}, {"participant_id": "{participant_id_2}", "role": "MEMBER"} ] } ``` ### Removing members ```bash theme={null} DELETE /v1/groups/{id}/members/{participant_id} ``` Members are soft-deleted with a `LEFT` status. Use `include_former=true` when listing to see former members. Removing a member does not affect the group's balance. ### Roles | Role | Description | | -------- | ------------------------------------ | | `ADMIN` | Can manage group membership | | `MEMBER` | Standard member (default if omitted) | The last `ADMIN` in a group cannot be demoted or removed. ## Group Balances Adjust a group's balance directly via the API: ```bash theme={null} POST /v1/groups/{id}/balances/adjust { "program_id": "{program_id}", "asset_id": "{asset_id}", "type": "CREDIT", "amount": "500", "description": "Monthly team allocation" } ``` Group balances are returned as a flat map of `{asset_id: amount}` representing aggregate totals per asset. Unlike participant balances, which are bucketed into `available` and `held`, group balances do not distinguish between buckets. ## Crediting Groups from Rules Rules can credit a group's wallet instead of the participant's by adding a `target` to the action. This rule pools purchase points into a family group: ```json theme={null} { "name": "Family Pool Points", "condition": "event.type == 'purchase'", "actions": [ { "type": "CREDIT", "asset_id": "{asset_id}", "amount": "event.amount * 2", "target": {"type": "GROUP", "id": "{group_id}"} } ] } ``` The participant who triggers the event must be a member of the target group, or the action fails. The same targeting works for state actions. For example, tracking how many purchases the group has made: ```json theme={null} { "name": "Track Team Purchases", "condition": "event.type == 'purchase'", "actions": [ {"type": "COUNTER", "key": "team_purchases", "value": "1", "target": {"type": "GROUP", "id": "{group_id}"}}, {"type": "CREDIT", "asset_id": "{asset_id}", "amount": "10", "target": {"type": "GROUP", "id": "{group_id}"}} ] } ``` ## Groups in CEL Conditions When a rule evaluates, the `groups` variable contains a list of groups the participant belongs to. Each entry has: | Field | Type | Description | | ------------ | -------- | ---------------------- | | `id` | `string` | Group UUID | | `name` | `string` | Group display name | | `tags` | `list` | Group tags | | `counters` | `map` | Group counters | | `attributes` | `map` | Group attributes | | `tiers` | `map` | Group tier memberships | ```javascript theme={null} // Only fire if participant is in at least one group groups.size() > 0 // Check a group-level counter groups.size() > 0 && get(groups[0].counters, "team_purchases", 0.0) < 100.0 // Check a group-level tag groups.size() > 0 && "premium_team" in groups[0].tags ``` `groups` is a list because a participant can belong to multiple groups. If your program uses a single group per participant, `groups[0]` is a convenient shorthand. For programs where participants may be in multiple groups, use `groups.size()` checks and index carefully. ## Group State Groups support the same state types as participants: tags, counters, and attributes. ```bash theme={null} # Tags PUT /v1/groups/{id}/state/tags/{tag} DELETE /v1/groups/{id}/state/tags/{tag} # Attributes PUT /v1/groups/{id}/state/attributes/{key} PATCH /v1/groups/{id}/state/attributes # Counters PUT /v1/groups/{id}/state/counters/{key} DELETE /v1/groups/{id}/state/counters/{key} ``` Group state is available in CEL via the `groups` variable and can be updated from rule actions using `"target": {"type": "GROUP", "id": "{group_id}"}`. ## Archiving Groups ```bash theme={null} DELETE /v1/groups/{id} ``` Archived groups are excluded from listings unless `include_archived=true` is set. Archived groups cannot be modified. # Ledger Source: https://docs.scrip.dev/guides/ledger Double-entry journal entries, postings, and system accounts Every asset movement in Scrip is recorded as a double-entry journal entry. Credits and debits always balance. ## Double-Entry Accounting Every transaction creates a journal entry with two or more postings that sum to zero: ``` Journal Entry: "Purchase reward - 10 points" ├── Posting 1: SYSTEM_ISSUANCE -10 (debit: source) └── Posting 2: participant/alice +10 (credit: destination) ``` For `PREFUNDED` assets, the source is the program wallet instead of `SYSTEM_ISSUANCE`: ``` Journal Entry: "Purchase reward - 10 points" ├── Posting 1: program/loyalty -10 (debit: program wallet) └── Posting 2: participant/alice +10 (credit: participant) ``` ## Accounts and Buckets An account is a balance container in the ledger. Participants, groups, programs, and system entities all have accounts. The ledger tracks funds by writing postings against these accounts. Each account holds a balance for a single asset in a single bucket. A participant with one asset has three accounts: one for `AVAILABLE` funds, one for `HELD` funds, and one for `DEFERRED` funds. A participant with three assets has up to nine accounts. A bucket segments the balance into separate pools: | Bucket | Description | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AVAILABLE` | The spendable balance. Credits land here by default. Debits, holds, forfeits, and redemptions draw from this bucket. | | `HELD` | Reserved and not spendable. A `HOLD` action moves funds from `AVAILABLE` to `HELD`, locking them until the hold is resolved. A `RELEASE` moves them back to `AVAILABLE`. Forfeits can also draw from `HELD`. | | `DEFERRED` | Not yet matured. When a credit specifies a future `matures_at` timestamp, funds land here instead of `AVAILABLE`. Deferred funds cannot be spent, held, or debited. When the maturity date passes, a background job automatically moves the funds to `AVAILABLE` and records a `MATURITY` journal entry. | When you query a participant's balances, the response breaks out each bucket: ```json theme={null} { "balances": [ {"asset_id": "...", "symbol": "PTS", "available": "850", "held": "150", "deferred": "500"} ] } ``` ## Journal Entries A journal entry is a record of a single ledger operation. The system creates one every time funds move: credits, debits, holds, releases, forfeits, and redemptions. Each journal entry contains two or more postings. A posting is a single line item that records a change to one account: which account, the signed amount (positive for credits, negative for debits), and which bucket. The postings within an entry always sum to zero. You can list journal entries through the API: ```bash theme={null} GET /v1/journal-entries?program_id=...&from=2025-01-01T00:00:00Z ``` Each journal entry includes: | Field | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `description` | Human-readable summary of the operation | | `postings` | The individual debit and credit lines, each with an account, amount, and bucket | | `event_id` | The event that triggered this entry, if it came from a rule. Null for direct API operations. | | `rule_id` | The rule that triggered this entry, if it was created during rule execution. Null for direct API operations and non-rule worker activity. | | `created_by_api_key_id` | The API key that triggered this entry, if it came from a direct API call | | `entry_hash` | SHA-256 hash sealing this entry into the organization's hash chain | ### Filtering The journal entries list endpoint accepts query parameters to narrow results. | Filter | Description | | --------------------------- | ------------------------------------------------------------------------------------------ | | `program_id` | Entries for a specific program | | `participant_id` | Entries involving a participant | | `external_id` | Entries by external ID | | `group_id` | Entries involving a group | | `asset_id` | Filter by asset | | `bucket` | Filter by bucket (`AVAILABLE`, `HELD`, `DEFERRED`) | | `event_id` | Entries from a specific event | | `rule_id` | Entries created by a specific rule | | `action_type` | Filter by ledger action type (`CREDIT`, `DEBIT`, `HOLD`, `RELEASE`, `FORFEIT`, `MATURITY`) | | `reference_id` | Filter by hold, release, or settle correlation ID | | `from` / `to` | Time range | | `min_amount` / `max_amount` | Posting amount range | Amount filters apply to raw signed posting values. Credits are positive, debits are negative. Entity filters (`participant_id`, `external_id`, `group_id`) are mutually exclusive. ### Entry Detail Fetch a single journal entry to see the full record: ```bash theme={null} GET /v1/journal-entries/{id} ``` The response includes every posting in the entry, with the full account, amount, and bucket detail for each. ## System Accounts Every fund movement has an origin and a destination. For transfers between participants, both sides are participant accounts. When funds are minted, forfeited, or redeemed, the other side of the transaction is a system account. | Account | Role | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SYSTEM_ISSUANCE` | Where new points come from. When the system mints funds for an `UNLIMITED` asset, the journal entry debits `SYSTEM_ISSUANCE` and credits the participant. | | `SYSTEM_BREAKAGE` | Where lost points go. Forfeited, expired, or otherwise removed funds are credited here. | | `SYSTEM_REDEMPTION` | Where spent points go. When a participant redeems funds for a reward, the entry debits the participant and credits `SYSTEM_REDEMPTION`. | These accounts make it possible to reconcile total points issued against points in circulation, points spent, and points lost. ## Integrity The ledger is append-only. Journal entries cannot be modified or deleted after creation. Every journal entry is sealed with a SHA-256 hash that covers its metadata, all postings, and the previous entry's hash. This forms a chain within each organization: if any entry were altered, the hash would no longer match, and every subsequent entry's hash would also break. The `entry_hash` field on each journal entry is this cryptographic seal. To reverse a transaction, create a new journal entry with opposite postings. A redemption reversal, for example, credits the participant and debits the redemption account. The original entry and its hash remain intact. # Lots & Expiration Source: https://docs.scrip.dev/guides/lots-and-expiration Track individual credits with expiration and vesting dates A lot is an individual credit recorded as its own entry in the ledger, with its own balance, expiration date, and vesting period. Instead of maintaining a single running total, `LOT`-mode assets track every credit separately. This is useful when points need to expire after a fixed window (e.g., 12 months from issuance), when rewards should vest before becoming spendable (e.g., a referral bonus that unlocks after 30 days), or when you need to spend oldest points first for regulatory or accounting reasons. Assets configured with `SIMPLE` mode do not create lots. They track a single balance per bucket. See [SIMPLE vs LOT Mode](#simple-vs-lot-mode) for a comparison. ## How Lots Work When a `LOT`-mode asset is credited, a new lot is created: ```json theme={null} { "id": "lot-uuid", "amount": "100", "remaining": "100", "status": "AVAILABLE", "created_at": "2025-01-15T10:00:00Z", "expires_at": "2026-01-15T10:00:00Z", "matures_at": null } ``` When funds are debited, lots are consumed in FIFO (first in, first out) order: the oldest lot is spent down before the next one is touched. Each lot tracks its remaining balance independently. ## Lot Lifecycle A lot moves through a series of statuses from creation to consumption or expiration. ``` DEFERRED (matures_at > now) ↓ matures_at passes AVAILABLE (spendable) ↓ hold HELD (reserved) ↓ debit CONSUMED (fully spent) ↓ or expires_at < now EXPIRED (forfeited to breakage) ``` | Status | Spendable | Description | | ----------- | --------- | ------------------------------------------------------------------------------------------------------ | | `DEFERRED` | No | Lot has a future `matures_at`. Automatically transitions to `AVAILABLE` when the maturity date passes. | | `AVAILABLE` | Yes | Lot is mature and available for spending | | `HELD` | No | Lot is reserved (e.g., pending settlement) | | `CONSUMED` | No | Lot fully spent via debits | | `EXPIRED` | No | `expires_at` has passed, forfeited to breakage | ## Expiration Set `expires_at` on a `CREDIT` action to give lots a deadline. The value can be a relative duration or a fixed timestamp. A relative duration starts from the time each lot is created: ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "expires_at": "8760h"} ``` A fixed timestamp sets the same deadline for all lots created by that rule: ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "expires_at": "2025-12-31T23:59:59Z"} ``` Expired lots are automatically excluded from debit operations. Durations are specified in hours. | Duration | Value | | -------- | --------- | | 24 hours | `"24h"` | | 7 days | `"168h"` | | 30 days | `"720h"` | | 90 days | `"2160h"` | | 1 year | `"8760h"` | ## Vesting Set `matures_at` on a `CREDIT` action to create a vesting period. Lots credited with a future `matures_at` land in `DEFERRED` status and automatically transition to `AVAILABLE` when the date passes. Deferred lots are excluded from debit operations. ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "matures_at": "720h"} ``` Both fields can be combined. This lot vests after 7 days and expires after 90: ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "matures_at": "168h", "expires_at": "2160h"} ``` `DEFERRED` cannot be targeted as a bucket for writes. It is read-only and available as a filter in query endpoints (e.g., `status=DEFERRED` on the lots list, or `bucket=DEFERRED` on journal entries). ## FIFO Consumption When debiting a `LOT`-mode asset, lots are consumed oldest-first: ``` Available lots (sorted by created_at): Lot A: 50 remaining (created Jan 1) ← consumed first Lot B: 100 remaining (created Feb 1) Lot C: 75 remaining (created Mar 1) Debit 120: Lot A: 50 → 0 (consumed) Lot B: 100 → 30 (partial) Lot C: 75 (untouched) ``` Only lots that are mature and not expired are eligible for consumption. ## Viewing Lots Inspect a participant's lots for a specific asset: ```bash theme={null} GET /v1/participants/{id}/balances/lots?asset_id=asset-uuid ``` | Filter | Description | | ---------------- | ----------------------------------------------------------------------------- | | `status` | Filter by lot status (`DEFERRED`, `AVAILABLE`, `HELD`, `CONSUMED`, `EXPIRED`) | | `reference_id` | Filter by correlation ID (e.g., find lots belonging to a specific hold) | | `expires_before` | Lots expiring before a date | | `expires_after` | Lots expiring after a date | ## Lot-Aware Operations Hold, release, and forfeit operations on `LOT`-mode assets are lot-aware: | Action | Behavior | | --------- | ----------------------------------------------------------------------- | | `HOLD` | Moves lots from `AVAILABLE` to `HELD`, preserving metadata and lot UUID | | `RELEASE` | Moves lots from `HELD` back to `AVAILABLE`, preserving lot UUID | | `FORFEIT` | Moves lots to `SYSTEM_BREAKAGE` | Lot bucket transitions (`AVAILABLE` ↔ `HELD`) update the lot row in place, preserving the lot UUID across holds, releases, and settles. Partial transitions split the remainder into a new lot. These operations return a `lots_processed` array showing which lots were affected and by how much: ```json theme={null} { "lots_processed": [ {"lot_id": "lot-uuid-1", "amount": "50"}, {"lot_id": "lot-uuid-2", "amount": "70"} ] } ``` ## SIMPLE vs LOT Mode The asset's `inventory_mode` determines whether credits are tracked individually or as a running total. | Feature | `SIMPLE` | `LOT` | | ----------------- | ------------------------- | ----------------------- | | Balance tracking | Single balance per bucket | Individual lot balances | | Expiration | Not supported | Per-lot expiration | | Vesting | Not supported | Per-lot maturity dates | | Consumption order | N/A | FIFO (oldest first) | Use `SIMPLE` for points with no lifecycle. Use `LOT` when you need expiration, vesting, or vintage tracking. `expires_at` and `matures_at` fields on `CREDIT` actions are silently ignored for `SIMPLE`-mode assets. No error is returned. # Participants Source: https://docs.scrip.dev/guides/participants Create and manage the users in your programs A participant represents a user in your system. You identify participants with your own external IDs, and Scrip handles enrollment, balances, and state tracking. ## Creating Participants ### Explicitly via the API ```bash theme={null} curl -X POST https://api.scrip.dev/v1/participants \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "user_123", "program_id": "{program_id}", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe" }' ``` | Field | Required | Description | | -------------- | -------- | ----------------------------------------------- | | `external_id` | Yes | Your application's user identifier | | `program_id` | No | Enroll the participant in a program on creation | | `status` | No | `ACTIVE` (default), `SUSPENDED`, or `CLOSED` | | `email` | No | Contact email (validated) | | `phone` | No | Contact phone number | | `first_name` | No | First name | | `last_name` | No | Last name | | `display_name` | No | Display name | | `attributes` | No | Key-value metadata | | `tags` | No | List of string labels | If a participant with the same `external_id` already exists, the call upserts: it updates the existing participant instead of creating a duplicate. ### Automatically on first event When a program's `on_unknown_participant` is set to `CREATE` (the default), participants are created the first time you send an event with an unrecognized `external_id`: ```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_id}", "external_id": "user_456", "idempotency_key": "signup-user-456", "event_timestamp": "2025-01-15T10:30:00Z", "event_data": {"type": "signup"} }' ``` Scrip creates the participant, enrolls them in the program, and processes the event in one step. Set `on_unknown_participant` to `REJECT` if you want to require explicit creation before sending events. See [Programs](/guides/programs#enrollment) for details. ### Auto-enrollment of existing participants When an event targets a participant who exists but isn't enrolled in the event's program, Scrip automatically enrolls them. This applies across all identity paths (`external_id`, `participant_id`, `recipient_id`, `recipient_external_id`). Inactive enrollments (FROZEN, LOCKED, or CLOSED) are reactivated. The `on_unknown_participant` setting only controls whether *new* participants are created. It does not affect enrollment of existing ones. ## Profile Fields Participants support optional profile fields for contact and display information: `email`, `phone`, `first_name`, `last_name`, and `display_name`. These are returned in all participant responses (create, update, get, list). Profile fields follow a three-state convention: | Value sent | Behavior | | ------------------- | ----------------------------- | | Omitted (or `null`) | Existing value is preserved | | Non-empty string | Field is set to the new value | | Empty string (`""`) | Field is cleared | The `email` field is validated on both create and update. Invalid addresses return a `400` error. ```bash theme={null} # Update profile fields curl -X PATCH https://api.scrip.dev/v1/participants/{id} \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "jane@example.com", "display_name": "Jane Doe" }' ``` ```bash theme={null} # Clear a field by sending an empty string curl -X PATCH https://api.scrip.dev/v1/participants/{id} \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "" }' ``` ## Identifiers Participants have two IDs: | ID | Format | Use | | ------------- | ------ | -------------------------------------------------- | | `id` | UUID | Scrip's internal identifier, used in all API paths | | `external_id` | String | Your application's user ID, used for lookups | To find a participant by your external ID: ```bash theme={null} GET /v1/participants?external_id=user_123 ``` Use the returned `id` for all subsequent API calls. The list endpoint returns identifiers, status, and profile fields. To get the full participant state in one call, use the detail endpoint: ```bash theme={null} GET /v1/participants/{id} ``` The detail response includes `balances`, `tags`, `counters`, `attributes`, `tiers`, `program_ids`, and profile fields inline. ## Status A participant's `status` controls what operations are allowed: | Status | Description | | ----------- | ------------------------------------------------------------------ | | `ACTIVE` | Normal operation. All actions allowed. | | `SUSPENDED` | Temporary freeze (e.g., fraud review, compliance hold). Reversible | | `CLOSED` | Deactivated. Can be transitioned back to `ACTIVE` or `SUSPENDED` | ### What's allowed by status | Action | `ACTIVE` | `SUSPENDED` / `CLOSED` | | ---------------- | -------- | ---------------------- | | `CREDIT` | Yes | Blocked | | `DEBIT` | Yes | Blocked | | `HOLD` | Yes | Blocked | | `RELEASE` | Yes | Blocked | | `FORFEIT` | Yes | Blocked | | `COUNTER` | Yes | Blocked | | `TAG` / `UNTAG` | Yes | **Allowed** | | `SET_ATTRIBUTE` | Yes | **Allowed** | | `SET_TIER` | Yes | **Allowed** | | `BROADCAST` | Yes | **Allowed** | | `SCHEDULE_EVENT` | Yes | **Allowed** | Balance and counter actions are blocked because a suspended or closed participant should not accumulate value. Metadata actions (tags, attributes, tiers) are allowed because you still need to manage inactive accounts: tagging a participant with `fraud_confirmed`, setting attributes for audit trails, or adjusting tiers during a review period. ### How this affects events Events targeting suspended or closed participants are still accepted (`202`) and processed normally. The outcome depends on which actions the matching rules trigger: * If a rule triggers a **blocked action** (e.g., `CREDIT`), the action fails and the event is marked `FAILED`. * If matching rules only trigger **allowed actions** (e.g., `TAG`, `SET_ATTRIBUTE`), those actions execute and the event is marked `COMPLETED`. * If **no rules match**, the event is marked `COMPLETED` with no actions taken. If you need events to fail unconditionally for inactive participants, add a CEL guard condition to your rules: `participant.status == "ACTIVE"`. This causes the rule not to match, so no actions execute and the event completes with no effect. ```bash theme={null} PATCH /v1/participants/{id}/status {"status": "SUSPENDED"} ``` ## Balances Check a participant's current balances across all assets: ```bash theme={null} GET /v1/participants/{id}/balances ``` Balances are split by asset into three buckets: | Bucket | Description | | ----------- | ------------------------------------------------------------------------------------------------------------------------- | | `AVAILABLE` | Spendable immediately | | `HELD` | Reserved for authorization holds, pending settlements, or fraud review | | `DEFERRED` | Not yet matured. Credits with a future `matures_at` land here and move to `AVAILABLE` automatically when the date passes. | You can perform manual balance adjustments directly on a participant: ```bash theme={null} POST /v1/participants/{id}/balances/adjust { "program_id": "{program_id}", "asset_id": "{asset_id}", "type": "CREDIT", "amount": "500", "description": "Customer service goodwill credit" } ``` See [Balance Operations](/guides/balance-operations) for hold, release, forfeit, and other operations. ## Transaction History View the ledger entries for a participant: ```bash theme={null} GET /v1/participants/{id}/activity/history ``` Returns a chronological list of credits, debits, holds, and releases with journal entry details. ### Time-Range Filters The endpoint supports two independent time-range filters that can be combined (AND semantics): | Filter | Filters on | Use case | | ------------------------- | ----------------------------------------- | -------------------------------------------------- | | `from` / `to` | `created_at` (system ingestion time) | When the journal entry was recorded | | `event_from` / `event_to` | `event_timestamp` (event occurrence time) | When the originating event occurred in your system | Entries without an originating event fall back to `created_at` for `event_from`/`event_to` filtering. You can also retrieve the events processed for a participant: ```bash theme={null} GET /v1/participants/{id}/activity/events ``` ## Participant State Each participant carries state that rules can read and update: | Type | Purpose | Example | | ---------- | --------------------------- | ----------------------- | | Tags | Boolean flags | `vip`, `first_purchase` | | Counters | Numeric trackers | `purchase_count: 42` | | Attributes | Key-value strings | `region: "US"` | | Tiers | Status levels with benefits | `loyalty_tier: "gold"` | Tags are normalized to lowercase. Counters support high-precision numerics. See [State Management](/guides/state-management) for how to read, update, and use state in rules. # Programs Source: https://docs.scrip.dev/guides/programs The top-level container for rules, assets, and participants A program is the top-level container in Scrip. It holds your rules, links to your assets, and scopes your participants. You might have one program for your entire rewards system, or separate programs for distinct campaigns like "Q4 Referral Bonus" or "Employee Recognition." Programs provide isolation. Rules in one program never trigger on events sent to another, so you can run multiple campaigns in parallel without rule conflicts. ## Creating a Program A program only requires a name. You can set the enrollment policy and other options at creation or update them later. ```bash theme={null} curl -X POST https://api.scrip.dev/v1/programs \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Customer Loyalty", "description": "Main loyalty track for retail customers" }' ``` | Field | Required | Description | | ------------------------ | -------- | -------------------------------------------------------------------- | | `name` | Yes | Display name (1-255 characters) | | `description` | No | Additional context (max 1000 characters) | | `on_unknown_participant` | No | `CREATE` (default) or `REJECT`. See [Enrollment](#enrollment) below. | The response returns a `program_id` that you'll use when sending events, creating rules, and querying balances. ## The Program Wallet Every program has a wallet. How it's used depends on the asset's issuance policy: * **UNLIMITED** credits mint new value directly. The wallet is bypassed. This is what most programs use. * **PREFUNDED** credits draw from the wallet. You fund it up front, and every credit to a participant debits the wallet. When the wallet is empty, credits fail. This is how you enforce a fixed budget. If you're using PREFUNDED assets, you manage the wallet with fund and burn: ```bash theme={null} # Fund the wallet POST /v1/programs/{id}/assets/{assetId}/fund {"amount": "100000", "description": "Q1 budget allocation"} # Burn unused funds POST /v1/programs/{id}/assets/{assetId}/burn {"amount": "5000", "description": "Reduce remaining Q1 budget"} ``` ## Linking Assets Assets exist at the organization level. To use an asset in a program, it must be linked. When you create a new asset with a `program_id`, the link happens automatically. To share an existing asset across programs, link it manually: ```bash theme={null} POST /v1/programs/{programId}/assets {"asset_id": "{asset_id}"} ``` This is how you create a shared economy, where participants earn the same asset across multiple programs. An asset cannot be unlinked from a program if any ledger entries exist for that asset in that program. ## Program State Programs support the same state types as participants: tags, counters, and attributes. Use them for global logic that isn't tied to any one participant. For example, a "first 1,000 signups" cap using a program-level counter: ```json theme={null} { "name": "Early Adopter Bonus", "condition": "event.type == 'signup' && get(program.counters, 'total_signups', 0.0) < 1000.0", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "100"}, {"type": "COUNTER", "key": "total_signups", "value": "1", "target": {"type": "PROGRAM"}} ] } ``` See [State Management](/guides/state-management#program-and-group-state) for more on program-level state. ## Program Status You can pause or retire a program by updating its status: | Status | Behavior | | ----------- | ------------------------------------------------------------------------------------------------------- | | `ACTIVE` | Events process, rules evaluate normally | | `SUSPENDED` | New events are rejected. Useful for pausing a program while investigating an issue. Can be reactivated. | | `ARCHIVED` | Hidden from default listings. Historical data remains for auditing. | ```bash theme={null} PATCH /v1/programs/{id} {"status": "SUSPENDED"} ``` ## Enrollment Existing participants are automatically enrolled in a program the first time an event targets them in that program. This applies across all identity paths and requires no configuration. Inactive enrollments (FROZEN, LOCKED, or CLOSED) are reactivated. The `on_unknown_participant` setting controls what happens when an event arrives for a participant that doesn't exist yet: | Setting | Behavior | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CREATE` (default) | Scrip creates the participant automatically, enrolls them, and processes the event. No separate registration step needed. | | `REJECT` | The event fails. You must create participants explicitly via `POST /v1/participants` before sending events for them. Existing participants are still auto-enrolled. | Most programs use `CREATE` for simplicity. Use `REJECT` when you need strict control over who can participate. ## Automations Programs can have automations that generate events on a schedule, at a specific time, or in response to participant state changes. Automations are scoped to a program and managed via `POST /v1/programs/{id}/automations`. See [Automations](/guides/automations) for details. # Redemptions Source: https://docs.scrip.dev/guides/redemptions Debit balances through amount redemptions and catalog purchases Redemptions debit a participant's balance via the API. They come in two forms: an amount-based redemption for open-ended cashouts (statement credits, charitable donations), and a catalog item redemption for purchasing a specific reward. Both are triggered by your application, not by rules. ## Amount Redemption Debit a specific amount from a participant's balance: ```bash theme={null} POST /v1/participants/{id}/redemptions { "program_id": "program-uuid", "asset_id": "asset-uuid", "amount": "1000", "description": "Cash out request #12345", "idempotency_key": "cashout-12345" } ``` | Field | Required | Description | | ----------------- | -------- | ------------------------------------------------------------------------------------ | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to debit | | `amount` | Yes | Positive decimal amount | | `description` | Yes | Reason for the redemption (1-500 characters) | | `idempotency_key` | No | Prevents duplicate redemptions on retry. Same key + different payload returns `409`. | The amount is debited from the participant's `AVAILABLE` balance and credited to the program's configured redemption target. ## Catalog Item Redemption Redeem a specific item from the reward catalog: ```bash theme={null} POST /v1/participants/{id}/redemptions/items { "program_id": "program-uuid", "reward_id": "reward-uuid", "quantity": 2, "idempotency_key": "order-789" } ``` For `UNIT_BASED` rewards, specify `quantity` (defaults to 1 if omitted). For `AMOUNT_BASED` rewards, specify `amount` instead. The cost is calculated from the reward's `unit_cost` and stored on the redemption record. For `UNIT_BASED` rewards, inventory is tracked: `redeemed_count` increments globally and per participant, and the reward status changes to `OUT_OF_STOCK` when `max_total` is reached. See [Rewards Catalog](/guides/rewards-catalog) for setting up catalog items. ## Redemption Targets Each program configures where redeemed funds flow via `redemption_target_type`. Most programs use `SYSTEM_REDEMPTION` to keep redeemed value separate from breakage. | Target | Description | | ------------------- | ------------------------------------------------------------------------------------------ | | `SYSTEM_REDEMPTION` | Default. Tracks redeemed value in a dedicated system account. | | `SYSTEM_BREAKAGE` | Combines with forfeited value in the breakage account. | | `LEDGER_ENTITY` | Routes to a specific ledger entity. Requires `redemption_target_entity_id` on the program. | The target is captured on the redemption record at creation time. If you change the program's target configuration later, existing redemptions and their reversals still use the original target. This keeps the ledger consistent. Change a program's target with [Update a program](/api-reference/programs/update-a-program): ```bash theme={null} PATCH /v1/programs/{id} { "redemption_target_type": "LEDGER_ENTITY", "redemption_target_entity_id": "entity-uuid" } ``` `LEDGER_ENTITY` requires `redemption_target_entity_id` in the same request. Switching to `SYSTEM_REDEMPTION` or `SYSTEM_BREAKAGE` must omit `redemption_target_entity_id` and clears any existing reference. New programs default to `SYSTEM_REDEMPTION`; the target cannot be set at creation time. ## Reversals Reverse a redemption to return funds to the participant: ```bash theme={null} # Full reversal POST /v1/redemptions/{id}/reverse { "reason": "Order cancelled by customer", "idempotency_key": "refund-123" } # Partial reversal (amount-based) POST /v1/redemptions/{id}/reverse { "amount": "500", "reason": "Partial refund for damaged item", "idempotency_key": "refund-456" } # Partial reversal (unit-based, whole units only) POST /v1/redemptions/{id}/reverse { "quantity": 1, "reason": "Customer returned 1 gift card", "idempotency_key": "refund-789" } ``` Omit `amount` or `quantity` for a full reversal. Include it for a partial reversal. The field must match the original redemption type: `UNIT_BASED` catalog redemptions accept `quantity`, `AMOUNT_BASED` and amount redemptions accept `amount`. `reason` is required (1-500 characters). Reversals are often reviewed during disputes or audits, so every reversal needs a human-readable explanation on record. ### Reversal Status Each reversal updates the redemption's `status`: | Status | Meaning | | -------------------- | -------------------------------------------------------- | | `COMPLETED` | No reversals applied | | `PARTIALLY_REVERSED` | Some amount or quantity reversed, more can follow | | `FULLY_REVERSED` | Entire redemption reversed, no further reversals allowed | The redemption tracks cumulative totals in `reversed_amount` and, for catalog items, `reversed_quantity`. ### Inventory Restoration For `UNIT_BASED` catalog items, reversals decrement `redeemed_count` and the per-participant `redemption_count`. If a reward was `OUT_OF_STOCK` and the reversal brings inventory below `max_total`, the reward status returns to `ACTIVE`. ### Lot Restoration For `LOT`-mode assets, reversals restore the specific lots consumed by the original redemption. Lots are restored in LIFO order (last in, first out) and retain their original metadata including `expires_at` and `matures_at`. ## Viewing Redemptions You can list redemptions per participant, fetch a single redemption by ID, or list the reversals attached to a redemption. ```bash theme={null} # List participant redemptions GET /v1/participants/{id}/redemptions # Get redemption details GET /v1/redemptions/{id} # List reversals for a redemption GET /v1/redemptions/{id}/reversals ``` ## Requirements Both redemptions and reversals enforce the same base checks: * Participant must be `ACTIVE` * Program must be `ACTIVE` (not `SUSPENDED` or `ARCHIVED`) * Asset must not be archived * Sufficient `AVAILABLE` balance for the requested amount * For catalog items: reward must be `ACTIVE` and within its availability window Idempotency keys are scoped per program. Replaying a request with the same key returns the original record without creating a duplicate. # Reporting Source: https://docs.scrip.dev/guides/reporting Ledger summaries, program activity, and API usage Scrip provides reporting endpoints for tracking program financials, browsing the ledger audit trail, and monitoring API usage. All reporting data is computed in real time from the double-entry ledger. ## Ledger Summary Get aggregated balances and flows across all assets in a program: ```bash theme={null} GET /v1/reports/ledger-summary?program_id=program-uuid ``` The response includes one entry per asset: | Field | Description | | ------------------- | ------------------------------------------------------------------------------- | | `total_issued` | Cumulative credits to participants (all time) | | `total_redeemed` | Cumulative value redeemed by participants (all time) | | `total_expired` | Cumulative value expired via lot expiration (all time) | | `total_forfeited` | Cumulative value forfeited via explicit forfeit actions (all time) | | `current_balance` | Net outstanding liability (issued minus redeemed minus expired minus forfeited) | | `participant_count` | Distinct participants holding this asset | The `program_id` filter is optional. Omitting it returns summaries across all programs in the organization. ## Program Activity Compare activity across programs: ```bash theme={null} GET /v1/reports/program-activity ``` Returns one entry per program with: | Field | Description | | --------------------- | -------------------------------------------- | | `event_count` | Total events processed | | `journal_count` | Total ledger entries created | | `total_issued` | Cumulative credits via this program | | `total_redeemed` | Cumulative value redeemed via this program | | `unique_participants` | Distinct participants who earned or redeemed | | `last_activity_at` | Timestamp of the most recent ledger entry | Use the `since` parameter to filter to programs with activity after a given timestamp: ```bash theme={null} GET /v1/reports/program-activity?since=2025-01-01T00:00:00Z ``` ## Journal Entries The journal is the full audit trail of every ledger movement. Each entry represents an atomic set of postings (debits and credits) that balance to zero. ```bash theme={null} GET /v1/journal-entries?program_id=program-uuid ``` ### Filtering | Filter | Description | | --------------------------- | ------------------------------------------------------------------------------------------------------- | | `program_id` | Entries for a specific program | | `participant_id` | Entries involving a specific participant | | `external_id` | Entries by external ID | | `group_id` | Entries involving a group | | `asset_id` | Filter by asset | | `bucket` | Filter by bucket (`AVAILABLE`, `HELD`, `DEFERRED`) | | `event_id` | Entries from a specific event | | `rule_id` | Entries created by a specific rule | | `action_type` | Filter by ledger action type (`CREDIT`, `DEBIT`, `HOLD`, `RELEASE`, `FORFEIT`, `VOID_HOLD`, `MATURITY`) | | `reference_id` | Filter by correlation ID to find all entries associated with a specific hold/release/settle flow | | `from` / `to` | Time range | | `min_amount` / `max_amount` | Posting amount range (signed: credits positive, debits negative) | The entity filters (`participant_id`, `external_id`, `group_id`) are mutually exclusive. ### Entry Detail ```bash theme={null} GET /v1/journal-entries/{id} ``` Returns the full entry with all postings. Each posting includes the entity type, bucket, asset, signed amount, and timestamp. Postings within an entry always sum to zero. ## Transaction History View a specific participant's or program's ledger history: ```bash theme={null} # Participant history GET /v1/participants/{id}/activity/history # Program history GET /v1/programs/{id}/history ``` Both return a chronological list of ledger entries involving that entity. Useful for building balance history views or transaction receipts in your UI. The participant history endpoint supports two time-range filters that can be combined: | Filter | Filters on | Description | | ------------------------- | ----------------- | -------------------------------------------------- | | `from` / `to` | `created_at` | When the journal entry was recorded in the system | | `event_from` / `event_to` | `event_timestamp` | When the originating event occurred in your system | This dual-filter lets you distinguish between when a transaction was recorded and when the underlying event happened. Entries without an originating event fall back to `created_at` for `event_from`/`event_to` filtering. ## Request Logs Browse a log of every API request made to your organization: ```bash theme={null} GET /v1/logs ``` Each entry includes the HTTP method, path, status code, duration, and authentication type. Use the detail endpoint to inspect the full request and response bodies: ```bash theme={null} GET /v1/logs/{id} ``` | Filter | Description | | --------------------------- | ----------------------------------------- | | `method` | HTTP method (`POST`, `GET`, etc.) | | `route_pattern` | Route pattern (e.g., `/v1/events`) | | `request_id` | Specific request by `X-Request-ID` header | | `status_min` / `status_max` | Status code range | | `from` / `to` | Time range | ## Usage Analytics Get daily aggregated API usage metrics: ```bash theme={null} GET /v1/usage ``` Returns one entry per day with total request count, error count (status >= 400), and average latency in milliseconds. Defaults to the last 30 days. Use `from` and `to` to specify a custom range. ## Common Queries | Goal | Endpoint | Filters | | ------------------------ | ------------------------------------------------------ | ----------------------------------------------------------------- | | Total points outstanding | Ledger Summary | `program_id` | | Program comparison | Program Activity | `since` | | Participant statement | Journal Entries | `participant_id` | | Debug a specific event | [Event Impact](/api-reference/events/get-event-impact) | Full causal chain: rules, postings, state changes, balance impact | | Breakage tracking | Ledger Summary | `total_expired` + `total_forfeited` fields | | API error investigation | Request Logs | `status_min=400` | # Rewards Catalog Source: https://docs.scrip.dev/guides/rewards-catalog Define redeemable items with pricing, inventory limits, and availability windows The rewards catalog defines items that participants can redeem with their points. Each reward belongs to a program, has a cost denominated in a specific asset, and can optionally enforce inventory limits and availability windows. You might use it for gift cards, merchandise, cashback tiers, or charitable donation options. ## Creating a Reward Rewards are scoped to a program and priced in a linked asset. ```bash theme={null} POST /v1/programs/{programId}/rewards { "name": "$10 Gift Card", "redemption_type": "UNIT_BASED", "asset_id": "points-uuid", "unit_cost": "1000", "max_total": 100, "max_per_participant": 2, "status": "ACTIVE" } ``` | Field | Required | Description | | --------------------- | -------- | ------------------------------------------------------------------------------------------------------ | | `name` | Yes | Display name (1-255 characters, unique per program) | | `description` | No | Additional context (max 1000 characters) | | `category` | No | Grouping label (max 100 characters) | | `redemption_type` | Yes | `UNIT_BASED` or `AMOUNT_BASED`. Cannot be changed after creation. | | `asset_id` | Yes | Which asset this reward is priced in. Must be linked to the program. Cannot be changed after creation. | | `unit_cost` | Yes | Cost per unit (must be positive). For `AMOUNT_BASED` rewards, this is the minimum redemption amount. | | `max_total` | No | Global inventory cap (`UNIT_BASED` only). Null means unlimited. | | `max_per_participant` | No | Per-participant cap (`UNIT_BASED` only). Null means unlimited. | | `status` | Yes | Initial status. Most rewards start as `DRAFT` until configuration is finalized. | | `available_from` | No | When the reward becomes redeemable (UTC timestamp) | | `available_until` | No | When the reward stops being redeemable (UTC timestamp) | | `metadata` | No | Arbitrary JSON for your own use | ## Reward Types ### UNIT\_BASED Discrete quantities with a fixed cost per unit. Use for items like gift cards, merchandise, or experiences. When a participant redeems, the total cost is `quantity * unit_cost`. Inventory is tracked: each redemption increments `redeemed_count` globally and per participant. When `redeemed_count` reaches `max_total`, the reward status automatically changes to `OUT_OF_STOCK`. ### AMOUNT\_BASED Flexible amounts without inventory tracking. Use for cashback, statement credits, or charitable donations. Participants specify an `amount` when redeeming. `max_total` and `max_per_participant` are not supported for this type. ## Reward Status | Status | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `DRAFT` | Not yet available for redemption. Use while configuring. | | `ACTIVE` | Available for redemption (subject to availability window). | | `OUT_OF_STOCK` | Set automatically when `redeemed_count` reaches `max_total`. Reversals can restore inventory and revert the status to `ACTIVE`. | | `ARCHIVED` | Removed from default listings. Cannot be redeemed. Historical data remains. | Only `ACTIVE` rewards can be redeemed. ## Availability Windows Restrict when a reward is redeemable by setting `available_from` and `available_until`. Both are optional. If both are set, `available_from` must be before `available_until`. ```bash theme={null} POST /v1/programs/{programId}/rewards { "name": "Holiday Special", "redemption_type": "UNIT_BASED", "asset_id": "points-uuid", "unit_cost": "500", "available_from": "2025-12-01T00:00:00Z", "available_until": "2025-12-31T23:59:59Z", "status": "ACTIVE" } ``` A redemption attempt outside the window returns a 409 error. ## Managing Rewards You can list, inspect, and update rewards through the API. Archived rewards are hidden from list results by default but can be included with `include_archived=true`. ```bash theme={null} # List catalog items GET /v1/programs/{programId}/rewards # Get a specific item (includes archived) GET /v1/programs/{programId}/rewards/{rewardId} # Update an item PATCH /v1/programs/{programId}/rewards/{rewardId} {"max_total": 200} ``` `redemption_type` and `asset_id` cannot be changed after creation. All other fields can be updated. ## Inventory Management Inventory tracking applies to `UNIT_BASED` rewards only. Scrip maintains a global `redeemed_count` and a per-participant count, both updated atomically during redemptions and reversals. * `max_total` can be increased but not decreased below the current `redeemed_count` * Use `DRAFT` status while configuring, then switch to `ACTIVE` when ready * When `redeemed_count` reaches `max_total`, the reward transitions to `OUT_OF_STOCK` automatically * `UNIT_BASED` reversals decrement `redeemed_count`. If this brings inventory back below `max_total`, the reward returns to `ACTIVE` See [Redemptions](/guides/redemptions) for how participants redeem catalog items and how reversals restore inventory. # Rule Actions Source: https://docs.scrip.dev/guides/rule-actions Configure what happens when a rule matches Actions define what happens when a rule's condition matches. Each rule has one or more actions that execute atomically in a single transaction. ## Participant Status Restrictions Not all actions are allowed on every participant. Financial actions are blocked for `SUSPENDED` and `CLOSED` participants to prevent inactive accounts from accumulating value. Metadata actions are always allowed so you can still manage inactive accounts (e.g., tagging for audit, updating attributes during a review). | Blocked for inactive participants | Allowed regardless of status | | ----------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `CREDIT`, `DEBIT`, `HOLD`, `RELEASE`, `FORFEIT`, `VOID_HOLD`, `COUNTER` | `TAG`, `UNTAG`, `SET_ATTRIBUTE`, `SET_TIER`, `BROADCAST`, `SCHEDULE_EVENT` | If a rule triggers a blocked action, the action fails and the event is marked `FAILED`. If matching rules only trigger allowed actions, the event completes normally. See [Participants: What's allowed by status](/guides/participants#whats-allowed-by-status) for the full matrix. ## Action Types | Action | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------- | | `CREDIT` | Add funds to an account | | `DEBIT` | Remove funds from an account | | `HOLD` | Move funds from `AVAILABLE` to `HELD` | | `RELEASE` | Move funds from `HELD` to `AVAILABLE` | | `FORFEIT` | Remove funds permanently (to `SYSTEM_BREAKAGE`) | | `VOID_HOLD` | Cancel HELD lots that were credited directly into HELD (e.g., pending auth rewards), return value to source | | `TAG` | Add a boolean flag | | `UNTAG` | Remove a boolean flag | | `COUNTER` | Increment a numeric value | | `SET_ATTRIBUTE` | Set a key-value string | | `SET_TIER` | Assign a tier level | | `SCHEDULE_EVENT` | Create a delayed follow-up event | | `BROADCAST` | Fan out an event to all participants | ## Asset Actions ### CREDIT Add funds to an account. ```json theme={null} {"type": "CREDIT", "asset_id": "uuid", "amount": "100"} {"type": "CREDIT", "asset_id": "uuid", "amount": "event.amount * 10"} {"type": "CREDIT", "asset_id": "uuid", "amount": "round(event.amount * 0.03, 2)"} ``` | Field | Required | Description | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `asset_id` | Yes | Target asset | | `amount` | Yes | Static value (`"100"`) or CEL expression (`"event.amount * 10"`) | | `bucket` | No | Balance bucket: `AVAILABLE` (default) or `HELD` | | `description` | No | Ledger entry description | | `reference_id` | No | Correlation ID for auth/settle reconciliation (`LOT` mode only). Behavior depends on the target bucket: crediting to `HELD` stamps held lots for later release. Crediting to `AVAILABLE` reconciles against any existing held lots for that reference, handling over/under-capture automatically; if no held lots exist, a standard credit is applied with the `reference_id` stamped on the lot. Static literal or CEL expression (e.g., `"event.authorization_id"`). | | `expires_at` | No | Lot expiration (LOT-mode only). RFC 3339 timestamp or duration (e.g., `"8760h"`) | | `matures_at` | No | Lot vesting date (LOT-mode only). RFC 3339 timestamp or duration (e.g., `"720h"`) | | `target` | No | Alternate recipient (default: event participant) | For `UNLIMITED` assets, `CREDIT` mints new funds. For `PREFUNDED` assets, `CREDIT` draws from the program wallet. **Automatic rounding:** If the evaluated amount has more decimal places than the asset's `scale`, the value is rounded automatically. For example, `event.amount * 0.03` might produce `1.009`, which rounds to `"1.01"` on a `scale: 2` asset. No error is returned. Use `round()` in your CEL expression if you need explicit control. ### DEBIT Remove funds from an account. ```json theme={null} {"type": "DEBIT", "asset_id": "uuid", "amount": "50"} ``` | Field | Required | Description | | ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `asset_id` | Yes | Target asset | | `amount` | Yes | Static value or CEL expression | | `allow_negative` | No | When `true`, allows the debit to overdraw the balance below zero. Used for clawbacks and corrections. Defaults to `false`. Not allowed when `target.type` is `PROGRAM`. | | `bucket` | No | Balance bucket: `AVAILABLE` (default) or `HELD` | | `target` | No | Alternate recipient (default: event participant) | Fails with `insufficient balance` if the available balance is less than the requested amount, unless `allow_negative` is `true`. See [Balance Operations: Negative Balances](/guides/balance-operations#negative-balances) for details and use cases. ### HOLD Reserve funds by moving them from `AVAILABLE` to `HELD`. ```json theme={null} {"type": "HOLD", "asset_id": "uuid", "amount": "500"} {"type": "HOLD", "asset_id": "uuid", "amount": "event.amount", "reference_id": "event.authorization_id"} ``` | Field | Required | Description | | -------------- | -------- | ----------------------------------------------------------------------------------------- | | `asset_id` | Yes | Target asset | | `amount` | Yes | Static value or CEL expression | | `reference_id` | No | Correlation ID to stamp on held lots (`LOT` mode only). Static literal or CEL expression. | | `bucket` | No | Source bucket (default: `AVAILABLE`) | Held funds are not spendable. Use for authorization holds or pending settlements. When `reference_id` is provided, the held lots are stamped so a future `RELEASE` can target them by reference. ### RELEASE Move funds from `HELD` back to `AVAILABLE`. ```json theme={null} {"type": "RELEASE", "asset_id": "uuid", "amount": "500"} {"type": "RELEASE", "asset_id": "uuid", "reference_id": "event.authorization_id"} ``` | Field | Required | Description | | -------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `asset_id` | Yes | Target asset | | `amount` | Conditional | Static value or CEL expression. Optional when `reference_id` is provided. Omitting `amount` releases all lots matching the reference. | | `reference_id` | No | Release only lots stamped with this reference during a previous hold (`LOT` mode only). Static literal or CEL expression. | | `bucket` | No | Source bucket (default: `HELD`) | ### FORFEIT Remove funds permanently. Debits the participant and credits `SYSTEM_BREAKAGE`. ```json theme={null} {"type": "FORFEIT", "asset_id": "uuid", "amount": "100", "bucket": "AVAILABLE"} ``` | Field | Required | Description | | ---------- | -------- | ------------------------------------ | | `asset_id` | Yes | Target asset | | `amount` | Yes | Static value or CEL expression | | `bucket` | No | Source bucket (default: `AVAILABLE`) | Use for point expiration or policy violations. Rule-triggered forfeits are blocked for non-active participants. Use the [forfeit API endpoint](/api-reference/participants/forfeit-participant-balance) for manual cleanup of closed accounts. ### VOID\_HOLD Cancel HELD lots that were credited directly into the HELD bucket (not moved there from AVAILABLE via a HOLD action). Returns value to the original source account (program wallet for `PREFUNDED`, `SYSTEM_ISSUANCE` for `UNLIMITED`). ```json theme={null} {"type": "VOID_HOLD", "asset_id": "uuid", "reference_id": "event.authorization_id"} ``` | Field | Required | Description | | -------------- | -------- | -------------------------------------------------------------------------------------- | | `asset_id` | Yes | Target asset (must be `LOT` mode) | | `reference_id` | Yes | Correlation ID matching the original CREDIT to HELD. Static literal or CEL expression. | | `description` | No | Context string | Use for auth reversals, when a merchant voids a transaction before settlement. Lots that a participant already owned and moved to HELD via a HOLD action are excluded, so you cannot accidentally void participant-owned funds. `amount`, `bucket`, and `lot_ids` are not supported — the entire provisional accrual is voided. ## State Actions State actions are durable updates, not same-event condition inputs. If a rule adds a tag, increments a counter, sets an attribute, or assigns a tier, the new state is visible to later events. It is not visible to lower-order rule conditions or to dynamic action expressions (like `amount`, `COUNTER` `value`, or `SET_ATTRIBUTE` `value`) in the current event. See [State Snapshot Evaluation Behavior](/guides/writing-rules#state-snapshot-evaluation-behavior). ### TAG Add a boolean flag to an entity. ```json theme={null} {"type": "TAG", "tag": "VIP"} {"type": "TAG", "tag": "FIRST_PURCHASE"} ``` | Field | Required | Description | | -------- | -------- | ------------------------------------------------ | | `tag` | Yes | Tag name to add | | `target` | No | Alternate recipient (default: event participant) | ### UNTAG Remove a boolean flag from an entity. ```json theme={null} {"type": "UNTAG", "tag": "PROMO_ACTIVE"} {"type": "UNTAG", "tag": "INTRO_PERIOD"} ``` | Field | Required | Description | | -------- | -------- | ------------------------------------------------ | | `tag` | Yes | Tag name to remove | | `target` | No | Alternate recipient (default: event participant) | Removing a tag that doesn't exist is a no-op. The action succeeds silently. ### COUNTER Increment a numeric value. ```json theme={null} {"type": "COUNTER", "key": "purchase_count", "value": "1"} {"type": "COUNTER", "key": "lifetime_spend", "value": "event.amount"} {"type": "COUNTER", "key": "monthly_purchases", "value": "1", "reset_after": "720h"} ``` | Field | Required | Description | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | `key` | Yes | Counter name | | `value` | Yes | Static number or CEL expression to add to the current value | | `reset_after` | No | Auto-reset duration (e.g., `"720h"`). Counter resets to 0 when the duration elapses. Send empty string to remove auto-reset. | | `target` | No | Alternate recipient (default: event participant) | The `value` field increments the counter. It does not replace it. See [State Management](/guides/state-management#auto-resetting-counters) for details on auto-reset behavior. ### SET\_ATTRIBUTE Set a key-value pair on an entity. ```json theme={null} {"type": "SET_ATTRIBUTE", "key": "spender_tier", "value": "high"} {"type": "SET_ATTRIBUTE", "key": "last_category", "value": "event.category"} ``` | Field | Required | Description | | -------- | -------- | ------------------------------------------------ | | `key` | Yes | Attribute name | | `value` | Yes | Static string or CEL expression | | `target` | No | Alternate recipient (default: event participant) | Scrip determines whether `value` is a literal or a CEL expression by checking for syntax characters (`.`, `()`, operators, brackets). A value like `"high"` is stored as-is. A value like `"event.category"` is evaluated as a CEL expression. If a value looks like CEL but the expression is invalid, the action fails. ### SET\_TIER Assign a tier level on an entity. Each `tier` represents a separate track (e.g., `"status"`, `"loyalty"`), and `level` specifies a position within that track. Tier levels have a numeric `rank` that defines their order in the hierarchy. ```json theme={null} {"type": "SET_TIER", "tier": "status", "level": "gold"} {"type": "SET_TIER", "tier": "status", "level": "platinum", "expires_at": "8760h"} ``` | Field | Required | Description | | ------------ | -------- | ----------------------------------------------------------------------- | | `tier` | Yes | Tier track identifier (e.g., `"status"`, `"loyalty"`) | | `level` | Yes | Tier level within the track (e.g., `"gold"`, `"platinum"`) | | `expires_at` | No | When the tier expires. RFC 3339 timestamp or duration (e.g., `"8760h"`) | | `target` | No | Alternate recipient (default: event participant) | If the participant already holds a tier in the same track, `SET_TIER` overwrites it. The previous tier is recorded as a transition for audit purposes. This rule promotes a participant to gold when their lifetime spend crosses \$1,000: ```json theme={null} { "name": "Gold Tier Promotion", "condition": "event.type == 'purchase' && get(participant.counters, 'lifetime_spend', 0.0) + event.amount >= 1000.0 && get(participant.tiers, 'status', {'rank': 0}).rank < 2", "actions": [ {"type": "SET_TIER", "tier": "status", "level": "gold", "expires_at": "8760h"}, {"type": "COUNTER", "key": "lifetime_spend", "value": "event.amount"} ] } ``` Tier state is available in CEL via `participant.tiers`. Each entry is a map with `level` (string), `rank` (number), `benefits` (map), `acquired` (timestamp string), and `expires` (timestamp string or null). ```javascript theme={null} participant.tiers["status"].rank >= 2 participant.tiers["status"].level == "gold" ``` If `expires_at` is set, the system schedules a `tier_expiration` event when the duration elapses. That event enters the rules engine and triggers the tier's configured downgrade policy. ## Scheduling Actions These actions create [automations](/guides/automations) under the hood. `SCHEDULE_EVENT` creates a one-time automation scoped to the program, and `BROADCAST` creates an immediate automation that fans out to all participants. ### SCHEDULE\_EVENT Create a follow-up event that fires after a specified delay. Under the hood, this creates a `one_time` + `program` automation targeting the same participant who triggered the original rule. | Field | Required | Description | | ------------ | -------- | -------------------------------------------------------------- | | `event_name` | Yes | The `type` value for the scheduled event's `event_data` | | `delay` | Yes | Duration before firing (e.g., `"24h"`, `"720h"`) | | `payload` | No | Additional data merged into the scheduled event's `event_data` | Common durations: `24h` (1 day), `168h` (1 week), `720h` (30 days), `8760h` (1 year). This rule grants a signup bonus and schedules an inactivity check 30 days later: ```json theme={null} { "name": "Schedule Inactivity Check", "condition": "event.type == 'signup'", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "50"}, {"type": "TAG", "tag": "WELCOME_BONUS"}, {"type": "SCHEDULE_EVENT", "event_name": "inactivity_check", "delay": "720h"} ] } ``` When the scheduled event fires, it enters the rules engine like any other event. A separate rule handles it: ```json theme={null} { "name": "Inactivity Bonus", "condition": "event.type == 'inactivity_check' && get(participant.counters, 'purchase_count', 0.0) == 0.0", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "25"} ] } ``` The automation is deduplicated using a key derived from the triggering event, rule, participant, event name, delay, and payload. Replaying the same event does not create a duplicate automation. ### BROADCAST Fan out an event to every active participant in the program. Under the hood, this creates an `immediate` + `participants` automation that begins fan-out right away. ```json theme={null} {"type": "BROADCAST", "event_name": "monthly_bonus", "payload": {"bonus_amount": 50}} ``` | Field | Required | Description | | ------------ | -------- | ------------------------------------------------------- | | `event_name` | Yes | The `type` value for the broadcast event's `event_data` | | `payload` | No | Additional data merged into each participant's event | `BROADCAST` cannot include `target`, `asset_id`, `amount`, or other action-specific fields. The broadcast event itself triggers rules, and those rules define what happens. This pair of rules runs a conditional monthly bonus. The first rule fires the broadcast; the second rule runs for each participant who qualifies: ```json theme={null} { "name": "Trigger Monthly Bonus", "condition": "event.type == 'month_end'", "actions": [ {"type": "BROADCAST", "event_name": "monthly_bonus", "payload": {"month": "2025-01"}} ] } ``` ```json theme={null} { "name": "Monthly Bonus Reward", "condition": "event.type == 'monthly_bonus' && get(participant.counters, 'monthly_purchases', 0.0) >= 5.0", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "25"} ] } ``` For more control over fan-out behavior (participant filtering, scheduling, guard conditions), create automations directly via the API. See [Automations](/guides/automations). `BROADCAST` and `SCHEDULE_EVENT` actions are skipped in test mode. Simulated events do not create automations. ## Targeting By default, actions apply to the event's participant. Use `target` to route an action to a different entity. These action types support `target`: `CREDIT`, `DEBIT`, `TAG`, `UNTAG`, `COUNTER`, `SET_ATTRIBUTE`, `SET_TIER`. ### Static Targets Target the program itself or a specific group by ID: ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "target": {"type": "PROGRAM"}} ``` ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "target": {"type": "GROUP", "id": "group-uuid"}} ``` ### Dynamic Targets Resolve the target from event data using a CEL expression. Use `external_id` to look up a participant by your application's user ID, or `participant_id` to look up by Scrip UUID. The `external_id` and `participant_id` fields in `target` are **CEL expressions**, not plain strings. To reference a value from the event payload, write `event.field_name`. To use a literal string, wrap it in quotes: `'"user-123"'`. Reference a field from event data (most common): ```json theme={null} { "type": "CREDIT", "asset_id": "...", "amount": "50", "target": {"external_id": "event.referrer_id"} } ``` Use a fixed participant (CEL string literal, note the inner quotes): ```json theme={null} { "type": "CREDIT", "asset_id": "...", "amount": "50", "target": {"external_id": "'user-123'"} } ``` Look up by Scrip UUID instead of external ID: ```json theme={null} { "type": "CREDIT", "asset_id": "...", "amount": "50", "target": {"participant_id": "event.recipient_id"} } ``` Dynamic target expressions only have access to `event` data, not participant state or program state. Only one ID field (`external_id`, `participant_id`, or `id`) can be specified per target. The target participant must exist and be enrolled in the program. ### Example: Referral Bonus Credit both the new user and their referrer from a single event: ```json theme={null} { "name": "Referral Bonus", "condition": "event.type == 'signup' && has(event.referrer_id)", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "50"}, {"type": "CREDIT", "asset_id": "...", "amount": "25", "target": {"external_id": "event.referrer_id"}}, {"type": "COUNTER", "key": "referral_count", "value": "1", "target": {"external_id": "event.referrer_id"}} ] } ``` ## Lot Expiration and Vesting For `LOT`-mode assets, `CREDIT` actions can set expiration and vesting dates: ```json theme={null} {"type": "CREDIT", "asset_id": "...", "amount": "100", "expires_at": "8760h"} {"type": "CREDIT", "asset_id": "...", "amount": "100", "matures_at": "720h"} {"type": "CREDIT", "asset_id": "...", "amount": "100", "matures_at": "168h", "expires_at": "2160h"} {"type": "CREDIT", "asset_id": "...", "amount": "100", "expires_at": "2025-12-31T23:59:59Z"} ``` Both fields accept RFC 3339 timestamps or Go durations. These fields are ignored for `SIMPLE`-mode assets. See [Lots & Expiration](/guides/lots-and-expiration) for details on lot lifecycle. # State Management Source: https://docs.scrip.dev/guides/state-management Tags, counters, and attributes for tracking participant state Tags, counters, attributes, and tiers store state on participants, programs, and groups. Rules read this state in CEL conditions and update it through actions. Tiers are covered in their own guide. See [Tiers](/guides/tiers) for qualification, retention, and downgrade policies. ## Tags Boolean flags. A tag either exists on an entity or it doesn't. Useful for one-time gates, status markers, and segmentation labels. ```bash theme={null} # Add a tag PUT /v1/participants/{id}/state/tags/vip # Remove a tag DELETE /v1/participants/{id}/state/tags/vip # List all tags GET /v1/participants/{id}/state/tags ``` Tags are normalized to lowercase on write. ### Using tags in conditions ```javascript theme={null} // Check if tagged "vip" in participant.tags // One-time gate !("welcome_bonus" in participant.tags) // Multiple tags sets.contains(participant.tags, ["vip", "gold"]) // has ALL sets.intersects(participant.tags, ["vip", "silver"]) // has ANY ``` ### Setting tags from rules The `TAG` action adds a tag and `UNTAG` removes one. Both apply to the event's participant by default. This rule grants a one-time welcome bonus and tags the participant to prevent re-triggering: ```json theme={null} { "name": "Welcome Bonus", "condition": "event.type == 'signup' && !('welcome_bonus' in participant.tags)", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "100"}, {"type": "TAG", "tag": "welcome_bonus"} ] } ``` Use `UNTAG` to remove a tag when a condition is no longer met. This rule removes a promotional flag when the promo period ends: ```json theme={null} {"type": "UNTAG", "tag": "PROMO_ACTIVE"} ``` To tag or untag a different entity, add a `target`: ```json theme={null} {"type": "TAG", "tag": "milestone_reached", "target": {"type": "PROGRAM"}} {"type": "UNTAG", "tag": "milestone_reached", "target": {"type": "PROGRAM"}} ``` ## Counters Numeric accumulators. Stored as high-precision decimals, available as numbers in CEL expressions. Useful for running totals, occurrence counts, and threshold tracking. ```bash theme={null} # Set a counter PUT /v1/participants/{id}/state/counters/lifetime_spend {"value": "500.00"} # Get a counter GET /v1/participants/{id}/state/counters/lifetime_spend # Delete a counter DELETE /v1/participants/{id}/state/counters/lifetime_spend ``` ### Using counters in conditions Always use `get()` for safe access with a default value: ```javascript theme={null} // Simple threshold get(participant.counters, "purchase_count", 0.0) >= 10.0 // Threshold crossing detection get(participant.counters, "spend", 0.0) < 1000.0 && (get(participant.counters, "spend", 0.0) + event.amount) >= 1000.0 ``` ### Updating counters from rules The `COUNTER` action increments the current value. It does not replace it. This rule tracks purchase count and lifetime spend on every purchase: ```json theme={null} { "name": "Track Purchase Activity", "condition": "event.type == 'purchase'", "actions": [ {"type": "COUNTER", "key": "purchase_count", "value": "1"}, {"type": "COUNTER", "key": "lifetime_spend", "value": "event.amount"} ] } ``` If a participant has `purchase_count: 7` and `lifetime_spend: 340.00`, and a purchase event arrives with `event.amount: 49.99`, after this rule fires: * `purchase_count` becomes `8` * `lifetime_spend` becomes `389.99` The `value` field accepts static values (`"1"`) or CEL expressions (`"event.amount"`). Counters reflect the pre-event state during rule evaluation. If Rule A increments a counter, Rule B in the same event still sees the original value. The same applies to tags, attributes, and tiers: state actions are durable updates, not same-event condition inputs. See [State Snapshot Evaluation Behavior](/guides/writing-rules#state-snapshot-evaluation-behavior) for details. ### Auto-resetting counters Counters can be configured to automatically reset to 0 after a duration elapses. The timer is per-participant, starting from when the counter was last created, explicitly set, or auto-reset. Resets are applied on the next read or write, not at the exact moment the timer expires. Set `reset_after` via the API: ```bash theme={null} PUT /v1/participants/{id}/state/counters/monthly_purchases {"value": "1", "reset_after": "720h"} ``` Or from a rule action: ```json theme={null} {"type": "COUNTER", "key": "monthly_purchases", "value": "1", "reset_after": "720h"} ``` Each participant gets their own independent timer. Reading the counter returns the effective value (0 if the window has elapsed), along with `reset_after` and `last_reset_at` when auto-reset is configured. To remove auto-reset from a counter, set `reset_after` to an empty string. Omitting the field leaves the existing configuration unchanged. This is for per-participant elapsed-time windows: "reset purchase count 30 days after first purchase" or "daily counter that resets after 24h of inactivity." For calendar-aligned resets (every Sunday, 1st of month), use a cron [automation](/guides/automations) instead. ## Attributes Key-value strings for arbitrary metadata. Useful for segmentation, preferences, and profile data referenced in rules. ```bash theme={null} # Set a single attribute PUT /v1/participants/{id}/state/attributes/region {"value": "US"} # Update multiple attributes PATCH /v1/participants/{id}/state/attributes {"attributes": {"region": "US", "plan": "premium"}} # Get an attribute GET /v1/participants/{id}/state/attributes/region # Remove an attribute DELETE /v1/participants/{id}/state/attributes/region ``` ### Using attributes in conditions ```javascript theme={null} get(participant.attributes, "region", "") == "US" get(participant.attributes, "plan", "") in ["premium", "enterprise"] ``` ### Setting attributes from rules The `SET_ATTRIBUTE` action sets a key-value pair on the event's participant. The `value` field supports literal strings and CEL expressions. Plain strings like `"high"` are stored as-is. Values containing CEL syntax like `event.category` or `string(event.amount)` are evaluated as expressions. ```json theme={null} { "name": "Mark High Spender", "condition": "event.type == 'purchase' && event.amount >= 500", "actions": [ {"type": "SET_ATTRIBUTE", "key": "spender_tier", "value": "high"} ] } ``` ## Program and Group State Programs and groups support the same state types as participants. Access them in CEL via `program.*` and `groups[*].*`. ```javascript theme={null} // Program counter get(program.counters, "total_issued", 0.0) < 100000.0 // Program tag "milestone_reached" in program.tags ``` To update program or group state from a rule, add a `target` to the action: ```json theme={null} { "name": "Limited Giveaway", "condition": "event.type == 'claim' && get(program.counters, 'total_claims', 0.0) < 1000.0", "actions": [ {"type": "CREDIT", "asset_id": "...", "amount": "50"}, {"type": "COUNTER", "key": "total_claims", "value": "1", "target": {"type": "PROGRAM"}} ] } ``` ## State History All state changes are logged. Query the history to see what changed, when, and why: ```bash theme={null} GET /v1/participants/{id}/activity/state-history?state_type=counter&key=lifetime_spend ``` Each entry records the `state_type`, `key`, `operation` (`set` or `delete`), `old_value`, `new_value`, timestamp, and the source of the change. Rule-triggered changes include the `event_id`. Direct API calls include the API key ID. ## State Updates on Inactive Participants State update behavior depends on participant status: | State Type | ACTIVE | SUSPENDED / CLOSED | | ---------- | ------- | ------------------- | | Tags | Allowed | Allowed | | Attributes | Allowed | Allowed | | Counters | Allowed | Blocked (409 error) | # Testing Source: https://docs.scrip.dev/guides/testing Validate rules before deploying with simulation ## Rule Simulation Use the simulation endpoint to test a rule against sample data without creating real events or affecting balances. Simulation evaluates the rule's CEL condition and returns what actions would fire, without executing them. ```bash theme={null} curl -X POST https://api.scrip.dev/v1/rules/{ruleId}/simulate \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "event": { "type": "purchase", "amount": 50.00, "category": "dining" }, "participant_state": { "tags": ["active"], "counters": {"monthly_spend": 250}, "attributes": {"tier": "gold"} } }' ``` The response shows whether the condition matched, the evaluated action amounts, and any errors in the CEL expression. Use this to iterate on conditions and amount formulas before activating a rule. ### Validation For syntax-only checks, use the validation endpoint: ```bash theme={null} curl -X POST https://api.scrip.dev/v1/rules/validate \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "condition": "event.type == \"purchase\" && event.amount > 10.0" }' ``` This checks that the CEL expression compiles without evaluating it against any data. Field references like `event.amount` are not verified against a schema. # Tiers Source: https://docs.scrip.dev/guides/tiers Ranked progression tracks with qualification, retention, and downgrade policies Tiers represent ranked progression on participants and groups. Each tier type (e.g., `"loyalty"`, `"status"`) is an independent track with ordered levels. You might define a loyalty track with Silver, Gold, and Platinum levels, or a status track for new, active, and churned members. Tiers can advance automatically based on counter thresholds, or be set directly by rules. Retention modes control how long a tier lasts, and downgrade policies determine what happens when qualification lapses. ## Tier Types and Levels A tier type defines a progression track. Each type contains ordered levels, where `rank` determines the hierarchy. Higher rank means a higher tier. ```bash theme={null} POST /v1/programs/{programId}/tiers { "key": "loyalty", "display_name": "Loyalty Status", "levels": [ { "key": "silver", "rank": 1, "display_name": "Silver", "qualification": { "mode": "ALL", "criteria": [ {"counter": "ytd_spend", "operator": ">=", "threshold": 500} ] }, "benefits": {"points_multiplier": 1.5} }, { "key": "gold", "rank": 2, "display_name": "Gold", "qualification": { "mode": "ALL", "criteria": [ {"counter": "ytd_spend", "operator": ">=", "threshold": 2000}, {"counter": "ytd_nights", "operator": ">=", "threshold": 10} ] }, "benefits": {"points_multiplier": 2.0, "lounge_access": true} }, { "key": "platinum", "rank": 3, "display_name": "Platinum", "qualification": { "mode": "ALL", "criteria": [ {"counter": "ytd_spend", "operator": ">=", "threshold": 5000}, {"counter": "ytd_nights", "operator": ">=", "threshold": 25} ] }, "benefits": {"points_multiplier": 3.0, "lounge_access": true, "suite_upgrade": true} } ], "lifecycle": { "retention": {"mode": "PERIOD_BASED"}, "qualification_period": {"type": "CALENDAR_YEAR"}, "status_validity": {"extend_months": 1}, "downgrade_policy": {"mode": "DROP_TO_QUALIFYING", "grace_days": 30}, "counters": {"qualifying": ["ytd_spend", "ytd_nights"], "rollover": "NONE"} } } ``` ### Tier Type Fields | Field | Required | Description | | -------------- | -------- | --------------------------------------------------------------------------------- | | `key` | Yes | Unique identifier (lowercase alphanumeric and underscores, starts with a letter) | | `display_name` | No | Human-readable name | | `levels` | Yes | Ordered list of tier levels | | `lifecycle` | No | Retention, qualification, and downgrade configuration. Omit for rules-only tiers. | ### Level Fields | Field | Required | Description | | --------------- | -------- | ----------------------------------------------------------------------------------------- | | `key` | Yes | Unique within the tier type | | `rank` | Yes | Integer determining hierarchy. Higher rank = higher tier. Must be unique within the type. | | `display_name` | No | Human-readable name | | `qualification` | No | Counter-based criteria for automatic advancement | | `benefits` | No | Arbitrary JSON returned with tier state | | `color` | No | Hex color code for UI rendering | | `icon_url` | No | Icon URL for UI rendering | Tier types can be scoped to a single program or defined at the organization level (shared across programs). Program-specific types take precedence over organization-wide types with the same key. ## Qualification Qualification criteria determine when a participant automatically advances to a tier level. Each criterion checks a participant counter against a threshold. ```json theme={null} { "mode": "ALL", "criteria": [ {"counter": "ytd_spend", "operator": ">=", "threshold": 2000}, {"counter": "ytd_nights", "operator": ">=", "threshold": 10} ] } ``` | Field | Description | | ----------- | ----------------------------------------------------------- | | `mode` | `ALL` (every criterion must be met) or `ANY` (at least one) | | `counter` | Counter key to evaluate on the participant | | `operator` | `>=`, `>`, `==`, `<=`, `<` | | `threshold` | Numeric value to compare against | After all rules fire for an event, Scrip evaluates qualification automatically. If the participant qualifies for a higher-ranked level than their current tier, they advance. Auto-evaluation only upgrades. Downgrades happen through the lifecycle system. If a `SET_TIER` rule action fires for the same tier type during the same event, auto-evaluation is skipped for that type. This lets rules take explicit control when needed. ## Benefits Each level can carry a `benefits` object, a freeform JSON payload that Scrip stores and returns whenever you query a participant's tier state. Use benefits to attach level-specific data that your application acts on: multipliers, feature flags, discount rates, access grants, or anything else tied to the level. ```json theme={null} { "key": "gold", "rank": 2, "benefits": { "points_multiplier": 2.0, "lounge_access": true, "support_priority": "high" } } ``` Scrip does not interpret or enforce the benefits payload. When a participant reaches Gold, their tier state response includes `"benefits": {"points_multiplier": 2.0, "lounge_access": true, "support_priority": "high"}`. Your application reads these values and applies the corresponding behavior. Benefits are also accessible in rule conditions via `participant.tiers..benefits`, so you can write rules that check a participant's current benefits before taking action: ```javascript theme={null} // Apply multiplier from tier benefits participant.tiers.loyalty.benefits.points_multiplier // Gate a rule on a benefit flag participant.tiers.loyalty.benefits.lounge_access == true ``` ## Retention Modes The `retention` config in `lifecycle` controls how long a tier lasts once achieved. | Mode | Behavior | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | `PERIOD_BASED` | Tier is re-evaluated at the end of each qualification period. The participant keeps their tier until the next period boundary. | | `ACTIVITY_REFRESH` | Tier expires after the specified `duration` of inactivity. Each event processed for the participant resets the timer. | ### Qualification Periods For `PERIOD_BASED` retention, the qualification period defines the evaluation cycle: | Type | Behavior | | --------------- | --------------------------------------------------- | | `CALENDAR_YEAR` | January 1 to December 31 | | `FIXED_YEAR` | Custom start date via `start_month` and `start_day` | | `NONE` | No periodic re-evaluation | At the end of each period, Scrip fires a `tier_evaluation` system event via an internal [automation](/guides/automations) that re-evaluates all tiers and applies the downgrade policy. ### Activity Refresh For `ACTIVITY_REFRESH`, the duration is specified in hours (e.g., `"8760h"` for one year, `"720h"` for 30 days). The timer restarts on every external event processed for the participant. When the timer expires without new activity, Scrip fires a `tier_expiration` system event via an internal automation and applies the downgrade policy. ### Status Validity By default, a `PERIOD_BASED` tier's status expires exactly at the period boundary. The optional `status_validity` config extends that grant. | Field | Behavior | | --------------- | ----------------------------------------------------------------------------------------------------- | | `extend_months` | Months to keep status valid past the period end. `0` or omitted means status expires at the boundary. | A participant who qualifies during a `CALENDAR_YEAR` period holds status through December 31. With `"status_validity": {"extend_months": 1}`, their status stays valid through January 31 of the next year, giving them a month of overlap before re-evaluation applies the downgrade policy. ## Downgrade Policies When a tier expires or the qualification period ends, the downgrade policy determines the participant's new level. | Mode | Behavior | | -------------------- | --------------------------------------------------------------------------------------------------------------------- | | `DROP_TO_QUALIFYING` | Find the highest level where qualification criteria are currently met. If none qualify, the tier is removed entirely. | | `DROP_ONE` | Drop exactly one rank below the current level. | | `HOLD` | Keep the current tier indefinitely, regardless of qualification. | Most programs use `DROP_TO_QUALIFYING`. It re-evaluates the participant's counters at downgrade time and places them at the level they actually qualify for. Set `min_level` to establish a floor that the participant can never drop below, regardless of qualification. ## Grace Periods Set `grace_days` on the downgrade policy to defer downgrades. When a downgrade would normally occur, the tier is extended by the grace period instead. If the participant re-qualifies during the grace window, the downgrade is cancelled. If the grace period expires without re-qualification, the downgrade proceeds. ## Counter Rollover The `counters` config controls what happens to qualifying counters at the end of a qualification period. | Rollover | Behavior | | -------- | ---------------------------------------------------------------------------- | | `NONE` | Qualifying counters reset to 0 at period end | | `EXCESS` | Qualifying counters carry over the amount above the current tier's threshold | For example, if the Gold threshold is 2000 and a participant has 2500 at period end, `EXCESS` rollover sets the counter to 500 for the new period. The `qualifying` array lists which counter keys are affected by rollover. Counters not in this list are left unchanged. ## SET\_TIER Rule Action Rules can assign a tier directly using the `SET_TIER` action. This is useful for promotions, overrides, or tier logic that goes beyond counter thresholds. ```json theme={null} { "name": "VIP Override", "condition": "event.type == 'vip_granted'", "actions": [ {"type": "SET_TIER", "tier": "loyalty", "level": "platinum", "expiry": "8760h"} ] } ``` | Field | Required | Description | | -------- | -------- | ------------------------------------------------------------------------------------------------ | | `tier` | Yes | Tier type key (e.g., `"loyalty"`) | | `level` | Yes | Level key to assign (e.g., `"platinum"`) | | `expiry` | No | Duration (e.g., `"8760h"`) or RFC 3339 timestamp. Schedules automatic expiration. | | `target` | No | Defaults to the event's participant. Use `{"type": "GROUP", "group_id": "..."}` for group tiers. | When `expiry` is set, Scrip schedules a `tier_expiration` system event at that time. If the participant qualifies at expiration, they keep the tier. Otherwise, the downgrade policy applies. ## Tier State in CEL Tier state is available in rule conditions via `participant.tiers`: ```javascript theme={null} // Check current level participant.tiers.loyalty.level == "gold" // Check rank for tier comparisons participant.tiers.loyalty.rank >= 2 // Access benefits participant.tiers.loyalty.benefits.points_multiplier // Check if tier has an expiry participant.tiers.loyalty.expires != null ``` Each tier entry exposes: | Field | Type | Description | | ---------- | ------ | --------------------------------------------------------------- | | `level` | string | Current level key | | `rank` | int | Current level rank | | `benefits` | map | Benefits JSON from the level definition | | `acquired` | string | RFC 3339 timestamp of when the tier was achieved | | `expires` | string | RFC 3339 timestamp of when the tier expires (null if no expiry) | Group tiers are available via `groups[0].tiers`. ## Tier Transitions Every tier change is recorded as a transition with the previous level, new level, timestamp, and what triggered the change (event ID or rule ID). Query a participant's tier history: ```bash theme={null} GET /v1/participants/{id}/state/tiers/{key}/history?program_id=program-uuid ``` ## Viewing Tiers Inspect tier type definitions and participant tier state through the API. ```bash theme={null} # List tier types for a program GET /v1/programs/{programId}/tiers # Get a specific tier type with its levels GET /v1/programs/{programId}/tiers/{key} # List a participant's current tiers GET /v1/participants/{id}/state/tiers?program_id=program-uuid # Get a participant's specific tier GET /v1/participants/{id}/state/tiers/{key}?program_id=program-uuid # Update a tier type and its levels PATCH /v1/programs/{programId}/tiers/{key} ``` `PATCH` merges into the stored tier: fields you omit are left unchanged. To disable lifecycle automation on an existing tier, or remove a level's `qualification`, send an empty object (`{}`) explicitly. Sending `null` or omitting the field is a no-op. A populated object replaces the whole config rather than deep-merging it, so include every field you want to keep. ## Archiving Tiers Archiving ends a tier's lifecycle. Use it to retire a progression track you no longer want participants to enter. ```bash theme={null} DELETE /v1/programs/{programId}/tiers/{key} ``` Archiving is one-way. The response sets `status` to `ARCHIVED` and `archived_at` to the time of the call, and an archived tier cannot be returned to `ACTIVE`. A program route only archives a program-scoped tier; it does not archive an organization-level tier that a program inherits. An archived tier stops all new assignment and evaluation: * `SET_TIER` rule actions targeting the tier are skipped. The event still completes rather than failing. * Automatic qualification and period-end re-evaluation no longer assign the tier. * Manual assignment (`PUT`) and tier updates (`PATCH`) are rejected with `409 Conflict` and code `tier_archived`. Existing participant tier state is preserved as historical. Participants keep the level they hold, and the tier and its levels stay readable through `GET` requests. A pending `tier_expiration` for an already-granted assignment still runs to completion, since it finishes the lifecycle of existing state rather than creating a new assignment. Archiving a tier that is already archived returns `409 Conflict` with code `tier_archived`. # Transfers Source: https://docs.scrip.dev/guides/transfers Move funds between participants and groups within a program Transfers move existing funds from one participant or group to one or more recipients. No new value is created: the source balance decreases by the exact total credited to all recipients. You might use them for peer-to-peer gifting, marketplace payouts with platform fees, or distributing a pool across multiple participants. ## Creating a Transfer ```bash theme={null} POST /v1/transfers { "program_id": "program-uuid", "source_external_id": "alice", "asset_id": "asset-uuid", "description": "Gift to Bob", "recipients": [ {"external_id": "bob", "amount": "50"} ] } ``` | Field | Required | Description | | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `program_id` | Yes | Program context | | `asset_id` | Yes | Which asset to transfer | | `source_external_id` | One of | Participant sending funds (by your external ID). Mutually exclusive with `source_group_id` and `source_participant_id`. | | `source_participant_id` | One of | Participant sending funds (by Scrip UUID). Mutually exclusive with `source_external_id` and `source_group_id`. | | `source_group_id` | One of | Group sending funds. Mutually exclusive with `source_external_id` and `source_participant_id`. | | `recipients` | Yes | Array of 1-100 recipients, each with `external_id`, `participant_id`, or `group_id` and an `amount` | | `description` | Yes | Reason for the transfer (1-500 characters) | | `idempotency_key` | No | Prevents duplicate transfers on retry. Same key + different payload returns `409`. | The source's `available` balance is debited for the total amount, and each recipient's `available` balance is credited. The entire transfer is atomic: if any part fails, nothing moves. ## Multi-Recipient Transfers Split funds across multiple recipients in a single request: ```bash theme={null} POST /v1/transfers { "program_id": "program-uuid", "source_external_id": "alice", "asset_id": "asset-uuid", "description": "Marketplace payout with platform fee", "recipients": [ {"external_id": "bob", "amount": "95"}, {"external_id": "platform_account", "amount": "5"} ] } ``` Up to 100 recipients per transfer. This covers marketplace payouts with platform fees, prize splits, revenue sharing, and similar distributions. ## Identifying Participants and Groups The source and recipients can be identified by external ID, Scrip UUID, or group ID. Each identifier is mutually exclusive. Provide exactly one per source or recipient. For the source, use `source_external_id`, `source_participant_id`, or `source_group_id`. For recipients, use `external_id`, `participant_id`, or `group_id` on each entry. ```bash theme={null} POST /v1/transfers { "program_id": "program-uuid", "source_group_id": "team-pool-uuid", "asset_id": "asset-uuid", "description": "Team bonus distribution", "recipients": [ {"external_id": "alice", "amount": "200"}, {"external_id": "bob", "amount": "200"}, {"group_id": "reserve-fund-uuid", "amount": "100"} ] } ``` ## LOT-Mode Assets For `LOT`-mode assets, transfers consume lots from the source in FIFO order and create new lots for each recipient. The new lots get fresh `created_at` timestamps, so their age resets to zero. Source lot expiration dates do not carry over to the recipient. ## Response The response includes a `journal_entry_id` for tracing the transfer in the ledger, along with computed totals. ```json theme={null} { "journal_entry_id": "uuid", "asset_id": "asset-uuid", "source_id": "uuid", "total_amount": "300.50", "recipient_count": 3 } ``` ## Transfer vs. Credit vs. Adjust | Operation | What it does | Use case | | ---------------------- | ------------------------------------------------ | -------------------------------- | | Transfer | Moves existing funds from source to recipients | P2P gifting, marketplace payouts | | `CREDIT` (rule action) | Creates new funds (or draws from program wallet) | Rewards, bonuses, earning points | | Adjust (API) | Manual credit or debit on a single participant | Customer service, corrections | ## Requirements * All recipients must be `ACTIVE`. The source can be `ACTIVE` or `CLOSED` (allowing fund recovery from closed accounts). * Program must be `ACTIVE` (not `SUSPENDED` or `ARCHIVED`) * Source must have sufficient `available` balance for the total transfer amount. If insufficient, the entire transfer rolls back. * Source and recipient cannot be the same entity * Idempotency keys are scoped per program. Replaying a request with the same key and parameters returns the original result. Replaying with the same key but different parameters returns a 409 conflict. # Webhooks Source: https://docs.scrip.dev/guides/webhooks Real-time HTTP callbacks for balance changes, redemptions, and more Webhooks push HTTP notifications to your server when key events happen: balance changes, redemptions, tier transitions, and more. Instead of polling the API for changes, you register a URL and Scrip delivers signed payloads the moment each event occurs. ## Creating an Endpoint Register a URL and specify which events you want to receive: ```bash theme={null} curl -X POST https://api.scrip.dev/v1/webhook-endpoints \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/scrip", "description": "Production receiver", "enabled_events": ["balance.credited", "redemption.completed"] }' ``` The response includes a `secret` starting with `whsec_`. Store it immediately. It cannot be retrieved later. You'll use it to [verify signatures](#signature-verification). ```json theme={null} { "id": "ep_f47ac10b-...", "url": "https://example.com/webhooks/scrip", "secret": "whsec_a1b2c3d4e5f6...", "enabled_events": ["balance.credited", "redemption.completed"], "status": "ACTIVE", "created_at": "2026-01-15T10:30:00Z", "updated_at": "2026-01-15T10:30:00Z" } ``` Use `"enabled_events": ["*"]` to subscribe to all event types. URLs must use HTTPS with a publicly resolvable hostname. Private IPs, `localhost`, `.local`, and `.internal` domains are rejected. ## Event Types | Event | Description | | -------------------------- | -------------------------------------------------------- | | `balance.credited` | Balance increased via rule action, API credit, or refund | | `balance.debited` | Balance decreased via rule action or API debit | | `balance.expired` | Lots expired and auto-forfeited | | `balance.held` | Balance moved from AVAILABLE to HELD | | `balance.released` | Balance moved from HELD back to AVAILABLE | | `balance.voided` | Provisionally issued HELD lots cancelled via void-hold | | `redemption.completed` | Amount or catalog item redeemed | | `redemption.reversed` | Redemption fully or partially reversed | | `transfer.completed` | Transfer between participants or groups completed | | `event.completed` | Event processing succeeded (all rules evaluated) | | `event.failed` | Event processing or validation failed | | `participant.created` | New participant enrolled | | `participant.tier_changed` | Tier level upgraded, downgraded, or removed | | `program.funded` | Program wallet funded | | `program.burned` | Program wallet balance burned | ## Payload Format Every delivery sends a JSON envelope: ```json theme={null} { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "type": "balance.credited", "api_version": "2026-03-01", "created_at": "2026-01-15T10:30:00Z", "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "data": { "journal_entry_id": "...", "organization_id": "...", "program_id": "...", "participant_id": "...", "asset_id": "...", "amount": "100.00", "bucket": "AVAILABLE" } } ``` | Field | Description | | ----------------- | ------------------------------------------------------------------------ | | `id` | Webhook event ID (UUID). Same across all endpoints receiving this event. | | `type` | The event type string. | | `api_version` | API version at time of emission. Currently `2026-03-01`. | | `created_at` | When the event was created (RFC 3339). | | `organization_id` | Organization that owns the event. | | `data` | Event-specific payload. | ### Event Payloads **`balance.credited` / `balance.debited`** When the target is a participant: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "participant_id": "uuid", "asset_id": "uuid", "amount": "100.00", "bucket": "AVAILABLE", "reference_id": "auth_12345", "settle": {"held_amount": "80.00", "delta": "20.00"} } ``` When the target is a group: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "group_id": "uuid", "asset_id": "uuid", "amount": "100.00", "bucket": "AVAILABLE", "reference_id": "auth_12345", "settle": {"held_amount": "80.00", "delta": "20.00"} } ``` Exactly one of `participant_id` or `group_id` is present, depending on whether the target is a participant or a group. `reference_id` is present when the credit or settle was correlated to a hold. `settle` is present only for settle operations (credit with `reference_id` to AVAILABLE) and contains `held_amount` (total previously held) and `delta` (settle minus held; positive = over-capture, negative = under-capture). **`balance.expired`** When the target is a participant: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "participant_id": "uuid", "asset_id": "uuid", "lot_count": 3 } ``` When the target is a group: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "group_id": "uuid", "asset_id": "uuid", "lot_count": 3 } ``` Exactly one of `participant_id` or `group_id` is present, depending on whether the target is a participant or a group. **`balance.held` / `balance.released`** When the target is a participant: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "participant_id": "uuid", "asset_id": "uuid", "amount": "100.00" } ``` When the target is a group: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "group_id": "uuid", "asset_id": "uuid", "amount": "100.00" } ``` Exactly one of `participant_id` or `group_id` is present, depending on whether the target is a participant or a group. **`balance.voided`** ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "participant_id": "uuid", "asset_id": "uuid", "amount": "100.00", "reference_id": "auth_12345" } ``` **`transfer.completed`** When the source is a participant: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "source_participant_id": "uuid", "asset_id": "uuid", "recipients": [ { "participant_id": "uuid", "amount": "50.00" } ] } ``` When the source is a group: ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "source_group_id": "uuid", "asset_id": "uuid", "recipients": [ { "group_id": "uuid", "amount": "50.00" } ] } ``` The source uses exactly one of `source_participant_id` or `source_group_id`. Each entry in `recipients` contains exactly one of `participant_id` or `group_id`, along with the `amount` transferred to that recipient. Source and recipient types may differ (e.g., a participant can transfer to a group). **`redemption.completed`** (amount redemption) ```json theme={null} { "redemption_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "participant_id": "uuid", "asset_id": "uuid", "amount": "50.00" } ``` **`redemption.completed`** (catalog item redemption) ```json theme={null} { "redemption_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "participant_id": "uuid", "reward_catalog_item_id": "uuid" } ``` **`redemption.reversed`** ```json theme={null} { "reversal_id": "uuid", "redemption_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "participant_id": "uuid", "asset_id": "uuid", "amount": "50.00" } ``` **`participant.created`** ```json theme={null} { "participant_id": "uuid", "organization_id": "uuid", "external_user_id": "user_123" } ``` **`participant.tier_changed`** When the target is a participant: ```json theme={null} { "participant_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "tier": "loyalty", "level": "gold" } ``` When the target is a group: ```json theme={null} { "group_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "tier": "loyalty", "level": "gold" } ``` When the target is a program: ```json theme={null} { "program_id": "uuid", "organization_id": "uuid", "tier": "loyalty", "level": "gold" } ``` `level` is `null` when a tier is removed (downgrade to base level). Exactly one of `participant_id`, `group_id`, or `program_id` is present, depending on the target of the tier change. The `SET_TIER` rule action supports cross-targeting via the `target` field, so consumers should not assume this is always a participant. **`program.funded` / `program.burned`** ```json theme={null} { "journal_entry_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "asset_id": "uuid", "amount": "1000.00" } ``` **`event.completed`** ```json theme={null} { "event_id": "uuid", "organization_id": "uuid", "program_id": "uuid" } ``` **`event.failed`** ```json theme={null} { "event_id": "uuid", "organization_id": "uuid", "program_id": "uuid", "error": "description of what went wrong" } ``` ## Signature Verification Every delivery includes a `Scrip-Signature` header so you can verify it came from Scrip and wasn't tampered with. ### Header Format ``` Scrip-Signature: t=1706090400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9 ``` | Component | Description | | --------- | --------------------------------------------------------- | | `t` | Unix timestamp (seconds) when the signature was generated | | `v1` | Hex-encoded HMAC-SHA256 signature | ### Verification Steps Parse the `t` and `v1` values from the `Scrip-Signature` header. Concatenate the timestamp, a literal dot, and the raw request body: `{t}.{raw_body}` Calculate `HMAC-SHA256(your_endpoint_secret, signed_payload)` and hex-encode the result. Use constant-time comparison. Reject the request if they don't match. Reject if `abs(now - t)` exceeds your tolerance. We recommend 5 minutes. ### Example (Python) ```python theme={null} import hmac, hashlib, time def verify_webhook(header, secret, body): # Parse header parts = dict(p.split("=", 1) for p in header.split(",")) timestamp = parts["t"] signature = parts["v1"] # Construct signed payload signed_payload = f"{timestamp}.{body}" # Compute expected signature expected = hmac.new( secret.encode(), signed_payload.encode(), hashlib.sha256 ).hexdigest() # Constant-time compare if not hmac.compare_digest(signature, expected): raise ValueError("Invalid signature") # Replay protection if abs(time.time() - int(timestamp)) > 300: raise ValueError("Timestamp too old") ``` ### Example (Node.js) ```javascript theme={null} const crypto = require("crypto"); function verifyWebhook(header, secret, body) { const parts = Object.fromEntries( header.split(",").map((p) => p.split("=", 2)) ); const { t: timestamp, v1: signature } = parts; const signedPayload = `${timestamp}.${body}`; const expected = crypto .createHmac("sha256", secret) .update(signedPayload) .digest("hex"); const valid = crypto.timingSafeEqual( Buffer.from(signature, "hex"), Buffer.from(expected, "hex") ); if (!valid) throw new Error("Invalid signature"); if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("Timestamp too old"); } ``` ## Retry Policy If your endpoint doesn't return a 2xx response, Scrip retries with exponential backoff: | Attempt | Delay | Cumulative | | ------- | --------- | ---------- | | 1 | Immediate | 0 | | 2 | 5 min | 5 min | | 3 | 10 min | 15 min | | 4 | 20 min | 35 min | | 5 | 40 min | 1h 15m | | 6 | 80 min | 2h 35m | | 7 | 160 min | 5h 15m | | 8 | 320 min | 10h 35m | After 8 attempts (\~10.5 hours), the delivery is marked `FAILED`. You can manually retry failed deliveries via the API. ### Response Handling | Your Response | Scrip's Behavior | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | **2xx** | Marked `DELIVERED` | | **429** (rate limited) | Retried on the backoff schedule. Does not count toward the endpoint's failure rate. Rate-limited endpoints are retried without risk of auto-disable. | | **4xx** (except 429) | Marked `FAILED` immediately. No retry. | | **5xx** | Retried on the backoff schedule | | **Network error / timeout** | Retried on the backoff schedule | Return a 2xx quickly (within 30 seconds). Process the payload asynchronously if your handler needs more time. The worker enforces a 30-second timeout per delivery attempt. ## Managing Endpoints ### Disable and Re-enable Temporarily stop deliveries without deleting the endpoint: ```bash theme={null} curl -X PATCH https://api.scrip.dev/v1/webhook-endpoints/{id} \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "DISABLED"}' ``` Set `status` back to `ACTIVE` to resume. Events that occurred while disabled are not retroactively delivered. ### Rotate Secret If a secret is compromised, rotate it immediately: ```bash theme={null} curl -X POST https://api.scrip.dev/v1/webhook-endpoints/{id}/rotate-secret \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` The old secret is invalidated immediately. Update your verification code with the new secret before any in-flight deliveries arrive. ### Delete Deleting an endpoint archives it. It stops receiving deliveries and is removed from list results: ```bash theme={null} curl -X DELETE https://api.scrip.dev/v1/webhook-endpoints/{id} \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` ## Endpoint Health Scrip monitors delivery success rates per endpoint. Endpoints with sustained delivery failures are automatically set to `DISABLED`. Pending deliveries for a disabled endpoint are marked `FAILED`. To re-enable an endpoint after resolving the underlying issue: ```bash theme={null} curl -X PATCH https://api.scrip.dev/v1/webhook-endpoints/{id} \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "ACTIVE"}' ``` HTTP `429` responses do not count toward the failure rate. Rate-limited endpoints are retried on the normal backoff schedule without risk of auto-disable. ## Debugging Deliveries ### List Deliveries for an Endpoint ```bash theme={null} curl https://api.scrip.dev/v1/webhook-endpoints/{id}/deliveries?status=FAILED \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` ### Inspect a Delivery The detail endpoint includes `last_response_status`, `last_response_body` (truncated to 4 KB), and `last_error`: ```bash theme={null} curl https://api.scrip.dev/v1/webhook-deliveries/{id} \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` ### Retry a Failed Delivery Reset a `FAILED` delivery back to `PENDING` for a fresh set of 8 attempts: ```bash theme={null} curl -X POST https://api.scrip.dev/v1/webhook-deliveries/{id}/retry \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` ## Best Practices | Practice | Rationale | | ---------------------------------- | ----------------------------------------------------------------------------------- | | Verify signatures on every request | Prevents spoofed deliveries | | Return 2xx quickly, process async | Avoids timeouts and unnecessary retries | | Use `*` sparingly | Subscribe only to events you need to reduce noise | | Handle duplicates idempotently | Network retries can deliver the same event more than once. Use `id` to deduplicate. | | Monitor failed deliveries | Check delivery status periodically or alert on consecutive failures | ## Delivery Guarantees Webhook events are created atomically with their domain operations. If the underlying transaction rolls back, no webhook is emitted. Retried domain operations (like event reprocessing) do not produce duplicate webhooks. Delivery is **at-least-once**: a single event may be delivered more than once if your endpoint returns a 2xx but the acknowledgment is lost in transit. Design your handler to be idempotent using the envelope's `id` field to detect duplicates. # Writing Rules Source: https://docs.scrip.dev/guides/writing-rules Define when and how participants earn and spend Rules are the core logic layer in Scrip. Each rule is a condition/action pair: if the condition matches, the actions execute. ## Rule Structure ```json theme={null} { "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](/guides/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](#budgets). | | `status` | No | `ACTIVE` (default) or `SUSPENDED` | ## Conditions Conditions are written in [CEL (Common Expression Language)](https://github.com/google/cel-spec), a simple expression language for evaluating boolean conditions against event data and participant state. ```javascript theme={null} // 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](/guides/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: ```json theme={null} { "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"} ] } ``` ```json theme={null} { "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: ```json theme={null} { "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: ```json theme={null} { "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. A budget is a single shared pool, not a per-participant allowance. The `limit` applies to total issuance for the rule across every participant in the program. A `10000` point budget means the rule issues 10,000 points combined to all participants, not 10,000 per participant. Consumption is tracked per `(rule, asset)`, so each entry in the `budgets` array is one program-wide cap. To cap how much an individual participant can earn, don't use a budget. Instead, track the participant's earnings in a counter and gate the rule on it. Add a `COUNTER` action that increments a per-participant counter by the credited amount, then guard the rule with a condition that checks the counter against the cap: ```json theme={null} { "name": "Referral Bonus (500 point lifetime cap per participant)", "condition": "event.type == 'referral' && get(participant.counters, 'referral_points', 0.0) < 500.0", "actions": [ {"type": "CREDIT", "asset_id": "points-uuid", "amount": "100"}, {"type": "COUNTER", "key": "referral_points", "value": "100"} ] } ``` Here a participant stops earning referral points once their `referral_points` counter reaches 500, regardless of how many other participants the rule has paid out. A budget and a counter cap are independent: combine both to cap per-participant earning and total program spend at the same time. Budgets are defined inline on the rule as an array of per-asset limits: ```json theme={null} { "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. ```json theme={null} {"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. ```json theme={null} {"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. ```json theme={null} {"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 and added to a single `consumed` total shared by all participants. 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, even when different participants trigger the rule at the same time. The response for any rule includes the current budget state: ```json theme={null} { "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: ```bash theme={null} 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. ```bash theme={null} 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: ```bash theme={null} PATCH /v1/rules/{id} {"budgets": []} ``` ## State Snapshot Evaluation Behavior When an event is processed, Scrip snapshots participant state, program state, and group state at the start. All rule conditions and dynamic action expressions within that event evaluate against this snapshot, not the live database. That means if Rule A increments a counter, adds a tag, sets an attribute, or assigns a tier, Rule B still sees the original value even though it evaluates after Rule A. This applies to counters, tags, attributes, tiers, program state, and group state. It also applies to dynamic action expressions: `CREDIT` / `DEBIT` / `HOLD` / `RELEASE` / `FORFEIT` amounts, `COUNTER` values, `SET_ATTRIBUTE` values, and `reference_id` expressions all use the same event-start snapshot. State actions write durable updates during processing, but those updates become inputs for future events, not for later rules (or later action expressions) in the same event. ### 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 The same holds for other state types. If Rule A fires a `TAG` action that adds `vip`, a Rule B condition that checks `"vip" in participant.tags` still evaluates against the pre-event tag set. The tag is visible on the next event. ### Threshold Crossing Pattern To detect when a counter crosses a threshold during an event, check the pre-event value plus the event amount: ```javascript theme={null} // 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 ``` For tag, attribute, or tier dependencies within the same event, put the dependent action in the same rule or trigger it on a later event (for example, with [`SCHEDULE_EVENT`](/guides/rule-actions#schedule_event)). See [CEL Expressions](/guides/cel-expressions#common-patterns) 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: ```bash theme={null} 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: ```bash theme={null} 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.). # Introduction Source: https://docs.scrip.dev/introduction Rewards infrastructure API backed by a double-entry ledger. Send events, define earn rules, and operate incentive programs without building the accounting. Scrip is an API for building loyalty and rewards programs. You send events from your application, define rules that control when and how participants earn, and Scrip tracks every balance change in a double-entry ledger. Most teams start loyalty programs with a few columns in their application database. That works until you need expiration, vesting, promotional windows, refund handling, hold/release flows, or an audit trail. At that point you're building something that looks a lot like a financial system. Scrip gives you two building blocks for this: * A **rules engine** that evaluates conditions against incoming events and participant state. Conditions are written in [CEL](/guides/cel-expressions) (Common Expression Language), a lightweight expression syntax. When they match, actions fire: credits, debits, holds, tags, counter increments, and more. Logic lives in configuration, not application code. * A **double-entry ledger** that records every balance change as a journal entry. Every credit to a participant has a corresponding debit from a source (like a program wallet). Nothing is mutated in place, so you always have a complete audit trail. ## How it works 1. Your app sends an **event** (a purchase, a signup, a referral) with whatever data your rules need. 2. The engine evaluates every active **rule** in the program against the event and the participant's current state. 3. When a condition matches, the rule's **actions** fire: credit points, debit a balance, set a tag, increment a counter. 4. Every balance change is recorded in the **ledger** as an immutable journal entry. Events process asynchronously. The API confirms receipt, and a worker handles evaluation and execution. Processing is idempotent: retrying the same event with the same `idempotency_key` produces the same result, so retries are always safe. ## Next Steps Set up a program and process your first event. Programs, assets, rules, and the ledger. Copy-paste rule recipes for category multipliers, sign-up bonuses, streaks, referrals, and more. # Quickstart Source: https://docs.scrip.dev/quickstart Create a program, configure an asset and rule, send an event, and verify the resulting balance This guide will help you set up a basic "Purchase Reward" program. By the end, you'll have sent an event and seen a user's balance increase automatically. ## 1. Set up your environment Grab your API key from the Scrip dashboard and set it as an environment variable: ```bash theme={null} export SCRIP_API_KEY="sk_your_api_key" ``` ## 2. Create your Program & Asset A **Program** is the top-level container for your rules and participants. An **Asset** is the unit of value you're tracking (points, credits, etc.). First create a program, then create an asset inside it. ```bash theme={null} # Create the Program curl -X POST https://api.scrip.dev/v1/programs \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Customer Loyalty"}' ``` Copy the `program_id` from the response. You'll use it in every subsequent call. ```bash theme={null} # Create a 'Points' asset inside that program curl -X POST https://api.scrip.dev/v1/assets \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "program_id": "YOUR_PROGRAM_ID", "name": "Points", "symbol": "PTS", "inventory_mode": "SIMPLE", "issuance_policy": "UNLIMITED", "scale": 0 }' ``` `scale` controls decimal precision. `0` means whole numbers only (10 points, not 10.5). Copy the `asset_id` from this response for the next step. ## 3. Define a Rule Rules tell Scrip *when* to give out points. We'll create a rule that gives **10 points** for every event where the `type` is `"purchase"`. ```bash theme={null} curl -X POST https://api.scrip.dev/v1/rules \ -H "Authorization: Bearer $SCRIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "program_id": "YOUR_PROGRAM_ID", "name": "10 Points per Purchase", "condition": "event.type == \"purchase\"", "actions": [ { "type": "CREDIT", "asset_id": "YOUR_ASSET_ID", "amount": "10" } ] }' ``` ## 4. Send an Event Send a purchase event for a user. The `external_id` is whatever ID you use for this user in your own system. You don't need to create the participant first. Scrip creates and enrolls them automatically when their first event arrives. ```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": "YOUR_PROGRAM_ID", "external_id": "user_123", "idempotency_key": "first-purchase-001", "event_data": { "type": "purchase", "amount": 49.99 } }' ``` The `idempotency_key` prevents duplicate processing. If you retry this request with the same key, Scrip returns the original response instead of crediting points again. ## 5. Verify the Balance Events process asynchronously, so give it a moment. Then check `user_123`'s balance. First, look up the participant by their `external_id` to get the Scrip-assigned `id`: ```bash theme={null} curl https://api.scrip.dev/v1/participants?external_id=user_123 \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` Then use that `id` to fetch their balances: ```bash theme={null} curl https://api.scrip.dev/v1/participants/PARTICIPANT_UUID/balances \ -H "Authorization: Bearer $SCRIP_API_KEY" ``` You should see 10 points. The rule matched the purchase event, credited the participant, and recorded the transaction in the ledger. ## Next Steps Now that you've seen the core loop, dive deeper: * **[Writing Rules](/guides/writing-rules)**: Use CEL to build complex logic (e.g., "double points for VIPs"). * **[State Management](/guides/state-management)**: Use counters and tags to track user progress over time. * **[Redemptions](/guides/redemptions)**: Let users spend those points on rewards.