> ## 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 API: points, tiers, and redemption endpoints

> Points-and-tiers loyalty program built on your CDP event stream. Configure earn rules and tiers, then read a balance or redeem points for any contact.

# Loyalty API

Loyalty is a points-and-tiers rewards program computed on top of the events you're already sending to the [Customer Data Platform](/api-reference/endpoints/cdp) — there's no separate ledger table to sync. Points accrue from a contact's behavioral CDP events per rules you define (e.g. `100 pts` for `account.created`, `1 pt` per `$1` of `order-completed`), points burn through redemptions, and a contact's balance + tier are recomputed on demand from that event history.

**Base path:** `/api/v1/loyalty`

**Authentication:** API key (`X-API-Key`) or session JWT. Reads require `contacts:read`; writes require `contacts:write`.

| Method   | Path                                         | Purpose                                                |
| -------- | -------------------------------------------- | ------------------------------------------------------ |
| `GET`    | `/api/v1/loyalty/program`                    | Get the active program config                          |
| `POST`   | `/api/v1/loyalty/program`                    | Create / replace the program config (201)              |
| `PUT`    | `/api/v1/loyalty/program`                    | Update the active program config                       |
| `DELETE` | `/api/v1/loyalty/program`                    | Reset to the default program (204, empty body)         |
| `POST`   | `/api/v1/loyalty/preview`                    | Simulate a program against sample events (no DB write) |
| `GET`    | `/api/v1/loyalty/members`                    | Paginated list of contacts with loyalty activity       |
| `GET`    | `/api/v1/loyalty/members/{contactId}`        | One contact's balance, tier, and traits                |
| `POST`   | `/api/v1/loyalty/members/{contactId}/redeem` | Debit points (a redemption)                            |
| `POST`   | `/api/v1/loyalty/members/{contactId}/adjust` | Operator credit or debit adjustment                    |

## Program config

A program has **earn rules** and **tiers**:

* **`perEvent` rule** — a flat point grant every time a named event fires (e.g. `50` points on `review.submitted`).
* **`perUnit` rule** — `points = value_in_event_property × pointsPerUnit`, rounded per the rule's `rounding` mode (e.g. `1` point per `$1` of an order's `total`).
* **Tiers** — an ordered ladder (`Bronze` → `Platinum`), each with a lifetime-points `threshold` and operator-authored `benefits` copy. The base tier's threshold is `0` so every contact always has a current tier.
* **`pointsExpiryDays`** — optional; when set, points lapse this many days after they're earned. Omit for points that never expire.

A tenant that hasn't configured its own program still gets a sensible default: a welcome bonus, points-per-dollar-spent, a review bonus, and a four-tier Bronze→Platinum ladder with a 365-day expiry.

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/loyalty/program \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "VIP Rewards",
    "earnRules": [
      { "id": "signup", "label": "Sign-up bonus", "type": "perEvent", "eventName": "account.created", "points": 100 },
      { "id": "spend", "label": "$1 = 1 point", "type": "perUnit", "eventName": "order-completed", "field": "total", "pointsPerUnit": 1, "rounding": "floor" }
    ],
    "tiers": [
      { "id": "member", "name": "Member", "threshold": 0, "benefits": ["1x points"] },
      { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] }
    ],
    "pointsExpiryDays": 365
  }'
```

```javascript Node theme={null}
// The Node SDK is not published to a registry yet — use dependency-free fetch (Node 18+).
const res = await fetch('https://api.orbit.devotel.io/api/v1/loyalty/program', {
  method: 'PUT',
  headers: {
    'X-API-Key': process.env.ORBIT_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'VIP Rewards',
    earnRules: [
      { id: 'signup', label: 'Sign-up bonus', type: 'perEvent', eventName: 'account.created', points: 100 },
      { id: 'spend', label: '$1 = 1 point', type: 'perUnit', eventName: 'order-completed', field: 'total', pointsPerUnit: 1, rounding: 'floor' },
    ],
    tiers: [
      { id: 'member', name: 'Member', threshold: 0, benefits: ['1x points'] },
      { id: 'vip', name: 'VIP', threshold: 1000, benefits: ['2x points', 'Free shipping'] },
    ],
    pointsExpiryDays: 365,
  }),
});
console.log(await res.json());
```

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "program": {
        "id": "loy_prog_01",
        "name": "VIP Rewards",
        "earnRules": [
          { "id": "signup", "label": "Sign-up bonus", "type": "perEvent", "eventName": "account.created", "points": 100 },
          { "id": "spend", "label": "$1 = 1 point", "type": "perUnit", "eventName": "order-completed", "field": "total", "pointsPerUnit": 1, "rounding": "floor" }
        ],
        "tiers": [
          { "id": "member", "name": "Member", "threshold": 0, "benefits": ["1x points"] },
          { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] }
        ],
        "pointsExpiryDays": 365
      },
      "program_source": "custom",
      "revision_id": "cdp_evt_01J8QWXYZ",
      "redemption_event_name": "loyalty.points_redeemed",
      "relevant_event_names": ["loyalty.points_redeemed", "loyalty.points_adjusted", "account.created", "order-completed"]
    },
    "meta": { "request_id": "req_01J8QWXYZ", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

`POST /program` (create) and `GET /program` return this same envelope — create responds `201`, read includes `program_source: "default"` until you author a program. `DELETE /program` resets to the default and responds `204` with an empty body (the points ledger is untouched — only the config reverts).

## Preview before you commit

`POST /preview` runs the accrual/tier engine against sample events you supply — either your current program or an override — with no database write. Use it to tune earn rules and tier thresholds before adopting them.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/loyalty/preview \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "event_name": "order-completed", "properties": { "total": 250 } },
      { "event_name": "order-completed", "properties": { "total": 90 } }
    ]
  }'
```

