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

# 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.<key>.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 accepts the Go units `s`, `m`, and `h` plus `d` (days) and `w` (weeks), combinable as in `"1w2d12h"`. `"365d"` and `"8760h"` are equivalent. Note that `m` means minutes, not months. 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 one rank below the current level. If the participant still qualifies at or above their current level, they keep that qualifying level instead. |
| `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", "expires_at": "365d"}
  ]
}
```

| Field        | Required | Description                                                                                                              |
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `tier`       | Yes      | Tier type key (e.g., `"loyalty"`)                                                                                        |
| `level`      | Yes      | Level key to assign (e.g., `"platinum"`)                                                                                 |
| `expires_at` | No       | Duration (e.g., `"365d"`) or RFC 3339 timestamp. Schedules automatic expiration. `expiry` is accepted as a legacy alias. |
| `target`     | No       | Defaults to the event's participant. Use `{"type": "GROUP", "id": "..."}` for group tiers.                               |

When `expires_at` 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

// Age math requires timestamp() because acquired/expires are strings
participant.tiers.loyalty.acquired != null &&
duration_hours(now - timestamp(participant.tiers.loyalty.acquired)) > 720
```

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 on each group entry. Prefer `groups.exists(g, g.tiers.loyalty.level == "gold")` when participants may belong to more than one group.

## Tier Transitions

Every tier change is recorded as a transition with the previous level, new level, timestamp, and what triggered the change. Query a tier's transition history:

```bash theme={null}
GET /v1/programs/{programId}/tiers/{key}/transitions
```

The endpoint is scoped to the tier, not to a single participant. It returns level changes across all participants in the program, newest first, with a 24-hour transition count in `meta.count_24h`. Each record includes the `participant_id` and `external_id`, `from_level` and `to_level` (absent for no prior level or a downgrade to base), a `trigger_kind` of `RULE`, `AUTO`, `SYSTEM`, or `MANUAL`, and the `rule_id` or `event_id` that caused the change.

For a single participant's tier changes, use their state history filtered to tier entries:

```bash theme={null}
GET /v1/participants/{id}/activity/state-history?state_type=tier
```

## 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.

A tier cannot be archived while any `ACTIVE` or `SUSPENDED` rule still references its key, in a `SET_TIER` action or in a CEL condition. The request returns `409 Conflict` with code `tier_referenced_by_rules`, and the error details list the referencing rules so you can archive or update them first. This prevents silently breaking rules: CEL tier accessors default missing keys rather than erroring, so a condition referencing an archived tier would quietly stop matching with no signal.

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`.
