Programs and rewards

This is the model behind the provisioning calls in the quickstart (steps 5–9): programs, their earning rules, rewards, and member enrollment. All four are plain CRUD-shaped /v1 routes, but each carries a rule the ledger’s append-only invariants (CLAUDE.md §4) force onto its API shape — that’s what this guide explains.

Program lifecycle

A program (POST /v1/programs) is points, stamps, or cashback-typed, carries an ISO-4217 currency and a points_rounding mode (floor | ceil | round), and moves through three statuses:

draft → active → archived

New programs always start draftstatus isn’t caller-settable at creation. PATCH /v1/programs/{id} moves it along and can update name/status/expiry_policy, but not the fields fixed at creation:

archived is a terminal, caller-chosen status (nothing forces it) — it’s how you retire a program without deleting its history.

Earning rules: versioned, append-only

An earning rule is always scoped to a program: POST /v1/programs/{programId}/rules. Its shape:

{
  "conditions_json": { "min_spend_minor": 1000, "location_ids": ["01J..."] },
  "effect_json": { "type": "spend", "points_per_currency_unit": 1 },
  "priority": 0,
  "valid_from": "2026-07-01T00:00:00Z",
  "valid_to": null,
  "status": "active"
}

Why “editing” a rule means POSTing again

ledger_entries EARN rows pin both rule_id and rule_version (CLAUDE.md §4 invariant 4) — every point ever earned is traceable to the exact rule logic that computed it, forever. That forces an asymmetry between the two writes this resource exposes:

So “I want to change how this rule computes points” is always: POST a new version with the corrected effect_json, then PATCH the old version to archived if you don’t want both live at once. GET /v1/programs/{programId}/rules returns every version ever created, newest first — the dashboard’s “rule history” view is just this list.

Rewards

POST /v1/rewards creates a discount | free_item | voucher reward against a program, with an integer cost_points (≥ 0) and an optional free-form config_json (e.g. discount amount, the specific free item). Like programs, a new reward always starts draft.

Unlike earning rules, rewards are not ledger-referenced the same way — a redemption snapshots cost_points (and the reward’s other terms) at quote/commit time, so PATCH /v1/rewards/{id} can safely edit name, cost_points, config_json, and status in place. Changing cost_points only affects redemptions quoted after the change; it never retroactively alters a redemption that already happened.

A reward must be active to be redeemable. POST /v1/redemptions/quote against a draft or archived reward fails 404 NOT_FOUND (“active reward required”) — the same code you’d get for a reward id that doesn’t exist at all, so don’t read a 404 here as “no such reward” without checking status too.

Member enrollment: resolve-or-create

POST /v1/members is deliberately not a plain “create” — it’s resolve-or-create against member_identifiers:

{
  "program_id": "01J...",
  "identifiers": [
    { "type": "phone", "value": "+15550002222" },
    { "type": "loyalty_number", "value": "8675309" }
  ],
  "birthday": "1990-05-12",
  "traits": { "favorite_drink": "latte" }
}

Enrollment mutates no ledger, and repeating the identical request is naturally idempotent through the underlying (tenant_id, type, value_hash) unique constraint — so, unlike POST /v1/transactions or a redemption commit, there is no Idempotency-Key on this route.

GET /v1/members?search=<value>&program_id=<id> hashes search against every identifier type (phone, email, QR, loyalty number, card token, external) and returns the owning member(s) — the same lookup a dashboard “find member” box would call.

Locations, summary, and your session

Three smaller pieces round out provisioning, each documented fully in the API reference rather than here:

Next steps