Wallets and Points
Every external user in a project has one wallet. A wallet holds any number of asset types (points, coffee_stamps, credits, …), each with its own balance. Underneath, each balance is the sum of asset entries (lots created by earns, each with an optional expiry), and every change is recorded as a transaction in an append-only ledger.
Asset types are free-form
Section titled “Asset types are free-form”There is no registry of asset types. The first earn of "assetType": "stars" creates that balance; a typo such as "point" silently creates a second, separate balance. Asset types are not converted into each other or into money. Pick names once, use them everywhere (API calls, rule actions, conditions), and treat them as case-sensitive.
The wallet also does not check that the customer profile exists. Writing to an unknown user ID creates a new, empty wallet for that ID. Resolve the customer ID before writing to a wallet.
Three layers of data
Section titled “Three layers of data”| Layer | Endpoint | What it answers | Important distinction |
|---|---|---|---|
| Balance | GET /projects/{projectId}/users/{userId}/wallet | “How many points does the customer have now?” | Also returns lifetime earned/spent/expired and the next expiry date per asset type |
| Asset entries | GET …/wallet/assets | “Which lots make up the balance and when do they expire?” | Only lots with something left in them; fully spent or expired lots are removed |
| Transactions | GET …/wallet/transactions | “How did the balance change, and why?” | Permanent ledger: earn, spend, expire, adjust |
The balance response is keyed by asset type:
{ "success": true, "data": { "assets": { "points": { "assetType": "points", "balance": 1250, "lifetimeEarned": 2000, "lifetimeSpent": 700, "lifetimeExpired": 50, "nextExpiration": "2026-12-31T23:59:59.000Z" } } }}Read a balance from the REST API as data.assets.points.balance. An asset type the customer has never earned is absent from assets; treat that as 0. Lifetime totals count earn, spend and expire transactions only. adjust transactions from modify are not included.
Inside rules, a wallet resource is reshaped so both {{ $('wallet').points.balance }} and {{ $('wallet').balances.points }} work. See conditions and resources.
Earn, spend, modify
Section titled “Earn, spend, modify”| Operation | Request field | Effect | Choose it when |
|---|---|---|---|
| Earn | amount (> 0) | Creates a new asset entry, optionally with expiresAt and tags; records an earn transaction | Awarding something: purchase points, bonuses, referral rewards |
| Spend | amount (> 0) | Consumes existing entries, first expiring first; records one spend transaction | Redeeming or deducting a known amount |
| Modify | targetAmount (≥ 0) | Computes delta = target − current balance. A positive delta creates one entry; a negative delta consumes entries like a spend. Records one adjust transaction with the signed delta | Correcting a balance to an exact value: migrations, support corrections, syncing from another system |
Never use modify to award or deduct a relative amount. “Give 100 points” is an earn. “Set to 100 points” is a modify. Running a modify twice with the same target is harmless. Running an earn twice credits twice.
Modify with a target equal to the current balance does nothing and returns "transaction": null, "delta": 0.
Spending order and insufficient balance
Section titled “Spending order and insufficient balance”Spend (and negative modify) consumes entries first-expiring-first-out (FEFO): entries with the earliest expiresAt go first, and entries with no expiry go last. A spend that needs more than the available balance fails as a whole with HTTP 400, error code 2601 (“Insufficient balance”). Nothing is partially deducted. A wallet can never go negative.
Expiry
Section titled “Expiry”expiresAtis set per earn (or per positive modify) and must be a future UTC timestamp such as2026-12-31T23:59:59Z. A past date is rejected with error2600.- Without
expiresAt, the entry never expires. - Expired entries are removed and an
expiretransaction is written for the remaining amount. This runs on a schedule and also before every wallet read or write, so a balance you read never includes expired points. - Rule
rewardactions can set expiry relatively (expiresIn+expiresInUnit, e.g.365+days) or absolutely (expirationDate). See the loyalty program guide.
Wallet changes as events
Section titled “Wallet changes as events”A project can opt in to wallet events: wallet_asset_earned, wallet_asset_spent, wallet_asset_modified, wallet_asset_expired. To opt in, register a resource of type event whose key is the event name. The event is emitted for the wallet’s owner. Its payload carries assetType, balance (the balance after the change) and transactionId, plus amount for earned/spent/expired or delta and previousBalance for modified. Earned, spent and modified events also carry referenceId and referenceType. Use this for tier upgrades (“when the balance reaches 5000”) or notifications. Watch out for loops: a rule that earns on wallet_asset_earned triggers itself.
Pitfalls
Section titled “Pitfalls”- Amounts are plain numbers in the asset’s own unit. A point is not a cent. Keep amounts as integers; the API does not reject fractions.
- Direct API writes and rule actions write to the same wallet. Before adding a direct award, check whether a rule already awards for the same business event.
referenceIdis stored for traceability but is not checked for duplicates. See working with wallets for safe retries.
See also
Section titled “See also”- Working with wallets: request bodies, verification, retries
- Loyalty program guide: earning points from events
- Customers
- Wallet in the API reference