Rewards in headless gamification
A reward is the spendable side of a loyalty program. Coupons, vouchers, physical items, digital codes, points multipliers. The hard parts are not the catalog; the hard parts are inventory exhaustion, idempotent claims, atomic points deduction, and graceful handling of fulfillment failures. This page covers what a rewards system actually is, how Bricqs handles those edges, and the patterns you will reach for.
- A reward decomposes into a definition (catalog entry), an inventory (deliverable codes), and a claim (the participant record); claims are append-only and one claim allocates one code.
- Always pass an Idempotency-Key on claim requests so a checkout retry collapses to the original claim instead of allocating a second code from inventory.
- Use points_deduction: true to deduct points in the same transaction as the claim, so a balance is never debited without a code and a code is never allocated without the matching debit.
- Pre-load codes from the system that will validate them; reach for generated codes only when the code is opaque to downstream systems.
- Set max_claims_per_user to a real number per reward and treat free vs paid rewards as distinct UI surfaces so users never tap claim expecting a gift and lose points instead.
What a reward is, in any gamification system
Before the API. Every rewards system, regardless of vendor, decomposes into the same primitives. If you understand these, the Bricqs implementation is just one mapping you could make.
A reward is a deliverable a participant can claim from a defined catalog. The catalog entry (the reward definition) carries the name, type, optional points cost, and per-user claim limits. The inventory of codes (the deliverables) is consumed one at a time on each claim.
Engineers wiring redemption surfaces (rewards catalog page, claim confirmation modal, fulfillment webhooks); growth and lifecycle teams choosing what to offer and how to price it. Rewards are owned by product, served by the platform, fulfilled by engineering.
Points are the currency you earn and spend. Rewards are what you spend them on. The points engine tracks balance; the rewards engine maps balance to deliverables.
Badges are recognition; rewards are deliverables. Both can be triggered by the same event, but the contracts differ: badges accumulate and never run out, rewards are exhaustible inventory.
A prize is a reward allocated to a winner by the contest system. The contest decides who wins; the rewards engine delivers the code. Most prize allocations resolve to a reward claim under the hood.
A feature flag controls access; a reward is a deliverable. Sometimes the deliverable is access (a digital code that unlocks a feature), but the contracts differ: flags are reversible, claimed rewards consume inventory and emit fulfillment events.
The decomposition every rewards system uses: a definition (the reward as a noun: code, name, type, optional points cost, per-user limit, expiry policy), an inventory (the actual deliverable codes, allocated in order on claim), and a claim (the participant’s record: which code they got, when, with what points deducted). Definitions are tenant-scoped configuration. Inventory is uploaded once. Claims are append-only.
The three operations every API supports: list available rewards (the catalog), claim a reward (allocate a code to a participant, optionally deducting points), and read claims for a participant (what have they got?). Inventory management and definitions sit behind admin endpoints.
The hardest design decision is not the schema, it is the inventory contract. Two patterns dominate. (1) Pre-loaded codes: upload a list of codes once (10k coupon codes from Shopify), the engine allocates one per claim. Tight inventory tracking, runs out cleanly, easy to monitor. (2) Generated codes: the engine creates a unique code per claim on the fly. No inventory ceiling, harder to integrate with external coupon systems. Bricqs supports both, preferring pre-loaded codes when they exist and falling back to generated codes when they do not.
When the rewards API is the right tool, and when it is not
Rewards are real value moving to real users. Get the contracts right before you ship; the cost of a refund flow is much higher than the cost of an extra hour of design.
You have a finite catalog of codes to distribute
Coupons, gift cards, voucher codes. The rewards engine handles inventory allocation and prevents over-claim; you do not have to write the lock yourself.
The reward should cost points
The points deduction in the claim call runs in one step with the code allocation, so the participant's balance is never debited without them receiving a code, and a code is never allocated without the matching debit.
You want one-call idempotent redemption from a flaky checkout
Storefronts retry on network failure. The Idempotency-Key header makes the retry safe: the second call returns the original claim instead of allocating a second code.
Your fulfillment runs in another system
Once outbound webhooks for progression events ship, the claim event will trigger your fulfillment endpoint. Until then, your code path that calls /claim can call fulfillment inline.
The deliverable is just recognition
A badge or a tier already does that. Rewards are for things the participant takes possession of and uses (a code, a coupon, a download); recognition without a deliverable is a badge.
The reward should expire on the program side
Rewards can carry a per-code expiry (e.g. coupon valid 30 days), but if the entire program expires ("all rewards expire on Dec 31"), expire the definitions, not the claims. Once a code is in the participant's hands, treat it as their property.
You need fractional or partial redemption
Each claim is one code from inventory. If the user should be able to redeem half a code or apply a partial discount, model that in your billing system; the rewards engine's contract is one claim, one code, one delivery.
It is a leaderboard prize and you have not used the contests system
Contests handle ranking, fraud detection, tie-breaking, and prize allocation with their own idempotency. Allocating prizes by checking the leaderboard at midnight is brittle. Use the contests API; it calls the rewards engine under the hood with the right guarantees.
You want unlimited claims per user
Set max_claims_per_user to a real number. Unlimited claims invite abuse. If the intent is one per visit, model it as a separate claim per visit (timestamped); do not leave the door open.
- Looking for the marketing-team take? The strategy guide on reward systems covers when rewards fit a program, catalog design, KPIs, and example flows.
- Configure in the dashboard: Rewards → Catalog → New Reward.
Bricqs models rewards with the standard three-part decomposition above: a definition (the catalog entry), an inventory (the deliverable codes), and claims (the participant records). You create the definition through the dashboard or the admin API. You upload inventory (or let the engine generate codes). You hand out claims by calling POST /rewards/{reward_id}/claim from your backend.
Bricqs handles the heavy parts for you. Claims are safe to retry through an Idempotency-Key header. Inventory allocation is one-at-a-time so no two participants can grab the same code. Points deduction, when requested, runs in the same step as the claim, so a failed deduction never leaves an orphaned reward and a failed claim never leaves a phantom debit.
What a reward looks like
Three shapes you will see in API responses. The first is the catalog entry. The second is the claim response. The third is the per-participant claim record.
{
"id": "rwd_uuid_123",
"name": "10% Off Coupon",
"type": "coupon", // coupon | voucher | physical | digital | points_multiplier
"description": "10% discount on next purchase",
"points_cost": 500, // null = free
"available_codes": 45, // null = unlimited; counts down as codes are claimed
"max_claims_per_user": 1,
"expires_at": "2026-12-31T23:59:59Z" // per-definition expiry; null = no expiry
}available_codes is computed live. The field surfaces remaining inventory at read time, not a stored counter. null means the reward has no inventory cap (often the case for generated-code rewards or points multipliers). Render inventory warnings on your catalog when available_codes < threshold.
Two expiry fields, two different meanings. The catalog entry’s expires_at is when the reward stops being claimable. The claim response’s expires_at is when the allocated code stops being usable (computed at claim time as now + code_expiry_days).
{
"claim_id": "claim_uuid_456",
"participant_id": "user_42",
"reward_name": "10% Off Coupon",
"reward_type": "coupon",
"code_value": "SAVE10-ABC123", // the actual code allocated to the participant
"expires_at": "2026-06-23T...", // per-code expiry (now + definition's code_expiry_days)
"points_deducted": 500, // 0 if points_deduction was false
"new_balance": 1000 // participant's balance after deduction
}code_value semantics differ by reward type. For coupons and vouchers, it is the literal code the participant uses. For physical rewards, it is an order reference your fulfillment system uses. For digital rewards, it is the access key or download link. Always render something useful from the field in your UI; never leave it blank.
One response, both halves. The new balance is returned alongside the code, so you can render “You spent 500 points, new balance: 1000” without a second fetch.
{
"claim_id": "claim_uuid_456",
"reward_name": "10% Off Coupon",
"reward_type": "coupon",
"code_value": "SAVE10-ABC123",
"claimed_at": "2026-05-24T11:42:00Z",
"expires_at": "2026-06-23T..."
}What happens when a claim arrives
One sequence covers every claim: free, paid, retry, inventory-exhausted. The branches differ in detail; the spine is the same.
- 01
Your code calls POST /rewards/{id}/claim
Send aparticipant_id, an optionalpoints_deductionflag, and an Idempotency-Key header. - 02
The engine checks idempotency
If the Idempotency-Key has been seen before for this tenant, the original claim is returned unchanged. Retries are safe; the user never gets two codes from one logical claim. - 03
The engine validates the claim
Reward must be active, within its valid window, have inventory available, and the participant must not exceed max_claims_per_user. Failed validation returns a structured error (insufficient inventory, already at limit, expired). - 04
Points deduction runs atomically (if requested)
If points_deduction is true, the deduction and the claim run in one transaction. If the participant has insufficient balance, the entire call rolls back; no code is allocated, no points debited. - 05
The engine allocates a code
If pre-loaded codes exist, the oldest available code is allocated atomically. If no pre-loaded codes exist (and the reward allows generated codes), one is generated on the fly with a configurable prefix. - 06
The claim is recorded
An append-only claim row stores the allocation: participant, reward, code_value, claimed_at, expires_at, idempotency_key. This is the source of truth for redemption history. - 07
A reward-claimed fact is emitted
The factreward.reward_claimedincludes the reward name, type, and code_value. The React SDK listens for a corresponding client event and refreshes the participant’s claimed list automatically.
reward.reward_claimed fact is in the next platform release. Today the fact is emitted internally but not pushed to your endpoint; run your fulfillment inline in the same handler that calls /claim for now, or poll /participants/{id}/rewards from a sync job.List, claim, and read rewards from any backend
Three endpoints cover almost every case. All endpoints are under /api/v1/gamify/ and authenticate with an API key (bq_live_ for production, bq_test_ for sandbox).
List available rewards
Returns the catalog of active rewards for the tenant. Filter by type to render a category view. Use the available_codes field to surface inventory warnings.
curl 'https://api.bricqs.com/api/v1/gamify/rewards?reward_type=coupon' \
-H "Authorization: Bearer bq_live_xxxxx"
# Response (200 OK)
{
"rewards": [
{
"id": "rwd_uuid_123",
"name": "10% Off Coupon",
"type": "coupon",
"description": "10% discount on next purchase",
"points_cost": 500,
"available_codes": 45,
"max_claims_per_user": 1
}
],
"total": 1
}Read-mostly endpoint. The catalog changes infrequently; cache it server-side for a few minutes and render server-side. Per-participant claims happen separately from a client-side button.
Filter by type. Useful when you have distinct catalog surfaces (coupons in one tab, physical rewards in another). The same API key sees all active rewards.
Claim a reward
Allocates one code from inventory to the participant. Pass points_deduction: true to deduct in one step the reward’s points cost. Always include an Idempotency-Key header so retries do not allocate duplicate codes.
curl -X POST https://api.bricqs.com/api/v1/gamify/rewards/rwd_uuid_123/claim \
-H "Authorization: Bearer bq_live_xxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345-coupon" \
-d '{
"participant_id": "user_42",
"points_deduction": true
}'
# Response (201 Created)
{
"claim_id": "claim_uuid_456",
"participant_id": "user_42",
"reward_name": "10% Off Coupon",
"reward_type": "coupon",
"code_value": "SAVE10-ABC123",
"expires_at": "2026-06-23T...",
"points_deducted": 500,
"new_balance": 1000
}Idempotency-Key is non-negotiable. Tie it to the user-visible action (order id, session id, campaign id) so a retry collapses to the original claim and a deliberate new redemption gets a fresh code.
Structured errors. The endpoint returns stable error codes (insufficient_points, inventory_exhausted, max_claims_exceeded) so you can map them to human-readable copy in your UI.
List a participant’s claimed rewards
Returns every reward the participant has claimed, with the code value and expiry. Use this for a “your rewards” profile page.
curl https://api.bricqs.com/api/v1/gamify/participants/user_42/rewards \
-H "Authorization: Bearer bq_live_xxxxx"
# Response (200 OK)
{
"participant_id": "user_42",
"rewards": [
{
"claim_id": "claim_uuid_456",
"reward_name": "10% Off Coupon",
"reward_type": "coupon",
"code_value": "SAVE10-ABC123",
"claimed_at": "2026-05-24T11:42:00Z",
"expires_at": "2026-06-23T..."
}
],
"total_claimed": 1
}Manage reward definitions and inventory (admin only)
Most teams create rewards and upload inventory in the dashboard. For automation (CI seeding, mirroring from another system), use the admin endpoints with an admin-scoped API key.
GET /api/v1/gamify/rewards
POST /api/v1/gamify/rewards
{ "name": "10% Off", "type": "coupon", "points_cost": 500, "max_claims_per_user": 1 }
PATCH /api/v1/gamify/rewards/{reward_id}
{ "points_cost": 400 }
DELETE /api/v1/gamify/rewards/{reward_id}
# Inventory
POST /api/v1/gamify/rewards/{reward_id}/codes
{ "codes": ["SAVE10-ABC123", "SAVE10-DEF456", ...] }Render claimed rewards with one hook
The useRewardsHeadless hook returns the participant's claimed rewards. It listens for a reward:claimed client event and re-fetches automatically when a new claim lands. Listing the catalog and calling claim happen through the REST endpoint or the client API directly.
"use client";
import { useRewardsHeadless } from "@bricqs/sdk-react";
export function MyRewards({ engagementId }: { engagementId: string }) {
const {
rewards, // array of claimed rewards
totalClaimed, // total count
isLoading,
error,
refresh,
} = useRewardsHeadless({ engagementId });
if (isLoading) return <p>Loading your rewards…</p>;
if (error) return <p>Could not load rewards.</p>;
if (!rewards.length) {
return <p>No rewards claimed yet. Visit the catalog to spend your points.</p>;
}
return (
<div>
<h2 className="text-lg font-semibold mb-3">Your rewards ({totalClaimed})</h2>
<ul className="space-y-3">
{rewards.map((r) => (
<li key={r.claim_id} className="rounded-xl border border-slate-200 p-4">
<div className="flex items-baseline justify-between">
<span className="font-semibold">{r.reward_name}</span>
<span className="text-xs uppercase text-slate-500">{r.reward_type}</span>
</div>
<code className="block mt-2 font-mono text-sm bg-slate-50 rounded px-2 py-1">
{r.code_value}
</code>
{r.expires_at && (
<p className="mt-2 text-xs text-slate-500">
Expires {new Date(r.expires_at).toLocaleDateString()}
</p>
)}
</li>
))}
</ul>
</div>
);
}No polling by default. useRewardsHeadless does not auto-poll; it relies on the reward:claimed client event to know when to re-fetch. After a successful claim from your own code, you can also call refresh() to update immediately.
The hook reads claimed rewards only. Listing the catalog and calling claim are separate actions. Render the catalog with a server-side fetch (it changes slowly) and call claim through your own backend proxy. The hook handles the “what do I have right now” surface.
Field shape. The hook returns rewards with snake_case wire fields (claim_id, code_value) plus top-level camelCase shortcuts (totalClaimed). A consistent camelCase boundary at the SDK is on the roadmap.
Recipes you will reach for
Four patterns that cover most reward implementations in production. Each is a small composition of the primitives above.
Pattern 1: Storefront-driven redemption with retry safety
The participant clicks Redeem in your storefront. Your backend issues the claim with an Idempotency-Key derived from the order or session id. Network flake during checkout triggers a retry; the second call returns the same coupon code instead of allocating a fresh one.
export async function redeemCoupon(participantId: string, rewardId: string, orderId: string) {
const res = await fetch(
`https://api.bricqs.com/api/v1/gamify/rewards/${rewardId}/claim`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
"Content-Type": "application/json",
// Tying the key to the order means a checkout retry collapses
// to one claim, but a deliberate new redemption (different order)
// gets a fresh code.
"Idempotency-Key": `order-${orderId}-reward-${rewardId}`,
},
body: JSON.stringify({
participant_id: participantId,
points_deduction: true,
}),
}
);
if (!res.ok) {
const error = await res.json();
// Map structured errors to user-visible messages
if (error.type === "insufficient_points") return { ok: false, reason: "low_balance" };
if (error.type === "inventory_exhausted") return { ok: false, reason: "sold_out" };
if (error.type === "max_claims_exceeded") return { ok: false, reason: "limit" };
throw new Error(`unexpected reward error: ${error.code}`);
}
const claim = await res.json();
return { ok: true, code: claim.code_value, newBalance: claim.new_balance };
}The one-step points deduction means the participant's balance can never end up debited without a coupon, and the coupon can never be allocated without a debit. Both halves ship in the response, so you can render the new balance without a second fetch.
Pattern 2: Inventory monitoring and low-stock alerting
Surface inventory warnings on your catalog page. Use the available_codes field to mark items as low stock or hide them when exhausted. Run a separate watcher that pings ops when inventory drops below threshold so you can top up before the catalog goes empty.
// Run every 30 minutes
const res = await fetch(
"https://api.bricqs.com/api/v1/gamify/rewards",
{ headers: { Authorization: `Bearer ${process.env.BRICQS_API_KEY}` } }
);
const { rewards } = await res.json();
for (const r of rewards) {
if (r.available_codes != null && r.available_codes < 50) {
await slack.send({
channel: "#rewards-ops",
text: `Reward "${r.name}" has only ${r.available_codes} codes left.`,
});
}
}Thresholds are taste; 50 codes is a reasonable default for moderate-traffic catalogs, lower for slow movers, higher for hot ones. Track each reward's claim velocity in your own analytics and set the threshold to cover your top-up SLA.
Pattern 3: Free reward as a campaign deliverable
Sometimes a reward is just a gift, not a redemption. Set points_cost: null on the definition and points_deduction: false on the claim. The claim still consumes inventory and is still idempotent, but no points move.
// Gift a birthday coupon to every participant with a birthday today
const birthdays = await db.query(`
SELECT user_id FROM users WHERE birthday_md = date_format(NOW(), '%m-%d')
`);
for (const { user_id } of birthdays.rows) {
await fetch(
`https://api.bricqs.com/api/v1/gamify/rewards/${BIRTHDAY_REWARD_ID}/claim`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `birthday-${user_id}-${new Date().getFullYear()}`,
},
body: JSON.stringify({
participant_id: user_id,
points_deduction: false,
}),
}
);
}The year-scoped Idempotency-Key gifts one birthday coupon per participant per year. Re-running the job tomorrow on today's same participants is a safe no-op.
Pattern 4: Server-rendered catalog with ISR cache
The catalog changes infrequently (new rewards on a weekly cadence at most). Render it server-side and cache for a few minutes; per-participant claims happen separately from a client-side button. This keeps the catalog page fast and the claim flow correct.
async function loadCatalog() {
const res = await fetch("https://api.bricqs.com/api/v1/gamify/rewards", {
headers: { Authorization: `Bearer ${process.env.BRICQS_API_KEY}` },
next: { revalidate: 300 }, // 5 minutes
});
return res.json();
}
export default async function RewardsCatalog() {
const { rewards } = await loadCatalog();
return (
<ul>
{rewards.map((r: any) => (
<li key={r.id}>
<h3>{r.name}</h3>
<p>{r.points_cost ?? 0} points</p>
{r.available_codes != null && r.available_codes < 10 && (
<p className="text-amber-700">Only {r.available_codes} left!</p>
)}
<ClaimButton rewardId={r.id} />
</li>
))}
</ul>
);
}ClaimButton is a client component that POSTs to your own server route (which holds the API key) and shows the claim result. The catalog is read-mostly; claims are write-mostly. Render the first from a cached server pass and the second through a transactional path.
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.
Choosing generated codes when a partner system already owns the inventory. The engine happily mints unique strings, but your point-of-sale, voucher provider, or Shopify rules will reject codes it never issued. You discover this the first time a customer tries to redeem.
Design choice: pre-load codes from the system that will actually validate them. Reach for generated codes only when the code is opaque to downstream systems (a session token, a one-off pickup reference). When a partner owns the namespace, mirror their codes into the inventory and treat exhaustion as a top-up signal.
Designing a paid reward that looks identical to a free one in the UI. Same card, same copy, same button; the points cost shows in tiny grey type next to the title. Users tap claim expecting a gift and feel tricked when the balance drops.
Design choice: treat free rewards (points_cost: null) and paid rewards as distinct surfaces. Different card layout, different button copy (Claim vs Redeem for X points), explicit confirmation step on paid claims. The Bricqs schema lets you model both; your UI is where the distinction needs to land.
Leaving max_claims_per_user unset, or setting it to a polite-sounding number like 10 without thinking about how the participant earns claims. A single user with a bot harvests the entire daily inventory in a minute, or one enthusiastic fan claims every coupon in the catalog because no one capped them.
Design choice: model max_claims_per_user as the answer to one question, How many of this reward does one user legitimately need per claim window? For coupons, almost always 1 per definition. For points multipliers, often 1 per month. Pick a real number per reward; do not default to unlimited.
Splitting the points debit from the claim across two API calls (deduct first, claim second) because it feels easier to reason about. The first ship-stopping outage will be a participant with 500 fewer points and no coupon, or a coupon allocated with no debit. Now you owe them both.
Design choice: always use points_deduction: true on the claim itself, so the engine runs both halves in one transaction. Treat the two-call shape as a design smell; if you cannot avoid it (you have a separate billing system), wrap the call sequence in compensating logic and budget the engineering time up front.
Setting code expiry policy at the program level ("all our coupons expire in 30 days") instead of per-reward. A high-value voucher with the same 30-day clock as a small discount coupon leaks goodwill; a campaign-bound reward outliving the campaign becomes a support escalation.
Design choice: pick code_expiry_days per reward definition, anchored on the reward's economic life. Long-tail rewards (annual gift cards) get long windows; campaign rewards expire with the campaign. Document the policy alongside the catalog so the support team can answer why a code stopped working without escalating.
Where to go next
Concepts, references, and patterns this page anchors. Pick the next read based on what you are about to build.
Points
The currency rewards are spent against. Covers the ledger, idempotency, the lifetime vs balance distinction, and the atomic deduction model that /claim uses.
Read itTier
Some rewards are tier-gated (only Gold+ can claim). Tier evaluation runs in the same code path as points; both are needed to model earn points, unlock a Gold reward, claim it.
Read itRewards REST API reference
Full endpoint detail: every parameter, every response field, every status code.
Read itHeadless SDK, progression hooks
useRewardsHeadless reference + the other progression hooks (useTier, usePoints, useBadgesHeadless).
Read itLoyalty tier engine
End-to-end walkthrough composing points, tiers, and rewards into a loyalty engine.
Read itReward systems
PM/lifecycle perspective: when rewards fit a program, catalog design, KPIs, and example flows. Pairs with this dev guide.
Read itAuthentication and API keys
How bq_live_ and bq_test_ keys work, scopes, and which key to use where.
Read itWebhooks
Set up outbound destinations and signature verification. Needed once progression webhooks ship for the reward-claimed event.
Read itCommon questions when integrating
Build with rewards
Pair this with the strategy guide on reward systems, or jump straight to the SDK setup page.
