BricqsBricqs
Concept · Rewards13 min read · For engineers + technical PMs

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.

useRewardsHeadless
One hook returns claimed rewards; claim runs through your server.
Key takeaways
  • 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.
1. The concept

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.

Definition

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.

Who owns it

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.

How it differs from adjacent mechanics
vs points

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.

vs badges

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.

vs prizes (in contests)

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.

vs feature flags

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.

2. Design decisions

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.

Reach for a reward when
01Finite catalog

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.

02Points cost

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.

03Retry-safe

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.

04External fulfilment

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.

Do not use a reward when
01Pure recognition

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.

02Program-wide expiry

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.

03Partial redemption

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.

04Wrong system

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.

05Unlimited claims

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.

Before you read on
  • 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.

3. Data model

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.

jsonCatalog entry (one row in GET /rewards)
{
  "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).

jsonClaim response (POST /rewards/{id}/claim)
{
  "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.

jsonParticipant claim record (one entry per claim)
{
  "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..."
}
4. Lifecycle

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.

  1. 01

    Your code calls POST /rewards/{id}/claim

    Send a participant_id, an optional points_deduction flag, and an Idempotency-Key header.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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.
  7. 07

    A reward-claimed fact is emitted

    The fact reward.reward_claimed includes the reward name, type, and code_value. The React SDK listens for a corresponding client event and refreshes the participant’s claimed list automatically.
RoadmapOutbound webhook delivery for the 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.
5. REST API

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.

bashGET /api/v1/gamify/rewards
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.

bashPOST /api/v1/gamify/rewards/{reward_id}/claim
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.

bashGET /api/v1/gamify/participants/{participant_id}/rewards
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.

bashReward definition + inventory CRUD (admin key required)
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", ...] }
6. React SDK

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.

tsxcomponents/MyRewards.tsx
"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&hellip;</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.

7. Common patterns

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.

tsserver.ts (your checkout handler)
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.

tsjobs/rewardInventoryWatcher.ts
// 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.

tscampaigns/birthday.ts (your scheduled job)
// 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.

tsxapp/rewards/page.tsx (Next.js App Router)
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.

8. Design pitfalls

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.

Mistake

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.

Fix

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.

Mistake

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.

Fix

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.

Mistake

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.

Fix

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.

Mistake

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.

Fix

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.

Mistake

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.

Fix

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.

FAQ

Common questions when integrating

Wire it up

Build with rewards

Pair this with the strategy guide on reward systems, or jump straight to the SDK setup page.