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

# Run a loyalty and rewards program end to end

> One continuous worked example, a coffee-shop rewards program, that wires earn rules and tiers, the incentives ledger, and wallet-pass delivery into a single live program.

# Run a loyalty and rewards program end to end

Orbit ships the three pieces of a rewards program as separate surfaces: [loyalty](https://docs.orbit.devotel.io/guides/loyalty-program.md) computes points and tiers from your event stream, [incentives](https://docs.orbit.devotel.io/guides/incentives-ledger.md) issues and audits the rewards those points buy, and [wallet passes](https://docs.orbit.devotel.io/guides/wallet-passes.md) put the card and the coupons on the customer's phone. This guide wires all three into one program with a single running example, **Aurora Coffee Rewards**: a member earns a point per dollar, climbs a tier ladder, redeems points for a free-drink coupon, and carries a wallet card whose balance refreshes after every purchase.

The route through the program:

```text theme={null}
order event → accrual (earn rules + tiers) → redemption → incentive ledger
     ↑                                                     ↓
  POS webhook / keyword opt-in                    coupon code + wallet pass
                                                           ↓
                                              save link over SMS / WhatsApp
```

## Prerequisites

* **CDP events flowing.** Points accrue from events you already send (`account.created`, `order-completed`, …). If ingestion is not wired yet, start with the [CDP event model](https://docs.orbit.devotel.io/concepts/cdp-event-model.md) and the [CDP API](https://docs.orbit.devotel.io/api-reference/endpoints/cdp.md).
* **An API key with `contacts:read`, `contacts:write`, and message-send permission.** Reads need `contacts:read`; enrollment, redemption, and the final delivery message need the rest.
* **One test contact** with an E.164 phone number, created or imported per the [import guide](https://docs.orbit.devotel.io/guides/import-contacts.md). The examples use `ctn_9f8e7d6c5b4a`.

## 1. Design the program: earn rules and tiers

A program is a name, a list of earn rules, a tier ladder, and an optional point-expiry window. For Aurora Coffee:

* **Points per spend** — 1 point per whole dollar on `order-completed`, computed from the order `total`.
* **Signup bonus** — 50 points on `account.created`.
* **Expiry** — unused points expire after 365 days.
* **Tiers** — Member at 0, Regular at 250, Gold at 1,000 lifetime points.

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/loyalty/program \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Aurora Coffee Rewards",
    "earnRules": [
      { "id": "signup", "label": "Joining bonus", "type": "perEvent", "eventName": "account.created", "points": 50 },
      { "id": "spend", "label": "1 point per dollar", "type": "perUnit", "eventName": "order-completed", "field": "total", "pointsPerUnit": 1, "rounding": "floor" }
    ],
    "tiers": [
      { "id": "member", "name": "Member", "threshold": 0, "benefits": ["Earn points on every order"] },
      { "id": "regular", "name": "Regular", "threshold": 250, "benefits": ["Free size upgrade"] },
      { "id": "gold", "name": "Gold", "threshold": 1000, "benefits": ["Free drink every month"] }
    ],
    "pointsExpiryDays": 365
  }'
```

Three rules keep the ladder honest:

* **Anchor a tier at threshold `0`.** The schema rejects a program without a base tier; every member must always have a current tier.
* **Thresholds are lifetime-earned, not current balance.** A member who earned 1,200 points and redeemed 1,000 stays Gold. Redemptions never demote.
* **Every rule needs a stable `id`** so you can raise a rate or retire a rule later without touching the rest of the program.

### Preview before you adopt

`POST /api/v1/loyalty/preview` runs the accrual engine against sample events with no database write. Use it to sanity-check the math before the program goes live:

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

The response attributes points per event (50 for the signup, 12 for the 12.40 order with `floor` rounding) and reports the resulting tier. A config missing its base tier fails here with a normal `422`, not at production time.

## 2. Fund the rewards: the incentive catalog and the ledger

Redemption is where loyalty hands off to the incentives engine. Two concepts matter before you price any reward:

* **A ledger entry** is the issued-reward record: who got it, why (`source`), what type and amount, and where it stands in its lifecycle (`issued` → `redeemed` or `voided`). It is the audit trail, nothing more.
* **Fulfillment** is how the value actually reaches the customer. Reward types are `discount_code` (Orbit mints a redeemable code), `credit` (your tenant account balance), `gift_card` (a gift-card network; manual until provider credentials are provisioned), and `custom` (you fulfil it yourself and mark the row redeemed when done).

`GET /incentives/providers` lists the fulfillment providers available per reward type, so you can price the catalog against what is actually wired:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/incentives/providers \
  -H "X-API-Key: $ORBIT_API_KEY"
```

For Aurora Coffee the catalog is deliberately boring:

| Reward | Cost in points | Reward type | Fulfillment |
| - | - | - | - |
| Free drink coupon | 100 | `discount_code` | Orbit mints a code the POS accepts |
| \$5 treat card | 500 | `gift_card` | Manual until gift-card credentials are provisioned |
| Barista's-choice perk | 250 | `custom` | Staff honour it in store; you mark it redeemed |

Budget earn rates against this catalog before launch: at 1 point per dollar, a 100-point coupon costs you a drink per 100 dollars of spend. The ledger's `amount` and `currency` fields let you sum value issued per period later, which is the spend side of the program's economics (see [Ops and limits](#6-ops-and-limits-ledger-audit-reversals-and-spend-caps)).

A redemption routes through the engine and tags the resulting ledger row `source: "loyalty"`, so program payouts, referral payouts, and one-off `source: "api"` promos all live in the same ledger. Issue standalone rewards only for triggers that are yours (a service-recovery gesture, a win-back offer); program rewards should always come from a redemption so the points debit and the ledger entry stay paired.

## 3. Deliver the reward: wallet pass and coupon

### Issue the member card at enrollment

Issue a `loyalty_card` pass the moment a member joins, keyed idempotently to the enrollment so a retried call replays the original pass instead of minting a second card on the customer's phone:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/issue \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "loyalty_card",
    "contact_id": "ctn_9f8e7d6c5b4a",
    "title": "Aurora Coffee Rewards",
    "subtitle": "Member",
    "organization_name": "Aurora Coffee",
    "barcode_message": "MEMBER-10482",
    "barcode_format": "QR_CODE",
    "fields": [
      { "key": "points", "label": "Points", "value": "50" },
      { "key": "tier", "label": "Tier", "value": "Member" }
    ],
    "idempotency_key": "enroll-10482"
  }'
```

Save two values from the response: `data.id` (the `wps_…` pass id you update and void against) and `data.platforms.google.save_url` (the delivery link). Check `GET /api/v1/wallet-passes/platforms` before a bulk enrollment; you can issue passes before Apple and Google credentials are connected, but the delivered save link only works once they are.

### Deliver the save link over SMS or WhatsApp

The pass is useless until it is on the phone. Put the save link in the channel the member opted into, exactly as the [wallet-pass guide](https://docs.orbit.devotel.io/guides/wallet-passes.md) does:

```bash theme={null}
SAVE_URL=$(jq -r '.data.platforms.google.save_url' issue-response.json)

curl -X POST https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"to\": \"+14155552671\",
    \"channel\": \"sms\",
    \"content\": {
      \"text\": \"Welcome to Aurora Coffee Rewards! You have 50 points. Add your card: ${SAVE_URL} Reply STOP to opt out.\"
    }
  }"
```

The same body with `"channel": "whatsapp"` delivers over WhatsApp for members whose consent covers it. SMS consent does not grant WhatsApp consent; enroll each member on the channel their record shows.

### Refresh the balance on every purchase webhook

When the POS posts the next `order-completed` event, the member's balance moves. Push the new balance to the pass so the phone is the member's source of truth:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/wps_a1b2c3/update \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fields": [
      { "key": "points", "label": "Points", "value": "62" },
      { "key": "tier", "label": "Tier", "value": "Member" }
    ]
  }'
```

Each accepted update increments the pass's `generation` counter, and the wallet app on the phone refreshes when the counter moves. The member sees 62 points without re-saving anything. Read the balance for the update from `GET /api/v1/loyalty/members/ctn_9f8e7d6c5b4a`, which returns `available`, `lifetimeEarned`, and the current tier.

### Deliver the redeemed coupon

When the member redeems 100 points for the free-drink coupon, the incentives engine mints the code and the ledger row carries it. Read it back filtered to the member:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/incentives/issued?source=loyalty&contact_id=ctn_9f8e7d6c5b4a" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Two delivery styles both work:

* **Message** — splice the row's `code` into an SMS or WhatsApp message (`"Your free drink is ready: code AUR-4F2K. Show it at the counter."`).
* **Coupon pass** — issue `type: "coupon"` with the code as `barcode_message` and `expires_at` set to the offer deadline, so the POS scanner reads it straight off the phone. Void the pass once the redemption is confirmed at the till.

Mark a `custom` reward redeemed with `POST /api/v1/incentives/{id}/redeem` when the barista hands the perk over, so the lifecycle stays honest.

## 4. Trigger accrual from order events

Points only move when events land, so the program is only as good as the pipe between your POS and the CDP. Three patterns cover it:

**Webhook consumer.** Your POS backend posts each paid order as an `order-completed` event to your [CDP ingest endpoint](https://docs.orbit.devotel.io/api-reference/endpoints/cdp.md), with the order id in `properties` and the member's contact identity. Deduplicate on the order id in your sender so a POS retry never double-accrues; accrual itself recomputes from the event stream, so a duplicate event would otherwise double the points.

**Segment-triggered journey.** If you would rather keep the trigger declarative, a [segment-triggered journey](https://docs.orbit.devotel.io/guides/segment-triggered-journeys.md) can react to the same events and run the enrollment side (issue the pass, send the save link) without bespoke consumer code.

**Keyword opt-in.** For walk-in signups, a [keyword rule](https://docs.orbit.devotel.io/guides/keyword-rules-recipes.md) ("text **BEANS** to your number") adds the contact and emits the `account.created` event that fires the signup bonus, so the keyword path and the API path feed the same earn rules.

For a backfill of historical orders, [CDP file ingest](https://docs.orbit.devotel.io/guides/cdp-file-ingest.md) replays them in chunks; because balances recompute from the full history, backfilled orders accrue exactly as live ones would.

## 5. Campaign tie-in: win back lapsed members

A lapsed member who still holds points is the cheapest win-back audience you have. Build the segment from loyalty traits plus recency: `loyalty_tier` or `loyalty_points_balance` above zero, and no `order-completed` in the last 60 days. The traits segments can match on are described alongside [Segments](https://docs.orbit.devotel.io/api-reference/segments.md) in the [loyalty guide](https://docs.orbit.devotel.io/guides/loyalty-program.md).

Run that segment through a [win-back journey with fallback channels](https://docs.orbit.devotel.io/guides/winback-journey-with-fallback-channels.md): SMS first, WhatsApp or email as fallback, and let the message carry the member's own number: "You have 320 Aurora points, 30 away from a free drink. They expire 31 Dec." Points-expiry copy converts because the expiry is real, configured in the program itself.

Before the send, apply the same tenant controls you would on any campaign: channel consent per recipient, your quiet-hours schedule, suppression and frequency caps, and your approved opt-out footer on every step. These are your tenant's controls; review [quiet hours](https://docs.orbit.devotel.io/guides/quiet-hours-configuration.md), [message suppression](https://docs.orbit.devotel.io/guides/message-suppression.md), and [opt-out rules](https://docs.orbit.devotel.io/guides/opt-out-rules.md) before enabling the journey.

## 6. Ops and limits: ledger audit, reversals, and spend caps

**Audit.** `GET /api/v1/incentives/issued?source=loyalty` is the program's payout report; add `contact_id` to see one member's full reward history, or `reward_type` to isolate coupons from gift cards. Page with `limit` and `offset` (max 200) and follow `has_more`. The ledger page in the dashboard shows the same rows with the same filters, mirrored at **Marketing → Incentives** and **/outbound/incentives**.

**Void and reversal.** Two separate reversals exist, and a refund that involves a reward usually needs both:

* `POST /api/v1/incentives/{id}/void` cancels an incentive that was issued in error or never fulfilled. Void only applies from `issued`; a row that is already `redeemed` or `voided` answers with a `409` describing its current state.
* `POST /api/v1/loyalty/members/{contactId}/adjust` reverses or grants points directly, using the same overspend-safe path as redemption. When a refunded order had earned points, debit them here so the balance and the tier math stay truthful.

Treat `409` from the incentive transitions and from wallet-pass `update`/`void` as expected termination, not a retryable error.

**Spend caps.** Rewards pay out real value, so give the program a budget like any other channel. The controls you own: the earn rates and tier benefits in the program config, the reward catalog you price in Section 2, idempotency keys that make a retry replay rather than double-mint, and void for anything issued in error. The ledger's `amount`/`currency` fields are the measurement: summing issued value per month is your reward spend. Your account's spend limits sit outside the program as the outer bound on platform spend; the program config is the dial inside it.

**The dashboard.** The Loyalty page (under **Marketing → Loyalty**, mirrored at **/outbound/loyalty**) shows the active program card (earn rules, tiers, expiry window) and the members panel with each member's computed balance and tier. It mirrors the API: anything you can `PUT` to the program you can edit in the program dialog, and **Redeem points** / **Adjust points** act on the selected member.

## Program live checklist

* Preview run against sample events returns the expected points and tier, and the program has its base tier at threshold `0`.
* Enrollment issues the pass with an `idempotency_key` derived from the enrollment, not the contact id, so a re-issue after a void mints a fresh pass.
* `GET /wallet-passes/platforms` checked before bulk delivery; save-link message sent over a consented channel with the opt-out instruction in place.
* The purchase webhook updates the pass after every earn and spend, so the phone never shows a stale balance.
* A redemption mints the coupon, the ledger row carries the code, and a `custom` reward has a documented redeem-when-done step.
* Refund runbook covers both halves: void the incentive, adjust the points.
* Win-back segment reviewed for consent and recency before the first send.
* Ledger filters (`source=loyalty`, `contact_id`) verified as the monthly payout report.

## Related reading

* [Loyalty program setup](https://docs.orbit.devotel.io/guides/loyalty-program.md) — earn-rule shapes, tier semantics, and the redemption contract in depth.
* [Run the incentives ledger](https://docs.orbit.devotel.io/guides/incentives-ledger.md) — standalone issuance, ledger filters, and redeem/void lifecycle.
* [Issue and deliver a wallet pass end to end](https://docs.orbit.devotel.io/guides/wallet-passes.md) — the pass lifecycle this guide reuses.
* [Segment-triggered journeys](https://docs.orbit.devotel.io/guides/segment-triggered-journeys.md) and [keyword rules](https://docs.orbit.devotel.io/guides/keyword-rules-recipes.md) — the trigger patterns from Section 4.
* [Win-back journey with fallback channels](https://docs.orbit.devotel.io/guides/winback-journey-with-fallback-channels.md) — the lapsed-member campaign from Section 5.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.