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

Badges in headless gamification

A badge is the simplest gamification primitive: a named recognition a user earns and keeps. It looks trivial. Done right, it shapes onboarding, retention, and feature discovery. Done wrong, it becomes a graveyard of icons nobody cares about. This page covers the concept, the design decisions, and a worked implementation using Bricqs.

useBadgesHeadless
One hook returns earned and unearned badges with status.
Key takeaways
  • A badge is a permanent, named recognition awarded to a user for satisfying a condition; it carries identity, context, and a timestamp, but no numeric value and no expiry.
  • Badges accumulate forever and never get revoked, while points are spendable currency and tiers are mutually exclusive levels that can move up or down.
  • Badge awards are idempotent on the composite of (participant_id, badge_code), so duplicate triggers return the original award with already_earned: true.
  • Codes are immutable identifiers used in API calls; pick lowercase snake_case codes you can live with for years, and treat name and description as the user-facing labels you can edit later.
  • Use badges for recognition, never for feature access; gate features with a tier or feature flag so revoking is never a regression.
1. The concept

What a badge is, in any gamification system

Before the API. Every badge 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 badge is a permanent, named recognition awarded to a user for satisfying a condition. It carries identity (an icon and label), context (why it was earned), and a timestamp. It does not carry a numeric value or expire.

Who owns it

Engineers wiring badges into product surfaces (dashboards, profile pages, post-action modals); growth and lifecycle teams choosing what to recognize. Badges are owned by product, surfaced by engineering.

How it differs from adjacent mechanics
vs points

Points are fungible currency: spendable, comparable, summable. Badges are atomic markers: not spendable, not summed. A user has badge X or they do not.

vs tiers

Tiers are mutually exclusive levels (Bronze, Silver, Gold). A user is at exactly one tier. Badges accumulate; you can hold dozens.

vs achievements (Steam-style)

Achievements are badges with a stricter contract: usually game-specific, often hidden, almost always permanent. Badges in business apps tend to be more visible and use richer rarity tiers.

vs milestones

A milestone is a state change ("hit 100 referrals"). A badge is a recognition. Milestones often trigger badges, but the milestone is the threshold logic and the badge is the surface.

The decomposition every badge system uses: a definition (the badge as a noun: code, name, icon, rarity, optional category) and an award (the badge as a verb: this user got this badge at this time, with optional metadata). Definitions are tenant-scoped configuration. Awards are participant-scoped events.

The three operations every API supports: list definitions (what badges exist?), award a badge (give it to a user), and read awards for a user (what does this user have?). Everything else is convenience: filtering by category, hidden badges, rarity rollups, locked-state rendering.

The hardest design decision is not the schema, it is the trigger model. How do badges get awarded? Three patterns dominate. (1) Server-evaluated rules: the platform watches user state and awards automatically when conditions hit. Lowest integration cost, hardest to debug. (2) Client-fired awards: your app POSTs when the user does something noteworthy. More control, more code, easier to test. (3) Engagement-completion actions: a quiz or game completion includes an “award badge X” side effect. Bricqs supports all three.

2. Design decisions

When badges are the right tool, and when they are not

Badges look free, but every badge you ship has an ongoing maintenance and UX cost. Pick the moments where they earn their keep.

Reach for a badge when
01Progress marker

You want to mark progress without changing balance

Badges do not affect spendable points. Use them when the recognition is the reward: first quiz, profile completion, hitting a usage milestone.

02Rare moment

The user did something rare and you want to celebrate it

Rare-tier badges with surprise modals work because they signal genuine difficulty. Bronze badges for trivial actions devalue the system.

03Stable identifier

You need a stable identifier for an accomplishment

Badges have stable codes you can join against in analytics, segmentation, and external systems. Webhooks fire on award, so you can sync to CRM, Slack, or your data warehouse.

04Onboarding trail

Onboarding needs a visible progress trail

A 'getting started' badge category gives new users a clear list of what to do next. Each row is concrete: do this, get that.

Do not use a badge when
01Spendable

The user should be able to spend or trade the recognition

That is points or a reward, not a badge. Badges cannot be spent; if you award a 'Free Coffee Badge', users will be confused when it does not redeem to anything.

02Expiring

You want it to expire

Badges are permanent by design. If you need a recognition that disappears (e.g. monthly status), use a tier or a streak. Both have built-in time semantics.

03High frequency

The condition fires constantly

