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 draft — status 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:
type,currency, andpoints_roundingare frozen once the program has ledger activity. The instant anyledger_entriesrow references the program, aPATCHattempting to change any of those three fails422 VALIDATION_FAILEDwith a message naming the offending field(s). Before that point they’re still open — the freeze isn’t time-based, it’s activity-based. This exists because anEARNrow’spointsvalue was computed under the program’s money/points semantics at the time; changing those semantics retroactively would silently invalidate every past entry.- Activating a program (
status: "active") requires at least oneactiveearning rule. Attempting to activate a program with none is422 VALIDATION_FAILED(“program needs an active earn rule”). This check only runs on the transition intoactive— re-PATCHing an already-active program (omittingstatus, or resendingstatus: "active") never re-checks, so archiving your last active rule afterward doesn’t retroactively deactivate the program.
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"
}
conditions_json— both keys optional:min_spend_minor(integer, gates the rule on a minimum sale size) andlocation_ids(array of location ULIDs, gates the rule to specific stores). An empty object ({}) matches every transaction.effect_json— a discriminated union ontype:Type Shape Meaning spend{ type: "spend", points_per_currency_unit }Points = net_minor × points_per_currency_unit ÷ 100, then rounded per the program’spoints_rounding.visit{ type: "visit", points }A flat number of points per qualifying transaction, regardless of amount. stamps{ type: "stamps", stamps }A flat stamp count (for stamps-type programs).statusat creation isdraftoractiveonly —archivedis reachable only viaPATCH(it’s a terminal state, not something you’d author a rule directly into).
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:
POST .../rulesalways inserts a new row.versionis server-assigned asmax(version for this program) + 1— never accepted from the caller, and never reused. There is no endpoint that rewrites an existing version’sconditions_json/effect_json.PATCH .../rules/{id}touches status and scheduling ONLY —status,priority,valid_from,valid_to. A request body containingconditions_jsonoreffect_jsonis rejected at the schema boundary (422), not silently dropped.
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" }
}
- If any supplied identifier already maps to an existing member in this tenant, that member is
returned as-is (
200,created: false) — nothing is mutated, including not attaching any new identifier from the request that member didn’t already have. Resolve is pure; it never merges. - If none match, a new member is created with all supplied identifiers attached (
201,created: true). - If the supplied identifiers resolve to more than one distinct existing member — e.g. the
phone belongs to member A but the loyalty number belongs to member B — the request fails
409 INVALID_STATErather than silently picking one. This is deliberate: a wrong-member guess here would misattribute points and history, so an honest conflict beats a quiet mistake. - Phone identifiers must be E.164 (
+<country><number>, e.g.+15550002222); a non-conforming value is422 VALIDATION_FAILEDbefore any resolve/create logic runs. - The response’s
identifiers[]always reflects the member’s actual stored identifiers (masked, e.g.+15••••2222), never the raw request — so a resolve response never claims an identifier the member doesn’t really have.
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:
- Locations (
/v1/locations) — plain org-config CRUD (name,external_ref,timezone,group_id); no lifecycle or versioning rules of its own. - Summary (
GET /v1/summary?window=7d|30d|90d) — live dashboard-tile aggregates (member count, points outstanding, redemptions/transactions in the window, active rewards/programs). A live query today, not a stored read model. - Account (
GET /v1/me) — the dashboard session’s own identity, active organization, role, and permission map; see the quickstart’s organization-activation steps (2–3) for the session mechanics this endpoint reflects.
Next steps
- Quickstart — these APIs in the context of a full signup-to-redemption run.
- Generic connector — how a POS integration books sales against the program you just created.
- API reference — every field, enum, and error code for these operations.