Core concepts
Wallets & Points
Every customer has a wallet that tracks their loyalty point balance. The wallet is backed by an append-only ledger that records every point movement with a full audit trail.
Wallet overview
| Field | Description |
|---|---|
id | Unique wallet ID |
customer_id | The customer this wallet belongs to |
balance | Current point balance (integer) |
expiring_soon | Points expiring in the near future |
A wallet is automatically created when a customer is registered. There is exactly one wallet per customer.
Ledger entries
The ledger is an immutable, append-only record of every point movement. Each entry contains:
| Field | Description |
|---|---|
type | The type of movement: earn, adjust, redeem, or expire |
amount | Signed integer: positive for credits, negative for debits |
running_balance | The wallet balance after this entry |
reason | Human-readable explanation |
causer_type | What triggered this entry: a transaction, an admin user or an API client |
causer_id | ID of the triggering entity |
transaction_id | Associated transaction (for earn entries) |
created_at | Timestamp of the entry |
Entry types
| Type | Direction | Triggered by |
|---|---|---|
| earn | Positive | Transaction submitted. Points calculated from earn rules |
| adjust | Positive or negative | Manual balance adjustment by admin or API |
| redeem | Negative | Customer redeems a reward |
| expire | Negative | Points expired under the vendor's expiration policy |
Immutable ledger
Ledger entries are never updated or deleted. The running_balance field on each entry provides a verifiable audit trail. To correct a mistake, create a new adjustment entry rather than modifying existing entries.
Point batches and expiration
Every earn creates a batch, and so does every manual adjustment that adds points. The batch carries an expiry date when the vendor has point expiration enabled and the plan includes it, and none when it does not. Switching expiration off, or moving to a plan without it, changes only new batches: a batch that already has an expiry date keeps it and still expires on that date. Each batch records:
| Field | Description |
|---|---|
amount | Points in this batch |
consumed | Points already used from this batch |
expires_at | When the remaining points in this batch expire |
A redemption or a manual deduction spends the batch that expires soonest first, and a batch with no expiry date last. Earn order is not the same thing: you can change expiration_months or switch expiration off at any time, so a batch earned earlier can expire later than one earned after it. Spending the most perishable points first keeps a customer from losing points that a redemption could have used. When a batch does expire, the points still in it are deducted from the wallet and recorded as an expire ledger entry.
How expiration works
- Vendor enables expiration and sets
expiration_months(e.g., 12 months); the plan must include expiration - Each time points are earned or added by hand while expiration is enabled and the plan includes it, a batch is created with
expires_atset to that date + expiration months - A redemption or a manual deduction spends the batch closest to its expiry date first, so expiry only removes points the customer still holds
- A background job periodically checks for expired batches and creates
expireentries
Manual adjustments
Administrators can manually adjust a wallet balance through the admin portal or the API. Adjustments create a new ledger entry with type adjust:
POST /api/v1/customers/{customer}/wallet/adjust
{
"amount": 500,
"reason": "Compensation for service issue",
"idempotency_key": "goodwill-2024-0042"
}
Positive amounts add points in a new batch that expires like earned points.
Negative amounts deduct points from the batch that expires first. The
idempotency_key makes the call safe to retry: re-sending the same key for the
same customer returns the original ledger entry instead of moving the balance
twice.
See Wallet endpoints for the complete API reference.