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

# Loyalty program model: points, tiers, and the CDP ledger

> How the points-and-tiers loyalty program works as a CDP-native layer: balances projected over the event stream, event-sourced program revisions, lifetime-points tier ladders, overspend-safe redemption, and the traits segments and journeys branch on.

# Loyalty program model

Loyalty in Orbit is a points-and-tiers program computed on top of the events you already send to the [CDP](/concepts/cdp-event-model). There is no separate loyalty ledger to provision, backfill, or sync — the CDP event stream **is** the ledger, and every balance and tier you read is a projection over it. This page explains that model: where points come from, how program configuration is versioned, how tiers climb, and how redemptions stay safe under concurrency. For the setup workflow, see the [Loyalty program guide](/guides/loyalty-program); for the per-endpoint contract, see the [Loyalty API reference](/api-reference/loyalty).

## The mental model: the CDP event stream is the ledger

Most loyalty vendors ship a dedicated points ledger — a table you maintain beside your customer data, kept in sync by a batch job. Orbit takes the event-sourced approach instead: a contact's points accrue from their own behavioral events (an `order-completed` track earns points per the program's earn rules), burn via reserved `loyalty.points_redeemed` events, and every read recomputes the balance from the full event history at the moment you ask.

Consequences of that design:

* **No drift.** Backfills, imports, and webhook-driven events feed the program the moment they land. There is nothing to re-sync.
* **A full audit trail.** Every earn, burn, and operator adjustment is an immutable event with a timestamp and a payload, inspectable in the contact's event timeline.
* **Zero new tables.** A workspace's loyalty data occupies no storage beyond the event stream it already owns.

One refinement matters: points are not a bare running sum. Each earn creates a **lot** that expires `pointsExpiryDays` after it was earned (when the program sets an expiry window), and redemptions consume the oldest live lots first — the standard FIFO expiry model.

## Accrual: earn rules map event names onto points

An earn rule names a CDP `track` event and one of two shapes:

| Rule type  | Inputs                                                     | Points granted                                                                                                                                |
| ---------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `perEvent` | `eventName`, `points`                                      | A flat `points` grant every time the event fires (e.g. 50 points for `review.submitted`)                                                      |
| `perUnit`  | `eventName`, `field`, `pointsPerUnit`, optional `rounding` | `properties[field] × pointsPerUnit`, rounded with `floor` (default), `round`, or `ceil` (e.g. 1 point per \$1 of `order-completed`'s `total`) |

A `perUnit` rule ignores events whose named property is missing or non-numeric — a malformed order grants nothing rather than granting garbage. A program carries up to 100 rules, and the same event name can appear under several rules (both a flat attendance bonus and a per-dollar multiplier on `order-completed`, say).

Operator adjustments are events too: a manual credit appends a `loyalty.points_adjusted` event, which behaves exactly like an earn — it opens its own FIFO lot, ages under the same expiry window, and counts toward tier.

## Program configuration: event-sourced revisions

Program configuration follows the same event-sourced discipline. You never mutate a stored row; every create, update, or reset appends one revision event (`loyalty.program_configured`) under a workspace-level sentinel, and the **active program is the latest non-tombstone revision**. Resetting the program appends a tombstone, which returns the workspace to the built-in default without touching a single point of member history — the ledger and the config are separate event families sharing one store.

If you never author a program, Orbit still computes balances against a sensible default: a sign-up bonus, points per dollar on orders, a review bonus, and a four-tier ladder with a 365-day expiry.

Because a program body fully replaces the previous revision, an update is a new version, not a patch — always `PUT` the complete program, not just the rule you changed.

## Tiers: a one-way ladder on lifetime points

Tiers are an ordered ladder keyed on **lifetime earned points** — the sum of everything a contact has ever earned, never reduced by spending. A program must contain a tier at threshold `0` so every member holds a current tier from their first event.

The spendable-balance / lifetime-points split is deliberate: redemption drains the balance but never the lifetime total, so climbing a tier is **one-way** — a member who redeems their entire balance keeps their tier and their progress toward the next one. Tier evaluation returns the current tier, the next threshold, and progress toward it, which is what the dashboard ladder and wallet-pass label render.

## Redemption: overspend-safe burns under an advisory lock

A redemption (or an operator debit) runs inside a transaction guarded by a **per-contact advisory lock**. The transaction takes the lock for that one contact, recomputes the balance from the event stream, validates the spend, and appends a single `loyalty.points_redeemed` event — or rolls the whole thing back. Two concurrent redemption requests for the same contact therefore serialize; the classic "both requests read the same balance and both spend it" race cannot overspend a shared balance. A spend that exceeds the available balance is rejected with a conflict, and the API tells you the available and requested amounts.

`POST /members/:contactId/redeem` accepts an optional `reward` label (a SKU or reward name) recorded on the redemption event, so downstream fulfillment can see what the customer burned points on.

## Where redemption meets incentives

The ledger knows points moved; paying out the reward is a separate concern. Spending points against a loyalty card on a [Wallet Pass](/channels/wallet-passes) routes through the [incentives fulfillment engine](/concepts/incentives) — the same primitive behind referral and survey rewards — which mints a discount code, calls a connected gift-card network, or records a manual payout for your team to fulfill. Wallet passes carry the program; incentives pay out what the program owes.

## The trait surface for segments and journeys

Every computed projection ships as contact traits, so points and tiers are first-class inputs to targeting:

| Trait                              | Holds                                                     |
| ---------------------------------- | --------------------------------------------------------- |
| `loyalty_points_balance`           | Spendable balance right now (unexpired earns minus burns) |
| `loyalty_lifetime_points`          | Lifetime earned total; drives tier                        |
| `loyalty_tier` / `loyalty_tier_id` | Current tier name and id                                  |

Segment conditions like `loyalty_points_balance >= 500` or `loyalty_tier == 'vip'` read these traits, and journeys can branch on them — nudge members nearing a threshold, suppress discount offers to top-tier holders, or trigger a "points expiring soon" nudge within the program's expiry window.

## Worked example: earn, preview, redeem

1. A customer's `order-completed` event lands with `properties.total = 129.00`. The `perUnit` rule from the example above grants 129 points (plus any flat bonuses other rules attach to the same event).
2. `GET /members/:contactId` recomputes the projection: balance up by 129, lifetime up by 129, tier re-evaluated.
3. Before adopting a tuned program, dry-run it with `POST /preview`: the endpoint simulates your candidate config against sample events and returns what balances and tiers would look like — pure computation, nothing persisted.
4. The customer spends 500 points: `POST /members/:contactId/redeem` takes the per-contact lock, verifies 500 ≤ available, and appends the burn. Balance drops by 500; lifetime and tier do not move.

## Related reading

* [Loyalty program setup guide](/guides/loyalty-program) — the step-by-step operator workflow.
* [Incentives: catalog, ledger, and fulfillment](/concepts/incentives) — the engine redemptions route through.
* [The CDP event model](/concepts/cdp-event-model) — the stream this ledger projects over.
* [Wallet Passes](/channels/wallet-passes) — the carrier for loyalty cards on Apple and Google Wallet.
* [Loyalty API reference](/api-reference/loyalty) — the per-endpoint contract.
