Programs
Currencies and the ledger, tiers, rewards, challenges, segments and historical counters.
Currencies and the ledger
A currency is a unit customers earn and spend: points, credits, coins, XP. An environment can have
as many as it needs, and each one keeps its own ledger. POST /currencies creates one:
| Field | Meaning |
|---|---|
code | Lowercase identifier (points, credits) that rules, rewards, tier programs and the API use. Fixed once created. |
name | Display name; can be changed. |
precision | Decimal places for display only, default 0. Amounts are always whole minor units: precision 2 shows 125 as 1.25. Fixed once created. |
expiration_policy | { "type": "never" } (default) or { "type": "days", "days": 365 }: each award expires that many days after it is earned. |
negative_balance | forbid (default) or allow: whether a spend may take the balance below zero. |
Every award is a ledger entry in integer minor units. Entries are lots with a remaining balance;
spends and expiries consume lots oldest-expiry first. Pending awards (confirm_after) count in
pending until the confirmation job moves them to available. Manual adjustments are
POST /ledger_entries with a reason; negative adjustments spend FIFO and respect the currency's
negative_balance policy.
One ledger per currency
Balances are kept per customer and per currency (available, pending, lifetime_earned). A
spend, an expiry or a reversal only ever touches lots of its own currency, so two currencies never
mix: a reward costs a set amount of one currency, a tier program counts one currency, and a points
transfer moves one currency with its own limits.
That is the reason to add a second currency. If some awards should expire (promotional credits
after 30 days) and others should not (purchase points), put them in two currencies, each with its
own expiration_policy. In one currency, a spend takes the soonest-expiring lots first and
lifetime_earned adds both kinds together. A second currency is also the way to give a different
unit (store credit with precision 2, coins next to XP) its own balance and its own
negative_balance policy.
Expiry and negative balances
- An award's expiry is stamped when it is earned: the currency's
expiration_policy, unless the rule action sets its ownexpires_after. Changing the policy later (PATCH /currencies/{id}) applies to new awards only; lots already earned keep their date. - Expired lots stop counting at their expiry everywhere: spends, balances, rule conditions,
segments, tiers and reports read the balance less any lot already past its
expires_at. Theexpireledger entry (and thepoints.expiredwebhook) follows before the customer's next spend or from a job that runs every 10 minutes, so for a few minutes the sum of a customer's ledger entries can be higher than the balance the API shows. - Under
forbid, a rule'spoints.deducttakes what the customer has (the effect reports the requested and the deducted amount), while a manual negative adjustment or a reward redemption that exceeds the balance fails withinsufficient_balance. Underallowthey go through and the balance goes below zero. A store credit wallet must forbid. - A currency can be deleted only while no ledger entry, rule, challenge, reward, issuance, tier
program, campaign budget or native program refers to it (
resource_in_useotherwise).
Currencies created with capabilities (mechanics currencies, such as the XP that the
levels_and_badges playbook installs) also declare whether they are spendable and
transferable; an XP currency is neither. Their expiry
is fixed at creation and they are never deleted.
Tier programs
{ name, currency, basis, levels[], downgrade_check }. basis is lifetime_earned,
earned_rolling_days or balance; levels are ascending thresholds starting at 0. Rules upgrade
customers with tier.evaluate; the downgrade job re-evaluates at each local day or month
boundary; operators can set a level directly (audited). Removing a level customers hold needs
reassign_to; changing the basis needs reevaluate: true.
Rewards
{ name, cost, currency, availability, expiry_after }. Rules issue them; customers redeem an
issuance (POST /reward_issuances/{id}/redeem), which spends the cost in the same transaction and
fails with insufficient_balance otherwise. Cancelling refunds the cost by a reversal entry and
releases availability; expiry does not refund. An issuance reads expired from its expires_at
on (also in the status filter), and it can then be neither redeemed nor cancelled. Its version
and ETag move up by one at that moment, so a write sent with an If-Match taken before the
expiry fails with stale_write. Campaigns and challenges past their ends_at read ended the
same way.
Challenges
{ qualifying: { event, conditions? }, target, period, contribution_key?, enrollment, repeatable, completion_actions[] }.
Each qualifying event contributes once per contribution_key value; reaching target completes
the period exactly once and applies the completion actions in the same transaction. Explicit
enrollment gates progress; revoke and reevaluate are audited operator actions.
Completion actions (points.award, points.deduct, reward.issue, attribute.set,
webhook.emit) are planned like a rule's actions: an amount may be an expression over the
completing event and the customer, rounded once to whole minor units, and confirm_after,
expires_after and webhook payload paths work as in rules. If the list cannot be evaluated (an
expression reads a property the event lacks) the challenge still completes, nothing of the list is
applied, and the execution reports challenge.completion_skipped with the reason. When the
challenge belongs to a campaign with a budget, its awards reserve that budget; once the budget is
exhausted the challenge still completes, the awards are left out (budget_exhausted) and the other
actions apply. reevaluate has no triggering event: a list that reads event.* or
customer.counters.* is skipped with no_triggering_event, while customer.attributes,
customer.balances, customer.tier, customer.segments and now resolve as usual.
Segments
Static segments have explicit members; dynamic segments are a condition over
customer.attributes.* evaluated live at rule time. Campaign audiences and rule conditions
reference segments by name, so a referenced segment cannot be renamed or deleted.
Historical counters
{ name, event, window, filter? } counts events per customer and window and is read as
customer.counters.<name>. A definition registered after events exist starts backfilling;
rules that read it skip with counter_not_ready until POST /counter_definitions/{id}/backfill
(inline for small histories, a job otherwise) marks it ready.
Native program contracts
Native programs have an immutable key, a kind, versioned policies and customer participations.
The native kinds are streaks, badges, quests (up to 32 steps), tiers (which also carry levels),
leaderboards and referrals; Mechanics describes each, and
GET /v1/capabilities says which are installed in an environment. Operators author them through
change sets (Author a mechanic); POST /programs with its version and
activation operations is the direct route (rules:write, and the publish scope for a version). Legacy
tiers, rewards, campaigns and challenges retain their existing command behavior; their contract
projections do not create an additional effect writer.
A participation pins an immutable program version and an IANA timezone. Publishing or activating a newer version does not silently upgrade existing customers. Persisted periods keep their original start, end, grace, admission and finalization boundaries, including the timezone-data version used to calculate them. Periods are half-open and ISO weeks start on Monday. Opening a new period preserves the previous period's history.
Commands and receipts
The following commands have canonical routes and equivalent aliases. API paths below are
relative to the /v1 base.
| Command | Canonical POST | Alias POST | Required scope |
|---|---|---|---|
| Enrollment | /program_enrollments | /programs/{id}/enrollments | customers:write |
| Correction | /program_corrections | /program_participations/{id}/corrections | ledger:write |
| Succession | /program_successions | /program_participations/{id}/successions | customers:write |
Use the customer's internal customer_id and the returned program/participation identifiers;
these commands do not infer an external identifier from its spelling. Each POST requires
Idempotency-Key, If-Match and a durable operation_key. If-Match fences the participation's
state_revision, with "0" for an initial enrollment. Enrollment and succession additionally
carry the expected catalog program_revision in the body. A stale revision returns
stale_write; retry using freshly read state only after deciding that the operation is still
appropriate.
An enrollment body contains program_id, customer_id, program_revision and operation_key.
A correction body contains customer_id, participation_id, contribution_id, operation_key
and a correction object. That object names kind (refund, void or fraud) and a reason,
with either a decimal-string fraction or an integer amount, or neither for a full correction.
It does not accept both. The authenticated operator path supplies provenance; callers cannot
assert their own trust or mark an arbitrary source as verified.
The same operation key and input through either alias resolves to the same permanent result. Reusing the operation key for different input returns a conflict. HTTP idempotency and permanent command identity are separate: losing a short-lived HTTP replay cache does not allow the command to execute again. Authorization runs before replay, so removed permissions cannot retrieve a previously authorized result through a write route.
Each canonical resource supports GET /<resource>/{id} for its original receipt. Enrollment
and succession reads require customers:read; correction reads require ledger:read.
Receipts are immutable results of the command, not a live view of participation state. In
particular, a timezone request's receipt remains scheduled after its due work completes.
Do not treat a receipt as a refreshed participation revision for a different command.
Corrections and owed completion
A correction references a permanent contribution identity, appends a correcting revision and applies the configured compensation. It does not rewrite a historical transition. Qualification and spendable value can be reversed; recognition history follows the program's correction policy. After period finalization, corrections remain explicit revisions of that historical period.
A native program can reach its target while its own reward cannot yet be evaluated, for example because a required counter is still backfilling. The original completion remains owed with its original cause and trust. Recovery applies the reward once readiness returns; it does not create a new contribution or reset an allowance. A period may close while retaining that obligation. This native behavior is distinct from the legacy challenge completion behavior described above.
Finalization waits for already admitted work relevant to the period. Unresolved poison events and unattributed retained receipt obligations block closure until their disposition is resolved. A later period can open while the previous one drains. A late job closes missed periods in order, once each, using their persisted boundaries.
Explicit succession
A succession body contains customer_id, participation_id, program_revision,
operation_key and a command object. Use
{ "type": "version", "program_version_id": "<target version id>" } for an explicit upgrade,
or { "type": "timezone", "timezone": "Europe/London" } to request a timezone successor.
The command carries compatible state into a successor and marks the predecessor superseded.
Program-wide contribution and reward identities survive: upgrading cannot re-earn a milestone
or turn a once-only program into a repeatable one. An incompatible upgrade is refused.
Timezone succession takes effect at the next boundary of the currently pinned calendar. It
cannot rewrite the current period or produce an overlapping allowance. Multiple requests before
that boundary replace the pending destination while retaining that original boundary. Pending
accepted activity and owed completion must drain before succession can take effect. A version
upgrade with an owed completion returns retryable concurrent_update with reason
participation_pending; timezone
work waits without moving its effective boundary. New ordinary activity at or after that
boundary cannot earn another allowance under the old timezone while succession is blocked.