```javascript Node theme={null}
const res = await fetch('https://api.orbit.devotel.io/api/v1/loyalty/preview', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.ORBIT_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    events: [
      { event_name: 'order-completed', properties: { total: 250 } },
      { event_name: 'order-completed', properties: { total: 90 } },
    ],
  }),
});
console.log(await res.json());
```

Omit `program` to run against your saved (or default) program; send `program` to evaluate a candidate override. Either way the response projects the balance and tier those events would produce for one contact:

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "program_source": "default",
      "state": {
        "program": "Orbit Rewards",
        "balance": {
          "available": 340,
          "lifetimeEarned": 340,
          "totalRedeemed": 0,
          "expired": 0,
          "expiringSoon": 340,
          "nextExpiryAt": "2027-08-26T10:00:00Z"
        },
        "tier": {
          "current": { "id": "member", "name": "Member", "threshold": 0, "benefits": ["1x points"] },
          "next": { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] },
          "pointsToNext": 660,
          "progress": 0.34
        },
        "traits": {
          "loyalty_points_balance": 340,
          "loyalty_lifetime_points": 340,
          "loyalty_tier": "Member",
          "loyalty_tier_id": "member"
        }
      }
    },
    "meta": { "request_id": "req_01J8QX012", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

## Members, balance, and redemption

`GET /members` lists every contact with loyalty activity, each with a computed `balance` and `tier`. Pages use `limit` (1–100, default 25) and `offset` as documented in [Pagination](/guides/pagination) — the members list is one of the offset-based endpoint families.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/loyalty/members?limit=2&offset=0" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```javascript Node theme={null}
const res = await fetch('https://api.orbit.devotel.io/api/v1/loyalty/members?limit=2&offset=0', {
  headers: { 'X-API-Key': process.env.ORBIT_API_KEY },
});
console.log(await res.json());
```

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "members": [
        {
          "contact_id": "cnt_abc123",
          "event_count": 14,
          "last_activity_at": "2026-08-20T14:12:00Z",
          "balance": {
            "available": 340,
            "lifetimeEarned": 840,
            "totalRedeemed": 500,
            "expired": 0,
            "expiringSoon": 120,
            "nextExpiryAt": "2026-09-15T00:00:00Z"
          },
          "tier": {
            "current": { "id": "member", "name": "Member", "threshold": 0, "benefits": ["1x points"] },
            "next": { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] },
            "pointsToNext": 160,
            "progress": 0.84
          },
          "traits": {
            "loyalty_points_balance": 340,
            "loyalty_lifetime_points": 840,
            "loyalty_tier": "Member",
            "loyalty_tier_id": "member"
          }
        }
      ],
      "program_name": "VIP Rewards",
      "limit": 2,
      "offset": 0
    },
    "meta": { "request_id": "req_01J8QY345", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

`GET /members/{contactId}` returns one contact's full state — `available` points, `lifetimeEarned` (drives tier; redemptions never lower it), `expiringSoon`, and `nextExpiryAt`.

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_abc123 \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```javascript Node theme={null}
const contactId = 'cnt_abc123';
const res = await fetch(`https://api.orbit.devotel.io/api/v1/loyalty/members/${contactId}`, {
  headers: { 'X-API-Key': process.env.ORBIT_API_KEY },
});
console.log(await res.json());
```

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "contact_id": "cnt_abc123",
      "program": "VIP Rewards",
      "balance": {
        "available": 340,
        "lifetimeEarned": 840,
        "totalRedeemed": 500,
        "expired": 0,
        "expiringSoon": 120,
        "nextExpiryAt": "2026-09-15T00:00:00Z"
      },
      "tier": {
        "current": { "id": "member", "name": "Member", "threshold": 0, "benefits": ["1x points"] },
        "next": { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] },
        "pointsToNext": 160,
        "progress": 0.84
      },
      "traits": {
        "loyalty_points_balance": 340,
        "loyalty_lifetime_points": 840,
        "loyalty_tier": "Member",
        "loyalty_tier_id": "member"
      }
    },
    "meta": { "request_id": "req_01J8QZ678", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

`POST /members/{contactId}/redeem` atomically debits points — concurrent redemptions against the same contact can't overspend a shared balance. It returns `409 INSUFFICIENT_POINTS` when the requested amount exceeds the available balance.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_abc123/redeem \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "points": 500, "reward": "10% off coupon", "reference": "ord_9182" }'
```

```javascript Node theme={null}
const res = await fetch('https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_abc123/redeem', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.ORBIT_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ points: 500, reward: '10% off coupon', reference: 'ord_9182' }),
});
console.log(await res.json());
```

A successful redemption responds `201` with the post-debit state:

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "contact_id": "cnt_abc123",
      "redemption_event_id": "cdp_evt_01J8R0ABC",
      "debited": 500,
      "program": "VIP Rewards",
      "balance": {
        "available": 340,
        "lifetimeEarned": 840,
        "totalRedeemed": 500,
        "expired": 0,
        "expiringSoon": 120,
        "nextExpiryAt": "2026-09-15T00:00:00Z"
      },
      "tier": {
        "current": { "id": "member", "name": "Member", "threshold": 0, "benefits": ["1x points"] },
        "next": { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] },
        "pointsToNext": 160,
        "progress": 0.84
      },
      "traits": {
        "loyalty_points_balance": 340,
        "loyalty_lifetime_points": 840,
        "loyalty_tier": "Member",
        "loyalty_tier_id": "member"
      }
    },
    "meta": { "request_id": "req_01J8R1DEF", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

When the requested amount exceeds the balance, the error body names both figures so you can surface them to the customer:

<ResponseExample>
  ```json 409 theme={null}
  {
    "error": {
      "code": "INSUFFICIENT_POINTS",
      "message": "Insufficient points: available 340, requested 500.",
      "status": 409
    },
    "meta": { "request_id": "req_01J8R2GHI", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

`POST /members/{contactId}/adjust` is the operator override: `{"direction": "credit", "points": 200, "reason": "Customer service gesture"}` grants points directly; `{"direction": "debit", ...}` removes them through the same overspend-safe path as redemption.

**Credit:**

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_abc123/adjust \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "direction": "credit", "points": 200, "reason": "Customer service gesture" }'
```

```javascript Node theme={null}
const res = await fetch('https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_abc123/adjust', {
  method: 'POST',
  headers: {
    'X-API-Key': process.env.ORBIT_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ direction: 'credit', points: 200, reason: 'Customer service gesture' }),
});
console.log(await res.json());
```

<ResponseExample>
  ```json 201 theme={null}
  {
    "data": {
      "contact_id": "cnt_abc123",
      "adjustment_event_id": "cdp_evt_01J8R3JKL",
      "direction": "credit",
      "points": 200,
      "program": "VIP Rewards",
      "balance": {
        "available": 540,
        "lifetimeEarned": 1040,
        "totalRedeemed": 500,
        "expired": 0,
        "expiringSoon": 120,
        "nextExpiryAt": "2026-09-15T00:00:00Z"
      },
      "tier": {
        "current": { "id": "vip", "name": "VIP", "threshold": 1000, "benefits": ["2x points", "Free shipping"] },
        "next": null,
        "pointsToNext": 0,
        "progress": 1
      },
      "traits": {
        "loyalty_points_balance": 540,
        "loyalty_lifetime_points": 1040,
        "loyalty_tier": "VIP",
        "loyalty_tier_id": "vip"
      }
    },
    "meta": { "request_id": "req_01J8R4MNO", "timestamp": "2026-08-26T10:00:00Z" }
  }
  ```
</ResponseExample>

**Debit:** send `"direction": "debit"` with the same `points` / `reason` / `reference` fields. A debit responds `201` with `direction: "debit"`, the deducted `points`, and the post-debit state; it returns the same `409 INSUFFICIENT_POINTS` envelope as redeem when the balance can't cover the deduction.

## Segments and journeys

Every member's computed state is exposed as traits — `loyalty_points_balance`, `loyalty_lifetime_points`, `loyalty_tier`, `loyalty_tier_id` — usable directly as entry conditions in [Segments](/api-reference/segments) and flow triggers, e.g. `loyalty_points_balance >= 500`.

## See also

* [Loyalty program guide](/guides/loyalty-program) — step-by-step tutorial for building a program end to end
* [CDP API](/api-reference/endpoints/cdp) — the event stream loyalty accrues from
* [Segments API](/api-reference/segments) — build an audience from loyalty traits
* [Pagination](/guides/pagination) — page through the members list
* [REST API recipes](/guides/api-recipes) — task-by-task curl and Node cookbook