Idempotency saves you from double-awards, but emitting an award event every page view still creates noise. If the trigger fires more than once a session, you probably want a streak or a points-counter, not a badge.

04Leaderboard

It is a numeric leaderboard

Ranking is for points or score, not badges. A 'Top 10 of the Month' is a leaderboard slot, not a badge; awarding the badge each month means stripping it the next month, which violates the permanence contract.

05One campaign

You only need it for one event campaign

Badges add to a permanent collection. A two-week campaign badge will sit in the user's profile forever as an awkward archaeology layer. Consider a streak or a contest entry instead.

Before you read on
  • Looking for the marketing-team take? The strategy guide on progression systems covers when badges fit a campaign, KPIs to track, and example programs.
  • Configure in the dashboard: Progression → Badges → New Badge.

Bricqs models badges with the standard two-part decomposition above: a definition and an award. You create the definition through the dashboard or the admin API. You hand out the award by triggering it from an engagement, a challenge milestone, or a direct API call from your backend.

Bricqs handles the heavy parts for you. Awards are idempotent: if the same award fires twice (a network retry, a duplicate triggering), the second one is a no-op. Badges never get revoked. Your React UI updates automatically through the SDK; your backend can read awards on demand through the REST API.

3. Data model

What a badge looks like

Two shapes you will see in API responses and SDK hooks. The first is the badge definition (the template). The second is a participant's view of a badge (template plus earned status).

jsonBadge definition (one row in your Badges table)
{
  "id": "b3f81a0c-...",          // unique id, generated by Bricqs
  "code": "first_quiz",          // your code, what you use in API calls
  "name": "First Quiz",          // shown in the UI
  "description": "Complete your first quiz.",
  "icon_url": "https://cdn.example.com/badges/first-quiz.png",
  "badge_color": "#E05D36",      // optional, used for accents
  "rarity": "common",            // common | uncommon | rare | epic | legendary
  "category": "onboarding",      // optional, your grouping
  "is_active": true,             // false = paused, will not be awarded
  "is_hidden": false,            // true = hide from UI until earned
  "display_order": 1,            // sort order in the badge grid
  "created_at": "2026-05-20T10:30:00Z",
  "updated_at": "2026-05-20T10:30:00Z"
}

Pick your codes carefully. The code field is what you reference in API calls and in dashboard actions. It cannot be changed later without re-issuing every existing award. Use lowercase snake_case (e.g. first_quiz, power_user).

Hidden badges still show up in the API list, but with all fields except code and earned stripped, until the user earns one. Useful for surprise badges.

jsonWhat a participant sees (one entry per badge they have access to)
{
  "code": "first_quiz",
  "name": "First Quiz",
  "description": "Complete your first quiz.",
  "icon": "https://cdn.example.com/badges/first-quiz.png",
  "rarity": "common",
  "category": "onboarding",
  "earned": true,                          // false = not earned yet (locked)
  "earned_at": "2026-05-20T11:42:00Z"      // null if not earned
}
4. Lifecycle

What happens when a badge is awarded

One sequence covers every way a badge gets earned, whether the trigger is a quiz completion, a challenge milestone, or a direct API call.

  1. 01

    Something happens

    A user finishes a quiz. A challenge milestone hits 100%. Your backend decides the user qualifies for a badge.
  2. 02

    The trigger fires

    Three places this can come from: a quiz, spin, or form completion that includes an “award badge” rule; a challenge milestone hitting its mark; or your backend calling POST /api/v1/gamify/badges/award directly.
  3. 03

    Bricqs checks if the user already has the badge

    If yes, the original award is returned unchanged with already_earned: true. Retries are safe: a duplicate trigger does not produce a second award.
  4. 04

    Otherwise, the badge is recorded

    The award is saved with a timestamp and a stable id. That record is the source of truth: what the read endpoints return, what your analytics warehouse can join against, what the SDK hook reads.
  5. 05

    The SDK refreshes

    Any React component using the useBadgesHeadless hook re-renders with the new badge marked as earned. If the badge was awarded as part of completing a quiz or spin, the activity webhook payload includes the awarded badge so your CRM stays in sync without any extra wiring.
A note on webhooksDedicated badge.earned webhooks (for badges awarded outside an engagement: direct API calls, challenge milestones) are on the roadmap. Today, the only outbound webhook events are engagement activity completions (quiz, spin, form, etc.). For now, poll the badges endpoint or use the SDK to detect awards from other sources.
5. REST API

Award and read badges 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).

Award a badge

