Points systems with expiry
Points look like a single number. They are not. Behind every spendable balance is a ledger, an idempotency contract, an expiry policy, and a partition between spendable and lifetime totals. Get any of these wrong and your loyalty program leaks money or angers users. This page covers the architecture, the trade-offs, and a worked Bricqs implementation including FIFO expiry.
- A points system is an append-only ledger of credits and debits, projected into a cached spendable balance; the ledger is the source of truth and the balance is the cache.
- Track three numbers separately: balance (spendable, decrements on spend and expiry), total_earned (lifetime, never decrements, drives tier ranking), and total_spent (lifetime user redemptions, excludes expiry).
- Every write must take a caller-supplied Idempotency-Key and replay the captured original response on retries, not recompute against the current balance.
- FIFO expiry consumes credits oldest-expiry-first, debits only the remaining unspent amount of each credit, and runs as a background worker that writes counter-debits into the ledger.
- The balance check and the debit must commit as a single atomic operation; SELECT-then-UPDATE lets two concurrent spends both succeed when only one has funds.
What a points system actually is
Strip away the loyalty-program marketing and a points system is a ledger with two access patterns. The interesting decisions are about how those access patterns interact: spend vs lifetime, expiry vs permanence, retry vs duplicate.
A points system is an append-only ledger of credits and debits, projected into a current spendable balance for each user. Each credit may carry an expiry. Each debit may consume specific credits (FIFO/LIFO) or just decrement the projection. The ledger is the source of truth; the balance is the cache.
Engineers building loyalty, rewards, or virtual currency. Product teams defining earn rules and redemption catalogs. Finance teams who care about liability accounting (expired vs outstanding points).
Badges are atomic markers. Points are summable, comparable, and spendable. You can ask 'how many points does the user have?'; you cannot ask 'how many badges do they have in total' as a useful number.
A tier is a label derived from points (or any metric). The tier system reads from the points ledger; it does not own its own balance. One ledger, multiple derived views.
Payment credits are non-expiring, non-tier-affecting, and accounted as deferred revenue. Loyalty points are usually expiring, do affect tier, and accounted as a liability. Same data structure, different policies.
Game currencies often have multiple denominations (gold vs gems) and complex conversion rules. Business loyalty systems usually want one currency per program. Bricqs supports both via the currency_code field on every ledger row.
The three numbers you cannot conflate. Every mature points system tracks at least three values per user. balance (also called available or spendable) is what the user can redeem right now; it decrements on spend and on expiry. total_earned (also called lifetime) is every credit ever, summed, and never decrements; it is the axis for tier ranking and hall-of-fame leaderboards. total_spent is every debit caused by user redemption, and does not include expiry (expiry has its own counter).
With expiry in play, balance is not equal to total_earned minus total_spent. Customers who do the math and find the difference will email support. Surface the three numbers separately in your UI and explain expiry as a fourth bucket.
Every change writes two records. One to the transaction log (the receipt: “user X earned 100 points on this date for this reason”) and one to the balance summary (“user X now has 1100 spendable points”). They must commit together. If they ever split (one write succeeds, the other fails), the summary drifts from the log and your numbers stop adding up.
The ledger row is the receipt. Every credit and debit gets a row. The row tells you when, by whom, for what reason, and (for credits) when it expires. Auditors, support teams, and your own debugging-yourself-at-3am all rely on this being lossless. Aggregating before writing throws away the evidence.
When points are the right tool, and when they are not
Points feel universal, but they have failure modes most teams discover the hard way. Use them when they earn their keep.
You want a continuous reward signal across many actions
Points scale: every action earns some, every redemption costs some. Badges and tiers are discrete; points are the connective tissue between them.
Users need to choose how to spend their reward
Points buy multiple rewards from a catalog. Direct rewards ("complete this and get a coupon") feel less personalized; points feel like agency.
You need a fair, summable ranking metric
Leaderboards and tier evaluation both need a numeric. Points (lifetime variant) work as the universal axis.
Finance needs to track liability
Outstanding points are a balance-sheet liability. A ledger with debit/credit semantics maps directly onto accounting. Badges do not.
You only have one or two actions worth rewarding
A single-action loyalty program does not need a ledger. Use a badge or a tier; you will reduce maintenance and avoid the expiry/liability conversation entirely.
Users cannot redeem them
Points without a redemption catalog become vanity numbers. If you do not have rewards yet, ship badges first and add points when you have something to spend them on.
Your audience values privacy over recognition
A visible point balance is comparative; some user bases (privacy-focused, enterprise B2B) find that anti-feature. Use a tier instead, one private label per user, no numeric comparison.
You are modeling fiat or stable currency
Points expire, fluctuate in redemption value, and are non-fungible across programs. If you need real money semantics, use a payment system (Stripe, PayPal) and treat the points balance as a separate accounting layer.
The earn rules are highly variable per user
If "how many points for this action" depends on segment, channel, time of day, A/B test, plus other factors, you are better off computing the points value in your backend and treating Bricqs as a write-only ledger. Keep the rules in your domain.
- Looking for the marketing-team take? The strategy guide on points systems covers earn-rate tuning, redemption value, and economics.
- Configure rewards in the dashboard: Progression → Rewards → New Reward.
Bricqs models points as an append-only ledger with a cached per-user balance summary. The two write paths (award and deduct) take a caller-supplied idempotency key; the read path hits the projection for speed and the transaction history endpoint for audits.
Award, deduct, and expiry all participate in the same progression engine: a points change can trigger tier evaluation, badge auto-awards, and downstream facts. You do not have to schedule a re-evaluation cron, and you do not have to compose those reactions in your app code.
What the API actually returns
Two response shapes you will see from the points endpoints. The first is a transaction (the receipt for one award or debit). The second is the balance summary (the projection of the ledger for one user).
{
"id": "txn_a7b2c1d0-...", // stable transaction id
"amount": 100, // positive = credit, negative = debit
"transaction_type": "manual_credit", // why this row exists
"reason": "Completed onboarding quiz",
"created_at": "2026-05-23T10:30:00Z"
}The transactions list is the source of truth. Append-only log of every credit and debit. Positive amount means credit (user got points), negative means debit (user spent or lost points). Every row has a stable id you can store in your own systems for reconciliation.
The balance summary is the cache. A per-user row updated in the same transaction as each new ledger row. Reads hit the projection (fast); audits and disputes go to the log (lossless).
{
"participant_id": "user_42",
"balance": 1100, // spendable, decrements on spend AND expiry
"total_earned": 1750, // lifetime, NEVER decrements; used for tier ranking
"total_spent": 700 // lifetime spent (excludes expiry)
}The three numbers, surfaced together. Render them as separate facts in your UI so users never have to reconcile them in their heads.
One subtle but important rule for retries. When a caller retries a write, the response from the retry must match the response from the original call. Capture the balance on the transaction record at the moment of the original write, and replay that captured number on retry. Bricqs does this for you; if you build your own ledger, do the same.
The expiry shape every API needs. A credit that can expire carries three additional pieces of information, visible on the award response when supplied: the expiry timestamp, how much of that specific credit is still spendable, and (after expiry runs) whether the row has been processed. The API never asks the caller to track this; the system handles it and exposes it through the balance summary.
What happens when you award, spend, or expire points
Three operations. Each one is plain enough to explain in a paragraph, with a short numbered breakdown for engineers wiring the API call.
Award. You call the award endpoint with a participant id, an amount, and an idempotency key (a string that uniquely identifies this award attempt, like the order id from your checkout). If you send the same call twice with the same key, Bricqs returns the original award unchanged; your retry does not become a second award. Optionally pass an expires_at timestamp to make this credit expire later.
Spend. Spend works the same shape: participant id, amount, idempotency key. The balance check and the debit happen as a single step, so two requests racing each other can never both succeed when only one of them has funds available. If the user does not have enough points, the request fails with a clean error (HTTP 400, code INSUFFICIENT_POINTS) and nothing changes. Among credits that carry an expiry, the one that expires soonest gets debited first (FIFO oldest-expiry-first). The user does not lose soon-to-expire points to a debit that could have come from their never-expiring balance.
Expiry. You do not trigger expiry. Bricqs runs a background job that looks for credits past their expiry and removes whatever the user has not spent of them. If the user already spent the full credit, expiry is a no-op for their balance. If they spent 70 of a 100-point credit, only the remaining 30 gets removed. A reward.points_expired event fires for each expired credit, so you can sync the deduction to your CRM, kick off a re-engagement email, or just log it to your analytics warehouse.
- 01
Award request arrives
POST /points/award with amount, participant_id, and anIdempotency-Keyheader. - 02
First time the key is seen
A new credit row is recorded, the balance summary increments, and any downstream effects (tier evaluation, badge auto-awards) run in the same transaction. - 03
Retry with the same key
The original credit is returned unchanged; no new write. The response replays the captured new_balance, not a fresh recomputation. - 04
Spend request arrives
POST /points/deduct with amount, participant_id, and an Idempotency-Key. The atomic check-and-debit prevents the classic TOCTOU race; insufficient balance returns a 400 with theINSUFFICIENT_POINTScode and writes nothing. - 05
FIFO consumption
The debit draws from the credit with the soonest expiry first. A 30-point spend against a 100-point credit expiring next week and a 50-point never-expiring credit takes 30 from the expiring one. - 06
Expiry worker sweeps
A background job scans for credits past theirexpires_atand writes counter-debits for whatever is left on each row. Areward.points_expiredfact fires for downstream consumers.
Award, deduct, and read balances
Four endpoints cover the entire surface. All authenticate with an API key (bq_live_ in production, bq_test_ in sandbox).
Award points (with optional expiry)
Pass the Idempotency-Key header to make the retry contract unambiguous. Pass expires_at when you want the credit to join the FIFO expiry queue.
curl -X POST https://api.bricqs.com/api/v1/gamify/points/award \
-H "Authorization: Bearer bq_live_xxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order:o_42:loyalty_award" \
-d '{
"participant_id": "user_42",
"amount": 100,
"reason": "Order completion",
"expires_at": "2026-08-23T00:00:00Z", // optional, null = never expires
"metadata": { "order_id": "o_42" }
}'
# Response (200 OK)
{
"transaction_id": "txn_a7b2c1d0-...",
"participant_id": "user_42",
"amount": 100,
"new_balance": 1100,
"tier_upgrade": { // null if no tier change
"code": "silver",
"name": "Silver",
"level": 2,
"previous_code": "bronze"
},
"badges_unlocked": [] // codes of any auto-awarded badges
}The Idempotency-Key is the retry contract. Derive it from the originating domain event (order id, activity id, redemption id) so the same business event always produces the same key, regardless of how many times your retry policy fires.
The award response is composite. Tier upgrades and badge auto-awards are returned in the same payload, so your UI can show all the consequences of one earn without a second round-trip.
expires_at is optional. Omit it for never-expiring credits; supply it to join the FIFO queue. The same endpoint covers both shapes.
Deduct points
400 Bad Request on insufficient balance. The atomic check-and-deduct prevents the TOCTOU race that naive implementations hit under load.
curl -X POST https://api.bricqs.com/api/v1/gamify/points/deduct \
-H "Authorization: Bearer bq_live_xxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reward:r_99:claim" \
-d '{
"participant_id": "user_42",
"amount": 50,
"reason": "Redeemed free coffee"
}'
# Response (200 OK)
{
"transaction_id": "txn_b3c4d5e6-...",
"participant_id": "user_42",
"amount": -50,
"new_balance": 1050
}
# Insufficient balance returns 400 with the structured envelope:
{
"error": {
"type": "insufficient_balance",
"code": "INSUFFICIENT_POINTS",
"message": "Insufficient points. Available: 30, requested: 50.",
"details": { "available": 30, "requested": 50 },
"request_id": "req_abc123..."
}
}Read balance and history
# Current balance
curl https://api.bricqs.com/api/v1/gamify/participants/user_42/points \
-H "Authorization: Bearer bq_live_xxxxx"
# Response (200 OK)
{
"participant_id": "user_42",
"balance": 1050, // spendable
"total_earned": 1750, // lifetime, NEVER decrements
"total_spent": 700 // lifetime spent (excludes expiry)
}
# Transaction history (paginated)
curl "https://api.bricqs.com/api/v1/gamify/participants/user_42/points/transactions?page=1&page_size=50" \
-H "Authorization: Bearer bq_live_xxxxx"
# Response includes every ledger row, including points_expiry counter-debits.Batch award (up to 100 participants)
Each item carries its own idempotency_key (since they award different participants). Useful for batch loyalty events (monthly bonus, season-end credits).
curl -X POST https://api.bricqs.com/api/v1/gamify/points/award-batch \
-H "Authorization: Bearer bq_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"awards": [
{ "participant_id": "user_42", "amount": 100, "idempotency_key": "monthly_2026_05:42" },
{ "participant_id": "user_43", "amount": 100, "idempotency_key": "monthly_2026_05:43" }
]
}'
# Response (200 OK)
{
"processed": 2,
"failed": 0,
"results": [
{ "participant_id": "user_42", "transaction_id": "txn_...", "new_balance": 1150 },
{ "participant_id": "user_43", "transaction_id": "txn_...", "new_balance": 350 }
]
}Render balance and history with one hook
The usePoints hook returns the live balance and recent transactions. It refreshes on every award or deduct event without manual polling.
"use client";
import { usePoints } from "@bricqs/sdk-react";
export function PointsHeader({ engagementId }: { engagementId: string }) {
const {
status, // 'idle' | 'loading' | 'ready' | 'error'
balance, // current spendable
totalEarned, // lifetime (never decrements; drives tier ranking)
currentTier, // { tier_code, tier_name, level } | null
transactions, // recent transactions, newest first
error,
refresh,
} = usePoints({ engagementId });
if (status === "loading") return <span>…</span>;
if (status === "error" || error) return <span>Could not load points.</span>;
return (
<div className="flex items-baseline gap-3">
<span className="text-3xl font-extrabold tabular-nums">{balance}</span>
<span className="text-sm text-slate-500">points</span>
<span className="ml-auto text-xs text-slate-500">
Lifetime earned: {totalEarned}
</span>
</div>
);
}The hook auto-refreshes. When the Bricqs runtime emits a points:awarded or points:deducted event (after a quiz completion, a reward claim, an external award), the hook re-fetches. No polling code on your side.
Pass refreshInterval to control polling. Default 15s. Pass refreshInterval: 0 to disable polling and rely only on event-driven refresh.
The balance is the summary, not the full log. The hook reads the balance summary for speed. If you need to show individual credits (e.g. “100 expiring in 7 days”), call the transaction history endpoint with a date filter.
Recipes for production
Five compositions of the primitives above. Each one solves a real loyalty-program need.
Pattern 1: Order webhook with safe retries
Your storefront fires a webhook on order completion. The handler awards points proportional to order value, with an idempotency key derived from the order so retries are safe even if the webhook fires twice.
export async function POST(req: Request) {
const order = await verifyAndParse(req); // your signature check
const pointsEarned = Math.floor(order.total / 10); // 1 pt per $0.10
if (pointsEarned === 0) return new Response(null, { status: 200 });
await fetch("https://api.bricqs.com/api/v1/gamify/points/award", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
"Content-Type": "application/json",
// Derived from the order id, same order, same key, idempotent.
"Idempotency-Key": `order:${order.id}:loyalty_award`,
},
body: JSON.stringify({
participant_id: order.customer_id,
amount: pointsEarned,
reason: `Order #${order.number}`,
metadata: { order_id: order.id, order_total: order.total },
}),
});
return new Response(null, { status: 200 });
}The Idempotency-Key collapses duplicate webhook deliveries. The second call returns the original response without writing again, so a flaky storefront cannot double-credit the user.
Pattern 2: 90-day expiry on earned points
Set expires_at on every award. The FIFO queue ensures users spend their oldest credits first, so the user-facing behaviour is 'points keep rolling unless you stop engaging.'
function ninetyDaysFromNow(): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() + 90);
return d.toISOString();
}
await fetch("https://api.bricqs.com/api/v1/gamify/points/award", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `order:${order.id}:loyalty_award`,
},
body: JSON.stringify({
participant_id: order.customer_id,
amount: pointsEarned,
expires_at: ninetyDaysFromNow(), // ← the FIFO bit
reason: `Order #${order.number} (90-day expiry)`,
}),
});Background expiry runs on a regular cadence. When a credit's expiry passes, whatever the user has not spent of that specific credit gets debited and a reward.points_expired event fires so you can sync to your CRM or trigger a re-engagement message.
Pattern 3: Atomic reward redemption (no orphans)
Use the rewards/claim endpoint with points_deduction=true. The deduct, code allocation, and claim record all commit in a single transaction. If any step fails, the whole thing rolls back; the user never sees a debit without a code.
curl -X POST https://api.bricqs.com/api/v1/gamify/rewards/r_99/claim \
-H "Authorization: Bearer bq_live_xxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: redeem:user_42:r_99:2026-05-23" \
-d '{
"participant_id": "user_42",
"points_deduction": true // ← atomic deduct + claim
}'
# Response (200 OK)
{
"claim_id": "clm_...",
"participant_id": "user_42",
"reward_name": "Free Coffee",
"reward_type": "coupon",
"code_value": "COFFEE-A7B2C1",
"expires_at": "2026-08-23T00:00:00Z",
"points_deducted": 50,
"new_balance": 1000
}If the catalog inventory is exhausted or the user is short on points, the entire transaction rolls back: no debit, no claim, no orphaned code. Retry with the same Idempotency-Key once the issue is resolved.
Pattern 4: Show lifetime separately to avoid the 'where did my points go?' ticket
Customers who notice the gap between earned and spendable will email support. Make the partition explicit in the UI: spendable up top, lifetime in a smaller secondary row, with a tooltip explaining expiry.
"use client";
import { useEffect, useState } from "react";
import { usePoints } from "@bricqs/sdk-react";
// The SDK hook surfaces balance + totalEarned + currentTier. For the
// lifetime-spent number, hit the REST endpoint directly, it returns
// total_spent (lifetime debits caused by redemption) alongside balance
// and total_earned. Compute the "expired" bucket as the residual.
export function PointsBalanceCard({
engagementId,
participantId,
apiBase,
apiKey,
}: {
engagementId: string;
participantId: string;
apiBase: string;
apiKey: string;
}) {
const { balance, totalEarned } = usePoints({ engagementId });
const [totalSpent, setTotalSpent] = useState(0);
useEffect(() => {
fetch(`${apiBase}/api/v1/gamify/participants/${participantId}/points`, {
headers: { Authorization: `Bearer ${apiKey}` },
})
.then((r) => r.json())
.then((d) => setTotalSpent(d.total_spent ?? 0));
}, [apiBase, apiKey, participantId]);
const expired = Math.max(0, totalEarned - balance - totalSpent);
return (
<div className="rounded-2xl border p-6 space-y-3">
<div>
<div className="text-4xl font-extrabold tabular-nums">{balance}</div>
<div className="text-sm text-slate-500">Spendable points</div>
</div>
<dl className="grid grid-cols-3 gap-3 text-xs text-slate-500 pt-3 border-t">
<div>
<dt className="font-semibold">Lifetime earned</dt>
<dd className="tabular-nums">{totalEarned}</dd>
</div>
<div>
<dt className="font-semibold">Spent on rewards</dt>
<dd className="tabular-nums">{totalSpent}</dd>
</div>
<div title="Points that passed their expiry without being spent">
<dt className="font-semibold">Expired</dt>
<dd className="tabular-nums">{expired}</dd>
</div>
</dl>
</div>
);
}The REST fetch is only needed because the React SDK's usePoints hook surfaces balance + total_earned but not total_spent today. If you only need balance, drop the fetch.
Pattern 5: Build against test keys without polluting live data
Mint a bq_test_ key from the dashboard. All writes route to an isolated test environment: separate balance, separate transactions, webhook destinations receive simulated deliveries rather than real HTTPS calls. Use this for local dev, CI tests, and integration sandboxes.
# 1. Mint (from the dashboard, or via POST /api-keys?mode=test)
# The test environment is provisioned automatically on first mint.
# 2. Use the test key in your test environment
export BRICQS_API_KEY=bq_test_xxxxx
# 3. Wipe test data between integration runs
curl -X POST https://api.bricqs.com/api/v1/admin/test-tenant/reset \
-H "Authorization: Bearer bq_live_xxxxx_admin"
# Reset wipes participant data: balances, transactions, claims,
# activity history. Definitions (badges, tiers, rewards) and the
# test API key are preserved so you do not have to re-seed.Test mode is metered with an abuse cap (generous enough for real test traffic, tight enough to prevent using it as a free unlimited tier). Webhooks fire with a simulated marker in the delivery log so you can verify wiring end-to-end without hitting your production endpoints.
Design choices teams regret later
Each pitfall is a structural decision you make at design time. Catch them in the dashboard and the spec, not after the program is live.
Treating balance as a derived view that can be recomputed from sum(transactions) on every read. Works at 1k transactions per user; falls over at 100k and forces a painful migration once the program scales.
Design choice: model the balance summary as a first-class cached projection that updates atomically with every ledger write. Reads hit the summary; the full transaction log only gets queried for history pages and audits. Bake this in on day one; retrofitting it after the fact is a major rewrite.
Decrementing the lifetime-earned counter on spend. Now your tier ranking and your leaderboards punish loyal customers for redeeming rewards: the user who never spends outranks the user who engages with the catalog.
Design choice: lifetime (total_earned) is monotonic and never decrements; only the spendable balance decrements on spend or expiry. Encode that partition in your schema and your UI so no future contributor can accidentally collapse the two.
Designing the points surface to be awardable from the browser. The user opens devtools, fires the POST manually, gets infinite points; you discover it weeks later when the redemption catalog runs out.
Design choice: server-side awards only. bq_live_ keys live on your backend; the browser can only read, never write. If the UX requires a 'tap to claim' action, route through your backend with auth so the points side is computed server-side, not client-supplied.
Modelling expiry as 'check expires_at on every read and subtract.' Now the balance is wrong until your code runs, and the inconsistency shows up in webhooks, exports, and audit logs that read the projection before the recompute lands.
Design choice: expiry is a background worker that writes counter-debits into the same ledger. The projection is always authoritative because the worker maintains it; downstream consumers see one consistent view regardless of when they read.
Letting earn rules drift between application code and Bricqs config. Each team owns a different multiplier; the same action awards different amounts depending on which surface the user touched.
Design choice: pick one source of truth for earn rules per program. If business logic is highly variable per segment or channel, compute the points value in your backend and treat Bricqs as a write-only ledger. If rules are uniform, configure them once in Bricqs and never compute amounts in app code.
Where to go next
Concepts, references, and patterns this page anchors. Pick the next read based on what you are about to build.
Badges
The atomic recognition primitive that pairs with points. Often awarded automatically when points cross a threshold.
Read itTiers
Tiers are derived from lifetime points. The tier evaluator reads the same ledger this page describes.
Read itProgression overview
How tiers, badges, streaks, points, and milestones fit together.
Read itPoints REST API reference
Full endpoint detail for award, deduct, balance, transactions, and batch award. Every parameter, every response field, every status code.
Read itRewards REST API reference
Reward definitions, inventory, and the atomic deduct-and-claim flow used to spend points on a reward catalog.
Read itHeadless SDK, progression hooks
usePoints reference plus the other progression hooks (useTier, useBadgesHeadless, useStreaks).
Read itPattern: loyalty tier engine
End-to-end walkthrough composing points, tiers, and rewards: earn rates, tier evaluation, redemption flow.
Read itPoints systems (strategy)
PM/lifecycle perspective: when to use points, earn-rate tuning, redemption catalog design.
Read itPoints vs tiers (strategy)
Which primitive solves which problem. Useful when deciding whether points are the right tool at all.
Read itAuthentication and API keys
How bq_live_ and bq_test_ keys work, scopes, and which key to use where.
Read itWebhooks (integration guide)
Subscribe to reward.points_awarded.v1, reward.points_expired, and other progression events. Set up destinations + signature verification.
Read itHeadless SDK setup
Provider, auth, and environment setup for usePoints and the other progression hooks.
Read itCommon questions when integrating
Build with points
Pair this with the strategy guide on points programs, or jump to the SDK setup.