Call this from your backend when you decide a user has earned a badge. Safe to retry: calling twice returns the same award the second time.

bashPOST /api/v1/gamify/badges/award
curl -X POST https://api.bricqs.com/api/v1/gamify/badges/award \
  -H "Authorization: Bearer bq_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "participant_id": "user_42",        // your user's id
    "badge_code": "first_quiz",         // matches the badge definition code
    "metadata": {                        // optional, anything you want stored
      "source": "quiz_completion",
      "quiz_id": "q_onboarding_1"
    }
  }'

# Response (201 Created)
{
  "participant_id": "user_42",
  "badge_code": "first_quiz",
  "badge_name": "First Quiz",
  "icon": "https://cdn.example.com/badges/first-quiz.png",
  "rarity": "common",
  "earned_at": "2026-05-20T11:42:00Z",
  "already_earned": false             // true if the user already had it
}

Idempotent by composite key. The pair (participant_id, badge_code) uniquely identifies an award, so retries return the original record with already_earned: true and the original earned_at timestamp.

Server-side only. Awards should be POSTed from your backend with a server-held API key, never from a client-side handler the user could fire from devtools.

Read a user’s badges

Returns all badges defined for your account, each marked with whether this specific user has earned it. Use this to render a badge grid.

bashGET /api/v1/gamify/participants/{participant_id}/badges
curl https://api.bricqs.com/api/v1/gamify/participants/user_42/badges \
  -H "Authorization: Bearer bq_live_xxxxx"

# Optional query params:
#   ?earned_only=true                 -> only return earned badges
#   ?badge_codes=first_quiz,power_user -> filter to specific codes

# Response (200 OK)
{
  "participant_id": "user_42",
  "total": 12,                  // total badges defined
  "earned_count": 3,            // how many this user has earned
  "badges": [
    {
      "code": "first_quiz",
      "name": "First Quiz",
      "icon": "https://cdn.example.com/badges/first-quiz.png",
      "rarity": "common",
      "category": "onboarding",
      "earned": true,
      "earned_at": "2026-05-20T11:42:00Z"
    },
    {
      "code": "power_user",
      "name": "Power User",
      "icon": "https://cdn.example.com/badges/power-user.png",
      "rarity": "epic",
      "category": "loyalty",
      "earned": false,
      "earned_at": null
    }
    // ...
  ]
}

Manage badge definitions (admin only)

Most teams create and edit badges in the dashboard. If you want to script it (e.g. seed badges in CI, import from another system), use the admin endpoints. They require an admin-scoped API key, not a participant key.

bashBadge definition CRUD (admin key required)
# List all badge definitions
GET    /api/v1/gamify/badges

# Get one
GET    /api/v1/gamify/badges/{badge_id}

# Create
POST   /api/v1/gamify/badges
       { "code": "first_quiz", "name": "First Quiz", "rarity": "common", ... }

# Update
PATCH  /api/v1/gamify/badges/{badge_id}
       { "name": "Quiz Beginner" }   // partial update

# Delete (soft-delete: existing awards are preserved)
DELETE /api/v1/gamify/badges/{badge_id}
6. React SDK

Render badges with one hook

The useBadgesHeadless hook returns all badges with earned status for the current participant. It polls on a configurable interval and you can call refresh() yourself after an event your code knows about.

tsxcomponents/BadgeGrid.tsx
"use client";
import { useBadgesHeadless } from "@bricqs/sdk-react";

export function BadgeGrid({ engagementId }: { engagementId: string }) {
  const {
    badges,      // all badges with earned status for this engagement
    earned,      // shortcut: only badges the user has earned
    unearned,    // shortcut: only badges the user has not earned
    isLoading,
    error,
    refresh,     // call refresh() to manually re-fetch
    isEarned,    // isEarned("first_quiz") -> true/false
  } = useBadgesHeadless({ engagementId, refreshInterval: 30000 });

  if (isLoading) return <p>Loading badges...</p>;
  if (error) return <p>Could not load badges.</p>;

  return (
    <ul className="grid grid-cols-3 gap-3">
      {badges.map((b) => (
        <li
          key={b.code}
          // dim unearned badges
          className={`rounded-xl border p-3 text-center ${
            b.earned ? "" : "opacity-40 grayscale"
          }`}
        >
          {b.icon && <img src={b.icon} alt="" className="w-12 h-12 mx-auto" />}
          <div className="text-sm mt-2 font-semibold">{b.name}</div>
          {b.earned && b.earned_at && (
            <div className="text-xs text-slate-500">
              Earned {new Date(b.earned_at).toLocaleDateString()}
            </div>
          )}
        </li>
      ))}
    </ul>
  );
}

Default poll interval is 30 seconds. Pass refreshInterval: 0 to disable polling entirely. After an action you know about (the user completed a quiz that awards a badge), call refresh() immediately rather than waiting for the next poll tick.

Filter at the source. Pass badgeCodes to only fetch a subset. Cheaper than filtering client-side when you only need a few.

Field shape. The hook returns badges with snake_case fields (earned_at, not earnedAt) to match the wire format. A camelCase boundary at the SDK is on the roadmap.

7. Common patterns

Recipes you will reach for

Five patterns that cover most badge implementations in production. Each is a small composition of the primitives above.

Pattern 1: Award via a rule on an engagement event

The user completes a quiz, spin, or form. A rule listens for the corresponding fact (for example, behavior.quiz_completed.v1) and fires an unlock_badge action. The rule's conditions decide whether the badge is awarded on every completion or only when a threshold is met. Configured in the dashboard once, no application code required.

bashPOST /api/v1/rules (or configure in the dashboard)
curl -X POST https://api.bricqs.com/api/v1/rules \
  -H "Authorization: Bearer bq_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Award first_quiz badge on passing quiz",
    "trigger_event": "behavior.quiz_completed.v1",
    "conditions": [
      { "field": "properties.score", "op": "gte", "value": 70 }
    ],
    "actions": [
      {
        "type": "unlock_badge",
        "config": { "badge_code": "first_quiz" }
      }
    ]
  }'

# Rules can also be created and edited from the dashboard UI.

The rules engine evaluates on every matching fact, awards the badge, and emits a badge_unlocked fact. Idempotency is automatic: the engine's composite key prevents double-awards even if the source fact is re-emitted. The same engine handles all the rule actions available (award_points, unlock_badge, set_tier, and others).

Pattern 2: Award when a metric crosses a threshold (milestone, then rule)

Some triggers do not fit inside an engagement. Example: award a badge when the user reaches 10 successful referrals. Two steps. First, create a milestone that watches the metric and emits a fact when the threshold is hit. Second, configure a rule that listens for that fact and awards the badge.

bashStep 1: create the milestone; Step 2: handle the fact
curl -X POST https://api.bricqs.com/api/v1/progression/milestones \
  -H "Authorization: Bearer bq_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "ten_referrals",
    "name": "10 Successful Referrals",
    "metric_type": "referral_count",
    "threshold": 10,
    "scope": "global"
  }'

# When the participant hits 10 referrals, the milestone emits:
#   progression.milestone_reached.v1
#   payload includes milestone_code, current_value, threshold

// Step 2: your webhook handler subscribed to progression.milestone_reached.v1
export async function POST(req: Request) {
  const event = await verifyAndParse(req); // your signature check

  // Filter to just the milestone we care about
  if (event.payload.milestone_code !== "ten_referrals") {
    return new Response(null, { status: 200 });
  }

  await fetch("https://api.bricqs.com/api/v1/gamify/badges/award", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      participant_id: event.participant_id,
      badge_code: "referral_champion",
    }),
  });

  return new Response(null, { status: 200 });
}

Milestone progress is tracked per participant, so the underlying fact fires exactly once per threshold crossing. The badge award is composite-idempotent on (participant_id, badge_code), so even if your webhook retries you will not double-record.

Pattern 3: Fire from your backend (custom event)

The trigger lives in your domain (e.g. order completion in your e-commerce backend). Award the badge directly from your server in the same request handler. Cleanest path for non-Bricqs triggers.

tsserver.ts (your backend, after the domain event)
// In your order webhook handler
await fetch("https://api.bricqs.com/api/v1/gamify/badges/award", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    participant_id: order.user_id,
    badge_code: "first_order",
    metadata: { order_id: order.id, order_value: order.total },
  }),
});

// Retries are safe by default. Badge awards are idempotent on the
// composite of (participant_id, badge_code). A second POST returns
// the original award with already_earned: true; nothing is recorded
// twice and earned_at preserves the first award's timestamp.

No Idempotency-Key header is needed for badge awards: the composite key on (participant_id, badge_code) gives you idempotency for free. Endpoints where the same call can legitimately create distinct records (like /points/award) do accept the header; see the points guide.

Pattern 4: Hidden surprise badge

Set is_hidden: true on the definition. The API returns a stripped-down row to unearned participants (just code and earned: false) so they cannot infer what it is. On award, the full row appears and your UI can fire a celebration modal.

tsxcomponents/SurpriseBadgeWatcher.tsx
"use client";
import { useEffect, useRef } from "react";
import { useBadgesHeadless } from "@bricqs/sdk-react";
import { celebrateBadge } from "./celebrate";

export function SurpriseBadgeWatcher({ engagementId }: { engagementId: string }) {
  const { earned } = useBadgesHeadless({ engagementId });
  const seen = useRef(new Set<string>());

  useEffect(() => {
    for (const b of earned) {
      if (seen.current.has(b.code)) continue;
      seen.current.add(b.code);
      // Only celebrate if this is a fresh award (not an initial-load hydration).
      if (b.earned_at && Date.now() - new Date(b.earned_at).getTime() < 5000) {
        celebrateBadge(b);
      }
    }
  }, [earned]);

  return null;
}

The watcher only celebrates badges newly added since first load, so a returning user does not get a confetti shower for badges they already had. The hidden definition keeps the surprise intact until the award lands.

Pattern 5: Rarity-driven visual treatment

The rarity field is intended to be a UI signal, not just metadata. Map each rarity to a visual treatment (border, glow, animation) so users read the prestige at a glance. Common badges get a flat icon; legendary badges get a glow.

tsxcomponents/BadgeChip.tsx
const RARITY_STYLES: Record<string, string> = {
  common:    "border-slate-200",
  uncommon:  "border-emerald-300",
  rare:      "border-blue-400 shadow-blue-100 shadow-md",
  epic:      "border-purple-500 shadow-purple-200 shadow-lg",
  legendary: "border-amber-400 shadow-amber-200 shadow-xl animate-pulse-slow",
};

export function BadgeChip({ badge }: { badge: Badge }) {
  return (
    <div className={`rounded-xl border-2 p-3 ${RARITY_STYLES[badge.rarity]}`}>
      <img src={badge.icon} alt={badge.name} className="w-10 h-10" />
      <div className="text-sm font-semibold mt-2">{badge.name}</div>
    </div>
  );
}

Treat rarity as a five-step ladder, not a free-form taxonomy. The visual jump from common to legendary should be obvious at a glance; if users cannot tell the levels apart, the rarity stops carrying signal.

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

Using badges to gate features ("unlock dark mode at 100 referrals"). The gating logic then has to check badge presence on every render, and revoking a badge becomes a feature regression.

Fix

Design choice: badges are recognition, not access control. Gate features with a tier or a feature flag. Tiers carry the entitlement contract; badges carry the celebration. They have different lifetime semantics for a reason.

Mistake

Treating the badge code as a mutable label. A PM asks to fix a typo or rename First Quiz to Quiz Beginner, and every existing award now references a code that does not exist; the API returns 404s on read.

Fix

Design choice: codes are immutable identifiers. Pick lowercase snake_case codes you can live with for years. The name field is what changes when copy is updated; if you must rename a code, plan a one-time migration script that copies awards to a new badge and deletes the old definition.

Mistake

Storing user-facing copy in the badge name field ("You earned this for completing 5 quizzes in a row!"). The name leaks into surfaces it was not designed for: chips, notification toasts, profile lists.

Fix

Design choice: the name is a short identifier (e.g. "Quiz Streak"). The description field is for the longer explainer. UI copy that varies by surface (modal vs grid vs notification) lives in your frontend, keyed on badge code.

Mistake

Designing a flat catalogue of bronze badges for trivial actions. Every login, every page view, every form field becomes a badge; the badge shelf turns into wallpaper and rare-tier awards lose their signal.

Fix

Design choice: reserve badges for moments worth celebrating. Use the rarity ladder deliberately; only ship a new badge when you can name the moment in a sentence. If a behaviour fires more than once per session, choose a streak or a points counter instead.

Mistake

Shipping a single-campaign badge into the permanent collection. A two-week launch badge sits in user profiles for years as awkward archaeology, and removing it would violate the permanence contract.

Fix

Design choice: badges are for the long-running shelf. For time-bound moments use a contest entry, a streak, or a tier. If a campaign must leave a permanent mark, design the badge to make sense out of campaign context (e.g. "Founding member 2026").

9. Pairs with

Where to go next

Concepts, references, and patterns this page anchors. Pick the next read based on what you are about to build.

FAQ

Common questions when integrating

Wire it up

Build with badges

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