BricqsBricqs

Esports social network on Bricqs, end to end

A complete proof-of-stack walkthrough. We wire up Qlan.gg — a hypothetical social network for esports creators — using only Bricqs primitives. Nine mechanics, every API call, every SDK hook, every UI component. Each mechanic shows both the headless React SDK path and the pure REST path, so teams on Vue, Svelte, Flutter, iOS, or any other stack see exactly how to ship the same thing without the SDK.

Last updatedMay 2026

Key takeaways

Quick read
  • Qlan.gg is an esports social network: gamer profiles, game-stats sync, follower graph, daily posts, comments, weekly leaderboards, season rewards.
  • Every mechanic maps to a Bricqs primitive: coins (points), badges, tiers, streaks, challenges, leaderboards, milestones, rewards, referrals, webhooks.
  • Two paths shown in parallel: headless React SDK (typed hooks) and pure REST (one GET per concern + a one-call GET /participants/{id}/state). Build the same product on any stack.
  • All scoring is server-side. The client posts events; the engine decides what advances, what completes, what unlocks. Cheating the UI does not move the ledger.
  • Idempotency keys on every write. Retries from a mobile client or a queue never double-credit a single coin.
  • reward.claimed.v1 and milestone.reached.v1 webhooks drive your fulfilment pipeline. No polling, no reconciliation jobs.

The product

Qlan.gg — esports creators, fans, and squads in one place

A social network where gamers fill a creator profile, sync their game stats, post highlight clips, follow each other, climb weekly leaderboards, hold streaks, and unlock season rewards. Coins are the in-app currency that everything else feeds into.

Profile completion

5-step onboarding (display name, game stats sync, Twitch/YouTube, favorite games, squad invites). Each step pays coins; completing all five awards the Streamer badge.

Daily activity loop

Coins for daily login, publishing a post, accumulating 5 comments, syncing fresh game stats. A coin balance the user actually feels every time they open the app.

Login streak with freezes

Daily streak that survives one missed day per week via freeze tokens. The Marathon badge unlocks at 30 days.

Follower milestones

Tiered rewards as the follower graph grows: 50 → Rising Star, 250 → Banner unlock, 1k → Pro tier + merch, 5k → Legend + payout.

Weekly leaderboards

Live ranking per game (Valorant, Apex, BGMI). Resets Monday. Top 10 enter the prize pool for the following week.

Badges

Persistent status markers. Some are activity-based (First Post, Marathon), some are positional (Top 100), some are seasonal (Wk-21 MVP).

Season tier rewards

Three milestones per season (Top 50% / 10% / 1%) with tiered prize pools and an HMAC-signed webhook on every allocation.

Weekly challenges

Bricqs challenges over a 7-day window — 'Post 5 clips this week' unlocks a badge plus a coin bonus.

Squad referrals

Double-sided: 200 coins each on join, plus a 5-friend milestone that gives both sides the Squad Captain badge.

The shape of the integration

One event in. Every program reacts.

Qlan.gg's backend posts events to /api/v1/gamify/events. The engine writes an immutable fact, fans it out to every subscribed program, and updates the participant's ledger. The React app reads the canonical state through SDK hooks. There is no second source of truth.

text
Qlan.gg backend (Node / Python / Go)
  │
  │  POST /api/v1/gamify/events  { post_published, profile_step_completed, ... }
  │
  ▼
Bricqs ingestion (the event buffer · idempotent · rate-limited)
  │
  ▼
Fact bridge — writes immutable fact, fans out to:
  ├─▶ Coins (points) engine    earn rules: post=20, comment_batch=10, etc.
  ├─▶ Badges                   First Post / Marathon / Streamer awarded on condition
  ├─▶ Streaks                  ticked by explicit /streaks/record calls (events do NOT tick)
  ├─▶ Follower milestones      fact_sum milestones at 50/250/1k/5k (delta-only events)
  ├─▶ Weekly leaderboards      the ranked store per game (Valorant, Apex, BGMI)
  ├─▶ Season tier rewards      contest with prize allocation by rank
  ├─▶ Weekly challenges        "Post 5 clips" with 7-day window
  ├─▶ Referrals                code → conversion → double-sided coins
  └─▶ Outbound webhooks        HMAC-signed POST to your fulfilment service

One POST. The engine handles ordering, dedupe, fan-out, and atomicity. Your code just POSTs the event.

Step 0 · before any mechanic

Connect your app to the Bricqs engine

Six things you do exactly once. Get keys, set up environments, install the SDK, mint participant tokens server-side, wire up the BricqsProvider, and smoke-test with a throwaway event. Every mechanic in this guide assumes these six things are in place.

1. Get a tenant and two pairs of API keys

In the Bricqs dashboard (Settings → API Keys), create a tenant for Qlan.gg and mint keys per environment: bq_test_* hits your isolated test sibling tenant, bq_live_* hits production. There is one key format; what a key can do is its SCOPES, chosen at mint: the default (events:write + gamify:read) emits events and reads state; add gamify:write for value movement (award, assign, claim); grant gamify:admin for program definitions and participant-token minting.

No API key ever reaches the browser (the SDK rejects bq_ prefixes client-side). Your backend uses its keys server-to-server; the client gets short-lived participant tokens, minted on demand from a server route with the admin-scoped key (next step).

2. Set up environment variables

.env.local (Qlan.gg web app)·bash
# Public — safe to expose
NEXT_PUBLIC_BRICQS_TENANT=tenant_qlan_gg
NEXT_PUBLIC_BRICQS_ENV=production
NEXT_PUBLIC_BRICQS_API_BASE=https://api.bricqs.co
NEXT_PUBLIC_BRICQS_ENGAGEMENT_ID=<engagement-uuid>  # hooks' shared default

# Server-only — never prefix with NEXT_PUBLIC_
BRICQS_ADMIN_API_KEY=bq_live_aV7p...   # gamify:admin scope: definitions + token minting
BRICQS_API_KEY=bq_live_xZ91...         # events:write + gamify:read (+ gamify:write where needed)
BRICQS_WEBHOOK_SECRET=...              # destination secret returned by POST /gamify/webhooks

3. Install the React SDK

bash
npm install @bricqs/sdk-react

# or
pnpm add @bricqs/sdk-react

4. Mint participant tokens server-side

The browser never holds an API key. On login, your backend trades the ADMIN-SCOPED key for a participant-pinned JWT (default TTL 5 minutes, server max 60), stores it in an HttpOnly cookie, and ships it to the SDK Provider on the next render — with a getToken callback for refresh.

app/api/bricqs/token/route.ts (Next.js Route Handler)·ts
import { cookies } from "next/headers";

export async function POST(req: Request) {
  const userId = await getCurrentUserId(req);
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const res = await fetch(`${process.env.NEXT_PUBLIC_BRICQS_API_BASE}/api/v1/auth/participant-token`, {
    method: "POST",
    headers: {
      "X-API-Key": process.env.BRICQS_ADMIN_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      participant_id: userId,
      // Optional: attach attributes that programs can filter on
      attributes: { tz: req.headers.get("x-user-tz") ?? "Asia/Kolkata" },
      expires_in_minutes: 30, // default 5, server clamps to max 60
    }),
  });

  const { token } = await res.json();
  cookies().set("bq_token", token, {
    httpOnly: true,
    sameSite: "lax",
    maxAge: 30 * 60,
    secure: process.env.NODE_ENV === "production",
    path: "/",
  });

  return new Response(JSON.stringify({ ok: true }));
}

The cookie is HttpOnly so JavaScript cannot read the raw token. The Provider receives it as a server-fetched prop on the next layout render.

5. Wrap the app in BricqsProvider

app/layout.tsx (Next.js App Router)·tsx
import { cookies } from "next/headers";
import { Providers } from "./providers";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // Read the participant token server-side so the first render has identity.
  const token = cookies().get("bq_token")?.value ?? null;

  return (
    <html lang="en">
      <body>
        <Providers participantToken={token}>{children}</Providers>
      </body>
    </html>
  );
}
app/providers.tsx·tsx
"use client";
import { BricqsProvider } from "@bricqs/sdk-react";

export function Providers({
  children,
  participantToken,
}: {
  children: React.ReactNode;
  participantToken: string | null;
}) {
  return (
    <BricqsProvider
      // Provider-level default the hooks inherit: useChallenge(),
      // useLeaderboard(), useBadgesHeadless() etc. resolve their engagement
      // from here and THROW if none is set anywhere.
      engagementId={process.env.NEXT_PUBLIC_BRICQS_ENGAGEMENT_ID}
      config={{
        participantToken: participantToken ?? undefined,
        apiUrl: process.env.NEXT_PUBLIC_BRICQS_API_BASE,
        // Refresh: called on first use and again when a request 401s
        // (tokens are short-lived by design).
        getToken: async () => {
          await fetch("/api/bricqs/token", { method: "POST" });
          const res = await fetch("/api/bricqs/token/read"); // your cookie->json bridge
          return (await res.json()).token;
        },
      }}
    >
      {children}
    </BricqsProvider>
  );
}

Hooks called below this provider read the canonical state; the tenant is derived from the token, not a prop.

6. Smoke-test from a server

From your backend or terminal·bash
# POST a throwaway event for a known user. This auto-creates the participant
# on first sight, so no separate registration call is needed.
curl -X POST https://api.bricqs.co/api/v1/gamify/events \
  -H "X-API-Key: $BRICQS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: smoke-once" \
  -d '{
    "participant_id": "smoke_test_user",
    "event_name": "smoke_test_ping",
    "properties": { "from": "setup-check" }
  }'

# Then verify the participant was created and the fact landed:
curl https://api.bricqs.co/api/v1/gamify/state/smoke_test_user \
  -H "X-API-Key: $BRICQS_API_KEY"

The participant is auto-created on first event. You never need a separate /participants endpoint unless you want to seed profile data.

7. Define your server helper (used by every later snippet)

The bricqs.* calls in the rest of this page are YOUR app's thin server wrapper, not an official SDK — the browser SDK deliberately has no server write methods. This is the whole module; it maps 1:1 onto the real REST contract.

lib/bricqs.ts (server-only helper — the ONLY 'bricqs.*' definition)·ts
const API = process.env.NEXT_PUBLIC_BRICQS_API_BASE!;

async function call(path: string, body: unknown, idempotencyKey?: string, admin = false) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: {
      "X-API-Key": admin ? process.env.BRICQS_ADMIN_API_KEY! : process.env.BRICQS_API_KEY!,
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    const err = (await res.json().catch(() => null))?.error;
    throw new Error(`Bricqs ${res.status} ${err?.code ?? ""}: ${err?.message ?? "unknown"}`);
  }
  return res.json();
}

export const bricqs = {
  events: {
    // POST /gamify/events — EmitEventRequest: participant_id, event_name, properties
    send: (e: { participant_id: string; event_name: string; properties?: Record<string, unknown>; idempotency_key?: string }) =>
      call("/api/v1/gamify/events", { participant_id: e.participant_id, event_name: e.event_name, properties: e.properties ?? {} }, e.idempotency_key),
  },
  points: {
    // POST /gamify/points/award — AwardPointsRequest: participant_id, amount, reason
    // (requires the gamify:write scope on BRICQS_API_KEY)
    award: (a: { participant_id: string; amount: number; reason?: string; idempotency_key?: string }) =>
      call("/api/v1/gamify/points/award", { participant_id: a.participant_id, amount: a.amount, reason: a.reason }, a.idempotency_key),
  },
};

One helper, real shapes, canonical Idempotency-Key header. Every bricqs.events.send / bricqs.points.award below is this module.

Step 1 · before any mechanic fires

Provision the base programs (coins, tiers, badges, rewards)

Mechanics reference programs by slug. Before you can wire up a single mechanic, the programs need to exist. This is a one-time admin step — run a provisioning script that creates the foundation, then never touch it again.

1. Create the “coins” currency

Coins are the points primitive with your product's label on it. The example uses the default points currency everywhere and simply renders it as “Coins” in the UI — no provisioning call needed; the ledger, idempotency, tier, and leaderboard wiring all come with the primitive. (Additional named currencies are configured on programs in the Builder; the read surface lists them at GET /api/v1/progression/currencies.)

2. Create the tier hierarchy

Qlan.gg has four tiers. Tiers are passive calculators — the engine recomputes a participant’s tier whenever lifetime coins cross a threshold, and promotions fire the tier.changed.v1 webhook. Each tier is one POST (real fields: code, name, level, criteria_config, benefits, point_multiplier).

POST /api/v1/gamify/admin/tiers (one call per tier)·bash
# Repeat for rookie(1, min 0), rising(2, min 500), legend(4, min 25000)
curl -X POST https://api.bricqs.co/api/v1/gamify/admin/tiers \
  -H "X-API-Key: $BRICQS_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "pro_creator",
    "name": "Pro Creator",
    "level": 3,
    "criteria_type": "points",
    "criteria_config": { "min_points": 5000 },
    "award_type": "automatic",
    "benefits": { "banner_color": true, "merch_eligible": true },
    "point_multiplier": 1.5,
    "color": "#7C3AED",
    "display_order": 3
  }'

benefits is a display list your UI renders; point_multiplier is applied by the points engine to awards while the tier is held.

3. Create the badge catalog

Twelve badges across three families. The badge DEFINITION carries identity and display (code, name, description, rarity, color, icon); the AWARD PATH is wired separately — as a challenge or milestone reward, a rule action, or a direct server call to POST /gamify/badges/award (gamify:write). There is no condition field on the definition itself.

POST /api/v1/gamify/admin/badges (run once per badge)·bash
# The Marathon badge — replicate for the other 11
curl -X POST https://api.bricqs.co/api/v1/gamify/admin/badges \
  -H "X-API-Key: $BRICQS_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "marathon",
    "name": "Marathon",
    "description": "Maintained the daily login streak for 30 days.",
    "badge_color": "#7C3AED",
    "rarity": "rare",
    "display_order": 3
  }'

# Its award path: a milestone on the daily_login streak metric at 30,
# with the badge as the milestone reward (configured in the Builder).

The full Qlan.gg catalog: first_post, streamer, marathon, highlight_reel, rising_star, squad_captain, top_100, wk_mvp_valorant, wk_mvp_apex, wk_mvp_bgmi, season_mvp, diamond.

4. Create reward definitions + upload code inventory

Rewards backed by physical merch or third-party codes need an inventory pool. The DEFINITION is one admin-API call; the CODE INVENTORY is managed in the dashboard (Rewards → codes: bulk-add or generate), which drives the internal code endpoints — there is no API-key path for code upload today. The engine atomically pops one code per claim so two parallel claims never collide.

POST /api/v1/gamify/admin/rewards·bash
curl -X POST https://api.bricqs.co/api/v1/gamify/admin/rewards \
  -H "X-API-Key: $BRICQS_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Qlan.gg branded overlay pack",
    "type": "physical",
    "description": "Twitch overlay + emote pack + sticker sheet.",
    "points_cost": 1000,
    "total_inventory": 200,
    "max_claims_per_user": 1
  }'
Code inventory (dashboard)·text
Dashboard -> Rewards -> "Qlan.gg branded overlay pack" -> Codes
  - Bulk-add your STR-XXXX codes (paste or CSV), or
  - Generate N random codes with a prefix.

Claims consume codes atomically; when inventory is exhausted, claims
return REWARD_INVENTORY_EXHAUSTED and your catalog UI should show
sold-out until you refill.

Inventory exhaustion is a stable error code (REWARD_INVENTORY_EXHAUSTED), not a silent failure.

5. Subscribe to outbound webhooks

POST /api/v1/gamify/webhooks·bash
curl -X POST https://api.bricqs.co/api/v1/gamify/webhooks \
  -H "X-API-Key: $BRICQS_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "qlan-progression-sink",
    "url": "https://api.qlan.gg/bricqs/events",
    "events": [
      "reward.claimed.v1",
      "tier.changed.v1",
      "challenge.completed.v1",
      "milestone.reached.v1",
      "badge.earned.v1"
    ]
  }'

# The response includes the GENERATED signing secret — store it as
# BRICQS_WEBHOOK_SECRET for signature verification on receive.

One destination subscribing to five progression events; every entry must exist in the published catalog (events.json) or the create is rejected. There is no client-supplied secret.

Provisioning complete

With Step 0 and Step 1 done, the engine knows about coins, tiers, badges, rewards, and where to send webhooks. Every mechanic below is a smaller, isolated wiring exercise on top of this foundation.

Mechanic 1

Onboarding: coins for each profile step, badge at 100%

Five steps. Each step posts an event. The points engine grants coins. A challenge tracks completion and grants the Streamer badge when all five steps clear. UI reads the progress from participant state.

Build your gamer profileAuto-saving
125/ 375 coins

250 coins to unlock the Streamer badge

Display name & bio
+50
Sync game stats
+75
Connect Twitch / YouTube
+100
Favorite games
+50
Squad invites
+100
What the user sees during onboarding. Steps gate progressively; coins land as each event arrives.

1. Define the challenge (one time, admin)

Builder (dashboard-session; internally POST /api/v1/challenges + /objectives)·text
# Challenges are configured in the Builder — there is no API-key path.
# Under the hood the dashboard drives the same JWT routes our integration
# test exercises:

POST /api/v1/challenges
  { "code": "creator_profile_setup", "name": "Build your gamer profile",
    "challenge_type": "milestone_quest" }
# GLOBAL on purpose (no engagement_id): API-ingested events bridge to
# facts WITHOUT an engagement, and engagement-scoped challenges only
# advance on facts from their own engagement — so event-driven
# challenges must be global to progress at all.

# One objective per profile step. Emit a DISTINCT event per step and count
# it with the fact_count evaluator. The criteria string is the FULL fact
# name your event becomes: "behavior.custom.<event_name>" (there is no
# per-objective payload filter, so distinct steps = distinct event names):
POST /api/v1/challenges/{id}/objectives   (x5)
  { "code": "display_name", "name": "Set display name",
    "objective_type": "fact_count",
    "criteria": { "fact_types": ["behavior.custom.profile_display_name_completed"] },
    "target_value": 1, "points_on_completion": 20 }

POST /api/v1/challenges/{id}/activate

# Completion badge ("streamer") is wired as the challenge's completion
# reward in the Builder. Participants enroll once:
POST /api/v1/gamify/challenges/{id}/enroll  { "participant_id": "..." }

2. Your backend awards coins when it processes each step

Bricqs owns the ledger, balances, and expiry. Your backend owns what a business event is worth: when it processes a completed profile step, it awards the coins imperatively. A deterministic idempotency key makes the call safe to retry — mobile retries and back-button replays never double-credit. (A declarative earn-rule layer, where the engine values events for you, is on the roadmap; today valuation lives in your handler.)

server/onboarding.ts (Node)·ts
import { bricqs } from "./bricqsClient";

const COINS_PER_STEP = {
  display_name: 50, game_stats: 75, social_sync: 100, favorites: 50, squad: 100,
} as const;

export async function recordProfileStep(userId: string, step: ProfileStep) {
  // Coins ARE the points currency in this example — there is no currency
  // field on the award request (AwardPointsRequest: participant_id, amount,
  // reason, metadata, idempotency_key, expires_at).
  await bricqs.points.award({
    participant_id: userId,
    amount: COINS_PER_STEP[step],
    reason: `profile_step:${step}`,
    idempotency_key: `${userId}:profile_step:${step}`,
  });
}

Idempotency-Key is deterministic per (user, step) — the award endpoint dedupes, so repeated POSTs never double-credit.

3. The React app reads challenge progress from participant state

One binding rule decides your read path: event-driven challenges are global (previous step), and global challenges never appear in by-engagement reads. So pass the CHALLENGE ID to the hook — useChallenge({ challengeId }) fetches it directly via the public POST /gamify/challenges/by-ids and needs no engagement context at all. Reserve the engagement mode (useChallenge({ engagementId }) or the provider default) for challenges attached to a Bricqs-rendered engagement, where activity facts carry the engagement id. useParticipantState({ include: ["challenges"] }) remains the one-call alternative when you render several challenges at once.

app/components/ProfileProgress.tsx·tsx
"use client";
import { useChallenge, usePoints } from "@bricqs/sdk-react";

const SETUP_CHALLENGE_ID = process.env.NEXT_PUBLIC_SETUP_CHALLENGE_ID!;

export function ProfileProgress() {
  // challengeId mode: fetches the GLOBAL challenge directly (public
  // POST /gamify/challenges/by-ids) — no engagement context needed.
  const { objectiveProgress } = useChallenge({ challengeId: SETUP_CHALLENGE_ID });
  const { balance } = usePoints();

  return (
    <div>
      <p>{balance} / 375 coins</p>
      {objectiveProgress.map((o) => (
        <Step key={o.id} label={o.name} done={o.isCompleted} />
      ))}
    </div>
  );
}

Per-objective progress fields on the real wire: id, code, name, current_value, target_value, is_completed, points_earned — camelized by the SDK. No optimistic UI required; the hooks revalidate after each event lands. (challengeId mode ships in the next SDK release; on current npm versions read the same data from useParticipantState({ include: ['challenges'] }).)

Mechanic 2

Daily activity: a coin economy with daily caps

Login, publish a post, hit 5 comments in a day, sync game stats. Each event grants coins via an earn rule. Per-action daily caps prevent spam farming.

Coin activity · today+160 today
2,840spendable coins

Daily login

2 min ago

+5

Published post · 'IGL macro reads'

32 min ago

+20

5 comments today

1 hr ago

+10

Synced Valorant stats

5 hr ago

+25

Follower #50 unlocked

yesterday

+100
The participant's coin activity feed — every earn event is visible with timestamp and reason.

Simple awards (1-event → fixed reward)

For the three events where one action equals one reward, your backend awards coins when it processes the event. A per-day idempotency key gives you the daily cap for free: the award endpoint dedupes on the key, so the second login of the day is a no-op. Bricqs owns the ledger and dedup; your handler owns the coin value.

server/coins.ts (Node)·ts
const RULES = {
  user_logged_in:    { amount: 5,  perDay: 1 },
  post_published:    { amount: 20, perDay: 3 },
  game_stats_synced: { amount: 25, perDay: 1 },
} as const;

export async function awardForEvent(userId: string, type: keyof typeof RULES, seq: number) {
  const { amount, perDay } = RULES[type];
  if (seq > perDay) return; // past the daily cap
  const day = new Date().toISOString().slice(0, 10);
  await bricqs.points.award({
    participant_id: userId,
    amount,
    reason: type,
    // key includes the day + the Nth occurrence → safe to retry, capped per day
    idempotency_key: `${userId}:${type}:${day}:${seq}`,
  });
}

Deterministic idempotency key = free daily cap + retry safety. (A declarative earn-rule layer with server-side caps is on the roadmap.)

“5 comments = 10 coins” via a recurring daily challenge

For threshold-style rewards (“do X N times in a window”), use a recurring daily challenge instead of a custom counter. Bricqs already counts qualifying events per participant per window — that’s exactly what the activity_count evaluator does. No custom state to maintain, no edge cases to handle, no “what if my counter drifts.”

POST /api/v1/challenges (one-time setup, then auto-recurring)·json
# Builder-configured challenge (dashboard-session routes):
POST /api/v1/challenges
  { "code": "daily_comments_5", "name": "Comment activist - 5 a day",
    "challenge_type": "daily_habit" }
# Global (no engagement_id) — see the onboarding note: event-driven
# challenges must be global to advance.

POST /api/v1/challenges/{id}/objectives
  { "code": "comments", "name": "Post 5 comments",
    "objective_type": "fact_count",
    "criteria": { "fact_types": ["behavior.custom.comment_posted"] },
    "target_value": 5,
    "points_on_completion": 10 }

POST /api/v1/challenges/{id}/activate

Each participant gets their own progress; the engine grants the 10 coins the moment the 5th qualifying event lands, and challenge.completed.v1 fires. Cadence (daily/weekly re-runs) is a program setting in the Builder.

Backend emits one event per comment — no counting

server/onComment.ts (Node)·ts
// POST one event per comment. That's it. Bricqs counts.
export async function recordComment(userId: string, commentId: string) {
  await bricqs.events.send({
    participant_id: userId,
    event_name: "comment_posted",
    properties: { comment_id: commentId },
    idempotency_key: `${userId}:comment:${commentId}`,
  });
}

A custom server-side counter is unnecessary here — challenges are the right primitive for threshold-style rewards. Reach for your own state only when no built-in primitive fits.

Game stats sync (OAuth callback → event)

game_stats_synced fires when the user connects (or re-connects) a third-party game account. The OAuth callback validates the token, persists the connection in your DB, and POSTs one event to Bricqs.

api/oauth/riot/callback/route.ts (Next.js)·ts
export async function GET(req: Request) {
  const code = new URL(req.url).searchParams.get("code")!;
  const userId = await getCurrentUserId(req);

  // 1. Exchange code for Riot access token
  const { access_token, puuid } = await exchangeRiotCode(code);

  // 2. Pull initial stats
  const stats = await fetchRiotStats(access_token, puuid);

  // 3. Persist the connection
  await db.gameConnections.upsert({
    user_id: userId,
    provider: "riot",
    puuid,
    last_synced_at: new Date(),
  });

  // 4. Tell Bricqs this user just synced
  await bricqs.events.send({
    participant_id: userId,
    event_name: "game_stats_synced",
    properties: { provider: "riot", game: "valorant", rank: stats.rank, kd: stats.kd },
    idempotency_key: `${userId}:riot:${todayInTz(participantTz)}`,
  });

  return Response.redirect("/profile?synced=valorant");
}

One sync per day per provider counts. The earn rule has daily_cap_per_participant: 1, so repeat resyncs in the same day are facts-only (no coin double-credit).

Mechanic 3

Daily login streak with freezes

A streak that survives one missed day per week. Powered by Bricqs streaks with a 6-hour grace period and three freeze tokens granted on signup. The Marathon badge unlocks at 30 days.

Daily login streak

12day streak

3 streak-freezes in your wallet · 18 days to the Marathon badge

M
T
W
T
!
F
S
S

Today: log in to extend

+5 coins · keeps the streak alive

useStreak('daily_login') drives the UI. The dashed pill is today — the user has not logged in yet.

1. Configure the streak program

POST /api/v1/gamify/admin/streaks·json
{
  "code": "daily_login",
  "name": "Daily Login",
  "fact_type": "user_logged_in",
  "period": "daily",
  "grace_periods": 1,
  "reset_on_miss": true
}

Real StreakCreate fields (admin API, gamify:admin key). fact_type LABELS the activity the streak represents; it does not subscribe the streak to events (see the tick call below). The FREEZE allowance is program config in the Builder (freeze_tokens_allowed); 7/30/100-day celebrations are handled by your service on streak.continued.v1 webhooks (check payload count). Day boundaries are UTC.

2. Tick it from your login handler (explicit, not event-driven)

server/onLogin.ts (Node)·ts
// Streak ticking is EXPLICIT: the only streak writer in the engine is
// POST /gamify/streaks/record. Emitting user_logged_in feeds coins, rules,
// and challenges — it does NOT advance the streak. Call both on login.
export async function onLogin(userId: string) {
  await bricqs.events.send({
    participant_id: userId,
    event_name: "user_logged_in",
    idempotency_key: `${userId}:login:${todayUtc()}`,
  });

  await fetch(process.env.NEXT_PUBLIC_BRICQS_API_BASE + "/api/v1/gamify/streaks/record", {
    method: "POST",
    headers: { "X-API-Key": process.env.BRICQS_API_KEY!, "Content-Type": "application/json" },
    body: JSON.stringify({ participant_id: userId, streak_code: "daily_login" }),
  });
}

Recording is naturally idempotent per period: a second call the same day returns already_recorded: true and the count is unchanged. Grace periods (freezes) are consumed automatically by the record call after a missed period.

3. React renders the streak

app/components/StreakCard.tsx·tsx
"use client";
import { useStreak } from "@bricqs/sdk-react";

export function StreakCard() {
  // participantId inherited from <BricqsProvider> / participantToken
  const { streak } = useStreak("daily_login");
  if (!streak) return null;

  return (
    <div>
      <h3>{streak.currentCount} day streak</h3>
      <p>
        {streak.freezesAvailable} freezes left · longest {streak.longestCount}
        {streak.isAtRisk && " · at risk today"}
      </p>
    </div>
  );
}

useStreak reads GET /gamify/participants/{id}/streaks (StreakStatusEntry: current_count, longest_count, freezes_available, is_at_risk). It renders whatever the record calls from step 2 produced.

Mechanic 4

Follower milestones: coins, badges, and tier unlocks

Tiered rewards as the follower graph grows. A single follower_count metric drives four milestone triggers, each granting a different bundle: coins, a badge, a Banner unlock, or a Pro tier promotion that ships physical merch.

Follower milestones187 followers
63 to Banner unlock75%
50

50 followers

100 coins · Rising Star badge

Claimed
250

250 followers

500 coins · Banner unlock

In progress
1k

1,000 followers

Pro tier · branded merch

5k

5,000 followers

Legend tier · creator payout

A single milestone tracker drives the follower-progression view. State (done / in progress / locked) comes from the engine.

1. Emit follower events from the social graph service

server/follow.ts (Node)·ts
export async function followUser(followerId: string, followeeId: string) {
  await graphDb.follow(followerId, followeeId);

  // DELTA-ONLY payload, one numeric property. The milestone engine
  // increments fact_sum milestones by EVERY numeric property on EVERY
  // event (event_processor_service.py:301) — sending count AND delta
  // would double-count, and any other event carrying numeric properties
  // would also feed the same milestones. Keep numeric properties out of
  // your other events while fact_sum milestones are active.
  await bricqs.events.send({
    participant_id: followeeId,
    event_name: "follower_count_changed",
    properties: { delta: 1, follower_id: followerId },
    idempotency_key: `${followeeId}:follow:${followerId}`,
  });
}

2. Define the milestones (dashboard-session route)

POST /api/v1/progression/milestones (one call per milestone; dashboard session)·json
{ "code": "follower_50",   "name": "50 Followers",   "metric_type": "fact_sum", "threshold": 50 }
{ "code": "follower_250",  "name": "250 Followers",  "metric_type": "fact_sum", "threshold": 250 }
{ "code": "follower_1000", "name": "1k Followers",   "metric_type": "fact_sum", "threshold": 1000 }
{ "code": "follower_5000", "name": "5k Followers",   "metric_type": "fact_sum", "threshold": 5000 }

Real MilestoneCreate: code, name, metric_type, threshold (plus scope, is_repeatable). metric_type fact_sum accumulates the numeric deltas from step 1. Milestone definitions carry NO reward config — each crossing emits a milestone.reached.v1 webhook, and YOUR handler grants the outcome: POST /gamify/points/award for the coin bundles, POST /gamify/badges/award for rising_star, your feature-flag service for the banner, your fulfilment queue for streamer_merch. Tier promotions are not milestone flags either: tiers advance automatically on lifetime coins (step 1 thresholds).

Mechanic 5

Weekly leaderboards: one per game, resets Monday

Live leaderboards keyed on coins earned this week, bracketed per game. The weekly board itself is a Bricqs LEADERBOARD with a weekly window (auto-resetting rank, zero config after create); the top-10 prize pool is a CONTEST you run per week.

Weekly leaderboard

Valorant · Wk 21

Live
1AArjun_Qlan
18,420
2SShreyaApex
17,150
3YYou
16,880
4KKaran_Plays
15,910
5RRhea.gg
14,720

Top 10 win the Wk 22 prize pool · resets Monday

A weekly leaderboard rendered via useLeaderboard, polling on refreshInterval.

1. One weekly-window leaderboard per game bracket

POST /api/v1/gamify/admin/leaderboards (gamify:admin scope)·bash
# time_window: "weekly" resets the ranking every week automatically.
# Repeat for weekly_apex and weekly_bgmi.
curl -X POST https://api.bricqs.co/api/v1/gamify/admin/leaderboards \
  -H "X-API-Key: $BRICQS_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "weekly_valorant",
    "name": "Valorant Weekly",
    "metric": "points",
    "time_window": "weekly",
    "max_entries": 100
  }'

LeaderboardCreate: metric (points, badge_count, streak_current), time_window (all_time, yearly, monthly, weekly, daily), max_entries up to 1000. The weekly window IS the reset — no cron, no cleanup.

2. The top-10 prize pool is a contest per week

POST /api/v1/contests (dashboard-session route), then POST /api/v1/contests/{id}/publish·json
{
  "name": "Valorant Weekly Cup — Jul 20",
  "slug": "weekly-valorant-2026-07-20",
  "starts_at": "2026-07-20T00:00:00+05:30",
  "ends_at": "2026-07-26T23:59:59+05:30",
  "completion_type": "ranked",
  "score_config": {
    "scoring_rules": [
      {
        "fact_name": "post_published",
        "condition": "payload.game_tag == 'valorant'",
        "points": 10,
        "cap_per_occurrence": 10
      }
    ],
    "daily_score_cap": 200
  },
  "prize_config": [
    { "rank_from": 1, "rank_to": 1,  "label": "Weekly MVP", "reward_definition_id": "<uuid of the weekly_mvp_badge reward>" },
    { "rank_from": 2, "rank_to": 10, "label": "Top 10",     "reward_definition_id": "<uuid of the top_10_coins_500 reward>" }
  ],
  "fraud_config": { "max_score_per_hour": 200 }
}

Real contest contract: scoring_rules subscribe to your event names (condition is a string expression over the event properties; points can be a number or 'payload.field * N'), prize_config tiers reference reward definitions by id, fraud_config caps scoring velocity (max_score_per_minute / _hour / _day). There is NO auto-roll: create next week's contest from your scheduler, or duplicate it in the dashboard each Monday.

3. The leaderboard hook

app/components/WeeklyLeaderboard.tsx·tsx
"use client";
import { useLeaderboard } from "@bricqs/sdk-react";

export function WeeklyLeaderboard({ game }: { game: "valorant" | "apex" | "bgmi" }) {
  // engagementId inherited from <BricqsProvider>; refreshes every 30s
  const { entries, myRank } = useLeaderboard({ code: `weekly_${game}`, limit: 10 });

  return (
    <Card>
      {entries.map((e) => (
        <Row key={e.participantId} {...e} isYou={e.rank === myRank} />
      ))}
      {myRank != null && myRank > 10 && <YouFooter rank={myRank} />}
    </Card>
  );
}

The hook revalidates on a 30s interval (set refreshInterval to tune). For push instead of polling, useBricqsStream subscribes to the public SSE endpoint GET /api/v1/gamify/stream/{participant_id}.

3. Optional: score on engagement quality instead of post count

The contest above scores 10 points per qualifying post. If you want the weekly cup to rank on engagement QUALITY, roll up likes + comments + watch-minutes in your backend and emit the INCREMENT as an event; then swap the scoring rule to read the delta from the event properties: { "fact_name": "engagement_score_updated", "condition": "payload.game_tag == 'valorant'", "points": "payload.delta" }. Contest scoring is ADDITIVE per event, so emit deltas, never the running total.

server/workers/engagementScore.ts (runs every 5 min)·ts
// Compute composite score from recent activity per (user, game).
// Weights chosen so 1 post ≈ 100 base, plus engagement amplification.
const WEIGHTS = { post: 100, like_received: 2, comment_received: 5, watch_minute: 1 };

async function rollup(userId: string, gameTag: string) {
  const since = startOfIsoWeek(); // resets Monday in tenant tz
  const stats = await analyticsDb.aggregate({ userId, gameTag, since });

  const score =
    stats.posts        * WEIGHTS.post +
    stats.likes        * WEIGHTS.like_received +
    stats.comments     * WEIGHTS.comment_received +
    stats.watchMinutes * WEIGHTS.watch_minute;

  // Emit the INCREMENT since the last run — contest scoring adds each
  // event's points, so re-sending a running total would over-count.
  const last = await kv.get(`eng:${userId}:${gameTag}`) ?? 0;
  const delta = score - last;
  if (delta <= 0) return;
  await kv.set(`eng:${userId}:${gameTag}`, score);

  await bricqs.events.send({
    participant_id: userId,
    event_name: "engagement_score_updated",
    properties: { game_tag: gameTag, delta, week_start: since.toISOString() },
    // Unique per rollup run: a retried run replays as a duplicate and is
    // dropped; a NEW run gets a new key and lands its new delta.
    idempotency_key: `${userId}:eng:${gameTag}:${since.toISOString().slice(0,10)}:${score}`,
  });
}

Delta emission + an idempotency key that changes only when the score does: retries of the same rollup are deduplicated, new increments land exactly once.

Mechanic 6

Badges: persistent status across activity, position, season

Twelve badges across three families. Activity badges (First Post, Marathon) earn on conditions. Positional badges (Top 100) award from contest results. Seasonal badges (Wk-21 MVP) are issued by the lifecycle worker on contest completion.

Your badges3 / 12 earned

First Post

Rising Star

Streamer

Marathon

Top 100

Legend

useBadges returns earned + locked (earned flag + earned_at). Progress bars come from the streak or challenge that feeds each badge.

Define the badge catalog

Definitions + award paths (they are separate)·text
# 1. DEFINE each badge once (identity + display only):
#    POST /api/v1/gamify/admin/badges  { "code", "name", "rarity", ... }

# 2. WIRE each badge's award path — this is where the "when" lives:
first_post      -> rule action on the post_published event (Builder)
marathon        -> milestone at 30 on the daily_login streak metric, badge reward
streamer        -> completion reward on the creator_profile_setup challenge
highlight_reel  -> completion reward on the weekly_clip_challenge
top_100         -> your backend awards on season close:
                   POST /api/v1/gamify/badges/award (gamify:write key)
rising_star     -> milestone at 50 on the follower-count metric

Badges award through rules, milestone rewards, challenge rewards, or a direct server call — the definition itself has no condition field. Whichever path fires, badge.earned.v1 is emitted and the award is idempotent per (participant, badge).

Render the catalog with useBadges

app/components/BadgeCabinet.tsx·tsx
"use client";
import { useBadges } from "@bricqs/sdk-react";

export function BadgeCabinet() {
  // engagementId inherited from <BricqsProvider>; returns earned + unearned
  const { badges } = useBadges();

  return (
    <Grid>
      {badges.map((b) => (
        <BadgeTile
          key={b.code}
          name={b.name}
          rarity={b.rarity}
          earned={b.earned}
        />
      ))}
    </Grid>
  );
}

Mechanic 7

Season tier rewards with webhook-driven fulfilment

The 12-week season ranks every creator by total engagement. Top 50% / 10% / 1% each unlock a tiered reward. The engine atomically claims a code from inventory and fires reward.claimed.v1 to your fulfilment service over HMAC-signed webhook.

Season milestone rewards

Top 1%

Branded merch + Diamond badge

Allocated

1,240 / 1,240

Top 10%

₹500 in-app credit

Allocated

12,400 / 12,400

Top 50%

Season MVP badge

In progress

47,820 / 62,000

The season-rewards card during a live season.

Reward unlocked

Streamer tier · branded overlay pack

Earned at 1,000 followers + 30-day streak. Code emailed and added to your wallet.

Code: STR-9F2BWebhook delivered
The unlock confirmation shown when the webhook fires.

1. Define the season contest with prize tiers

POST /api/v1/contests·json
{
  "name": "Season 14 — Global",
  "slug": "season-global-s14",
  "starts_at": "2026-06-01T00:00:00Z",
  "ends_at": "2026-08-23T23:59:59Z",
  "completion_type": "ranked",
  "score_config": { "source": "points" },
  "prize_config": [
    { "rank_from": 1, "rank_to": 1, "label": "Season Diamond", "reward_definition_id": "<uuid of season_diamond>" },
    { "percentile_from": 0, "percentile_to": 1,  "label": "Top 1%",  "reward_definition_id": "<uuid of season_branded_merch>" },
    { "percentile_from": 1, "percentile_to": 10, "label": "Top 10%", "reward_definition_id": "<uuid of season_in_app_credit_500>" },
    { "percentile_from": 10, "percentile_to": 50, "label": "Top 50%", "reward_definition_id": "<uuid of season_mvp_badge>" }
  ],
  "max_reward_liability": 250000,
  "fraud_config": { "max_score_per_hour": 500, "max_score_per_day": 2000 }
}

score_config.source: 'points' makes the season score every coin the participant earns while the contest is live — no separate season event needed. Prize tiers match by rank OR percentile (0 = best; inclusive lower, exclusive upper bound; first matching tier in list order wins). Tier promotion is NOT a contest field — tiers advance on lifetime points via the tier program from step 1. Publish with POST /api/v1/contests/{id}/publish.

2. Subscribe to the reward.claimed.v1 webhook

POST /api/v1/gamify/webhooks·bash
curl -X POST https://api.bricqs.co/api/v1/gamify/webhooks \
  -H "X-API-Key: bq_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name":   "qlan-rewards-sink",
    "url":    "https://api.qlan.gg/bricqs/rewards",
    "events": ["reward.claimed.v1", "milestone.reached.v1", "tier.changed.v1"]
  }'

# The response INCLUDES the generated signing secret — store it as
# BRICQS_WEBHOOK_SECRET. You do not supply your own secret.

3. Verify the signature in your handler

api/bricqs/rewards/route.ts (Next.js Route Handler)·ts
import crypto from "node:crypto";

export async function POST(req: Request) {
  const sig = req.headers.get("x-bricqs-signature")!;
  const ts  = req.headers.get("x-bricqs-timestamp")!;
  const body = await req.text();

  // Constant-time HMAC verify
  const expected = crypto
    .createHmac("sha256", process.env.BRICQS_WEBHOOK_SECRET!)
    .update(`${ts}.${body}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response("bad sig", { status: 401 });
  }
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return new Response("stale", { status: 401 });
  }

  const event = JSON.parse(body);
  if (event.type === "reward.claimed.v1") {
    const { participant_id, reward, code, expires_at } = event.data;
    await fulfilment.send(participant_id, reward, code, expires_at);
    await db.rewardLedger.insert({ user_id: participant_id, reward_slug: reward.slug, code });
  }
  return new Response("ok");
}

Constant-time compare + a 5-minute timestamp window covers signature spoofing and replay attacks.

Mechanic 8

Weekly challenges: 'Post 5 clips this week'

Optional, opt-in weekly challenges sit on top of the always-on loops. Bricqs challenges with a 7-day window. Completing one grants a coin bonus plus a one-week badge.

This week's challenge

Post 5 clips this week

Unlocks the Highlight Reel badge + 250 coins

3 / 5 clips

3 days left

2 clips to badge

Average member finishes by Sunday

The weekly challenge card. State + days-remaining come from useChallenge({ challengeId }) — the direct read path for global event-driven challenges (see the onboarding binding note).
POST /api/v1/challenges (dashboard-session route), then POST /api/v1/challenges/{id}/activate·json
{
  "code": "weekly_clip_challenge_2026_07_20",
  "name": "Post 5 clips this week",
  "challenge_type": "weekly_sprint",
  "duration_type": "fixed",
  "start_date": "2026-07-20T00:00:00+05:30",
  "end_date": "2026-07-26T23:59:59+05:30",
  "completion_rewards": { "points": 250, "badges": ["highlight_reel"] },
  "objectives": [
    {
      "code": "clip_count",
      "name": "Post 5 clips",
      "objective_type": "fact_count",
      "criteria": { "fact_types": ["behavior.custom.clip_posted"] },
      "target_value": 5
    }
  ]
}

Real challenge contract: challenge_type weekly_sprint, objective_type fact_count, completion_rewards as { points, badges[] }. Three honest constraints, all pinned by the integration test: (1) the criteria string is the full fact name an API event becomes — behavior.custom.<event_name> — and it cannot filter on properties, so emit a distinct clip_posted event alongside post_published when the post carries a clip; (2) the challenge must be GLOBAL (no engagement_id) or API events will never advance it; (3) there is no weekly auto-roll — create next week's instance (new code, new dates) from your scheduler or duplicate in the dashboard. Progress lives per instance; the badge persists once earned.

Mechanic 9

Squad referrals: double-sided, with a 5-friend milestone

The referrer and the referee both earn 200 coins on conversion. Hitting 5 successful referrals unlocks the Squad Captain badge on both sides. Powered by the Bricqs referrals API with a milestone overlay.

Refer your squad2 of 5 referrals

Your code

ARJUN-QLAN

You earn 200 coins for every friend who joins. At 5 referrals, both of you unlock the Squad Captain badge.

PPriya
Joined · +200 coins
VVikram
Joined · +200 coins
AAditya
Invite pending
The referral card — code, status of each invite, and progress toward the squad-captain milestone.

1. Generate a referral code, record the conversion

Referrals are per-participant: mint a code for the referrer, then record the conversion when it counts. Tie conversion to first_post_published, not signup — that defeats fake-account farming and lines rewards up with real engagement. Your backend decides the reward and awards it imperatively on conversion.

server/referrals.ts (Node)·ts
// Mint the referrer's code (idempotent — returns the existing code if any).
const { code, shareUrl } = await bricqs.referrals.generate({ participantId: referrerId });

// Later, when the referee's first post lands within the attribution window:
const { converted } = await bricqs.referrals.convert({
  code,
  refereeParticipantId: refereeId,
});

if (converted) {
  // Both-sided reward — your backend owns the amount.
  for (const pid of [referrerId, refereeId]) {
    await bricqs.points.award({
      participant_id: pid, amount: 200,
      reason: "referral_converted", idempotency_key: `ref:${code}:${pid}`,
    });
  }
}

POST /gamify/referrals/generate + /convert. Conversion fires on first_post_published, not signup. (Milestone rewards like Squad Captain ride on the referral_count milestone metric.)

2. Render share + invite history with useReferral

app/components/ReferralCard.tsx·tsx
"use client";
import { useReferral } from "@bricqs/sdk-react";

export function ReferralCard() {
  // participantId inherited from <BricqsProvider> / participantToken
  const { code, shareUrl, stats, copyLink } = useReferral();

  return (
    <Card>
      <Code value={shareUrl ?? code} onCopy={copyLink} />
      <p>{stats?.totalConversions ?? 0} squad members joined</p>
    </Card>
  );
}

The hook reads canonical state — code, share URL, click + conversion stats — straight from the engine.

3. Capture ?ref= on the landing page

The shared link is qlan.gg/?ref=ARJUN-QLAN. On landing, capture the code into a first-party cookie with the 30-day attribution window. On signup, attach the cookie to the participant creation call so the engine can credit the referrer when the new user's first post lands.

middleware.ts (Next.js — runs on every request)·ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(req: NextRequest) {
  const ref = req.nextUrl.searchParams.get("ref");
  const res = NextResponse.next();

  if (ref && !req.cookies.has("bq_ref")) {
    // First-party, 30-day attribution window. Matches the program config.
    res.cookies.set("bq_ref", ref, {
      maxAge: 30 * 24 * 60 * 60,
      sameSite: "lax",
      path: "/",
    });
  }
  return res;
}
app/api/auth/signup/route.ts (Next.js)·ts
export async function POST(req: Request) {
  const { email, password } = await req.json();
  const newUser = await db.users.create({ email, password });

  // Pull the attribution cookie set by middleware
  const ref = cookies().get("bq_ref")?.value;

  // Tell Bricqs about the signup, with ref code attached. The referrals service
  // attaches the attribution; conversion fires later when the first post lands.
  await bricqs.events.send({
    participant_id: newUser.id,
    event_name: "participant_created",
    properties: { source: "web_signup", referral_code: ref ?? null },
    idempotency_key: `${newUser.id}:created`,
  });

  return Response.json({ ok: true });
}

The engine matches the referral_code to the referrer's program code and stores the attribution pending — it only resolves on the first first_post_published event from this new participant.

No SDK required

Build the same UI with pure REST — every mechanic, every endpoint

The SDK examples above are convenience. Every state in this guide is one GET away, and every mutation is one POST. Teams on Vue, Svelte, Flutter, native iOS/Android, Go templates, or server-rendered HTML can ship the same product without touching @bricqs/sdk-react.

One endpoint, everything

GET /api/v1/gamify/state/{participant_id} returns the complete gamification context for a participant — points, tier, badges, streaks, active challenges, and rewards — in a single call. This is usually all a custom dashboard needs.

Use the per-resource endpoints below when you want to read a single concern (just the leaderboard, just the badges) or paginate transactions.

1. One call returns the whole participant context

GET /api/v1/gamify/state/{participant_id}·bash
# Server-side: API key with gamify:read. (Browser-side, the participant's
# short-lived JWT works as Authorization: Bearer — pinned to its own id.)
curl https://api.bricqs.co/api/v1/gamify/state/p_qlan_arjun \
  -H "X-API-Key: $BRICQS_API_KEY" | jq

# Response (ParticipantStateResponse — request a subset with
# ?include=points,tier,badges,streaks,challenges,contests,rewards)
{
  "participant_id": "p_qlan_arjun",
  "points": { "balance": 2840, "lifetime": 12420, "redeemed": 9580 },
  "tier": {
    "code": "pro_creator", "name": "Pro Creator", "level": 3,
    "achieved_at": "2026-05-02T11:08:00Z", "color": "#7C3AED",
    "next_tier": { "code": "legend", "name": "Legend", "level": 4, "points_needed": 12580 }
  },
  "badges": [
    { "code": "first_post",  "name": "First Post",  "earned": true,  "earned_at": "2026-04-12T10:14:00Z", "rarity": "common", "icon": null },
    { "code": "rising_star", "name": "Rising Star", "earned": true,  "earned_at": "2026-04-19T08:42:00Z", "rarity": "rare",   "icon": null },
    { "code": "marathon",    "name": "Marathon",    "earned": false, "earned_at": null,                    "rarity": "epic",   "icon": null }
  ],
  "streaks": { "current": 12, "longest": 47, "last_activity_at": "2026-05-14T06:12:00Z" },
  "challenges": { "active": [ { "challenge_id": "…", "name": "Post 5 clips this week", "progress": 3, "target": 5 } ], "active_count": 2, "completed_count": 4 },
  "contests": { "entered_count": 1, "entries": [ { "contest_id": "…", "rank": 14, "score": 320 } ] },
  "rewards": { "total_claimed": 2 }
}

One round-trip, full UI render. The shape is intentionally flat for browser-rendered apps: dump it into Pinia, Zustand, or your global store and you have everything.

2. Per-mechanic REST endpoints, mapped one-to-one with the SDK hooks

MechanicREST endpoint (read)Mutation (write)SDK equivalent
Coins balanceGET /gamify/participants/{pid}/pointsPOST /gamify/points/award · /deduct · /award-batchusePoints
Coins transaction historyGET /gamify/participants/{pid}/points/transactionsusePoints().transactions
Tier + progressGET /gamify/participants/{pid}/tierPOST /gamify/tiers/assign (admin)useTier
Badges (earned + locked + progress)GET /gamify/participants/{pid}/badgesPOST /gamify/badges/award (admin)useBadges
Streak detail (history, freezes)GET /gamify/participants/{pid}/streaksPOST /gamify/streaks/recorduseStreak
Leaderboard top-NGET /gamify/leaderboards/{code} (authed) · GET /public/leaderboards/{code}?engagement_id= (credential-less)useLeaderboard (uses the credential-less /public variant)
My rank + surrounding rowsGET /gamify/leaderboards/{code}/rank/{pid}useLeaderboard().myRank
Reward catalogGET /gamify/rewardsPOST /gamify/rewards/{id}/claimuseRewards
Claimed rewards (codes, expiry)GET /gamify/participants/{pid}/rewardsuseRewards
Challenge progressGET /gamify/challenges/{id}/progressPOST /challenges (admin)useChallenge
Challenges available to meGET /gamify/challenges/by-engagement/{eid}/alluseChallenges
Referral code + statsGET /gamify/referrals/stats/{pid}POST /gamify/referrals/generate · /convertuseReferral
Event history (last N)GET /gamify/participants/{pid}/eventsPOST /gamify/events · /events/batchclient.events
Participant profile + stateGET /gamify/state/{pid}POST /participants · PATCH /participants/{pid}useParticipantState
Default rule:Every SDK hook is a thin wrapper around one (or two) of these endpoints. Building without the SDK costs you the cache + revalidation conveniences, not data access.

3. Three live UI examples in plain fetch — no React, no SDK

leaderboard.ts (Vue / Svelte / Vanilla — pure REST)·ts
// Render top 10 + the current user's rank, even if they're rank 9,142.

async function loadWeeklyLeaderboard(game: "valorant" | "apex" | "bgmi", me: string) {
  const code = `weekly_${game}`;

  // participantJwt = the short-lived token your backend mints per login
  // (POST /api/v1/auth/participant-token). Pinned to this participant.
  const [top, mine] = await Promise.all([
    fetch(`https://api.bricqs.co/api/v1/gamify/leaderboards/${code}?limit=10`, {
      headers: { Authorization: `Bearer ${participantJwt}` },
    }).then(r => r.json()),

    fetch(`https://api.bricqs.co/api/v1/gamify/leaderboards/${code}/rank/${me}`, {
      headers: { Authorization: `Bearer ${participantJwt}` },
    }).then(r => r.json()),
  ]);

  return {
    rows: top.entries,  // [{ rank, participant_id, score, display_name, change }]
    me: mine,           // { participant_id, rank: 9142, score: 2340, percentile: 8.4, total_participants }
    isMeInTop10: top.entries.some(e => e.participant_id === me),
  };
}

// Refresh every 15s for "live"-feeling leaderboard updates without the SDK
setInterval(() => loadWeeklyLeaderboard("valorant", currentUserId).then(render), 15_000);

Two endpoints, two parallel fetches. The rank endpoint returns your row plus percentile and total_participants (no neighbor rows — render surrounding ranks from the main board when you need them). Polling at 15s is plenty for a weekly leaderboard.

badges.ts (any framework)·ts
// Render the badge cabinet — earned + locked + per-badge progress.

// Default returns earned + locked; pass ?earned_only=true for the cabinet-only view.
const res = await fetch(
  `https://api.bricqs.co/api/v1/gamify/participants/${userId}/badges`,
  { headers: { Authorization: `Bearer ${participantJwt}` } },
);
const { badges, earned_count, total } = await res.json();

// badges = [
//   { code: "first_post", name: "First Post", earned: true,  earned_at: "2026-04-12T...", rarity: "common", icon: null, category: null, description: null },
//   { code: "marathon",   name: "Marathon",   earned: false, earned_at: null,             rarity: "epic",   icon: null, category: null, description: null },
//   ...
// ]

Earned and locked in one list (earned boolean + earned_at). Per-badge numeric progress is not in this response — for a 12-of-30 tile, read the streak or challenge that feeds the badge and render its counter.

rewards.ts (any framework)·ts
// Catalog + claim flow with codes + expiry — no SDK.

async function loadRewards(userId: string) {
  const [catalog, mine] = await Promise.all([
    // Catalog filters: ?reward_type=..., ?engagement_id=... (no participant filter)
    fetch(`https://api.bricqs.co/api/v1/gamify/rewards`, {
      headers: { Authorization: `Bearer ${participantJwt}` },
    }).then(r => r.json()),
    // { rewards: [{ id, name, type, points_cost, available_codes, max_claims_per_user,
    //               description, image_url, value, expires_at }], total }

    fetch(`https://api.bricqs.co/api/v1/gamify/participants/${userId}/rewards`, {
      headers: { Authorization: `Bearer ${participantJwt}` },
    }).then(r => r.json()),
    // { rewards: [{ claim_id, id, reward_name, reward_type, code_value,
    //               claimed_at, expires_at, value, terms }], total, participant_id }
  ]);

  return { catalog: catalog.rewards, claimed: mine.rewards };
}

async function claim(rewardId: string, userId: string) {
  const res = await fetch(`https://api.bricqs.co/api/v1/gamify/rewards/${rewardId}/claim`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${participantJwt}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `${userId}:reward:${rewardId}`,
    },
    // ClaimRewardRequest: participant_id, points_deduction, optional email +
    // engagement_id. points_deduction: true spends the coins atomically.
    body: JSON.stringify({ participant_id: userId, points_deduction: true }),
  });

  if (!res.ok) throw new Error((await res.json()).error?.message ?? "claim failed");
  return res.json();
  // { claim_id, code_value: "STR-9F2B", expires_at, new_balance,
  //   points_deducted, reward_name, reward_type, participant_id }
}

Idempotency-Key on the claim is critical — double-clicks, mobile retries, browser back-navigations cannot double-deduct.

4. Polling cadence cheat-sheet

The SDK opens a single revalidation channel and pushes diffs. For pure-REST builds, poll at the cadence that matches the user’s expectation of “live.” In every case below, the fetch is one round-trip — the engine is not the bottleneck.

SurfaceHow &ldquo;live&rdquo; does it need to feel?Recommended poll
Profile state (points, tier, badges)Updates within a tab focusRefetch on window focus + after every POST mutation
Coin balance (transactional display)Within a few seconds of actionRefetch immediately after each event POST
Weekly leaderboardWithin ~15 secondsPoll every 15s while the leaderboard is on screen
In-match / live contest leaderboardSub-secondPoll every 2-3s, or upgrade to the live-channel endpoint (see Gap analysis)
Streak cardPer sessionRefetch on app open + after the daily-login event POST
Reward catalogPer page viewRefetch on /rewards route mount
Challenge progressWithin a few secondsRefetch after every event POST that could advance an objective
Default rule:Most cohorts never hit the per-tenant rate limit (5,000 req/min) even with aggressive polling. If you do approach it, batch the reads into /participants/{pid}/state which returns everything in one call.

5. What the SDK gives you that pure REST doesn’t (gap analysis)

Every read in this guide is REST-accessible. The SDK is a thin convenience layer with three properties that, if you want, you implement yourself when going REST-only:

CapabilitySDK behaviorREST-only equivalent
Shared cache across componentsMultiple components calling usePoints share one fetchUse SWR, TanStack Query, Apollo, Pinia/Zustand store — same effect with one extra dep
Auto-revalidation on focusRefetches stale resources when the tab regains focusAdd a window 'focus' listener that re-runs your fetchers
Live-channel updates (push, not poll)GET /gamify/stream/{participant_id} (SSE, participant token)Same public SSE endpoint useBricqsStream consumes — any EventSource client works. Polling remains fine at the cadences above.
Optimistic mutationsMutate cache instantly; reconcile on responseUpdate your local store immediately, roll back on error
Token refresh before TTLBuilt-in helper that hits your /token route at ~80% TTLImplement once: setInterval at 0.8 * TTL

Roadmap callout

Live channel for pure-REST consumers (Server-Sent Events). The public SSE endpoint GET /api/v1/gamify/stream/{participant_id} (participant token auth) streams participant-scoped frames, the same channel the SDK hook useBricqsStream consumes. Any EventSource client can subscribe; polling at the cadences in the table above also covers every use case here.

Everything else in this page is shipped today and exposed via the API.

Putting it together

The complete event catalog for Qlan.gg

Every event your backend POSTs into /api/v1/gamify/events. Programs subscribe to one or more — the engine handles the fan-out. New mechanics added later require no platform code change, just a new program.

Event typeWhere it comes fromPrograms that react
profile_step_completedOnboarding wizard, per stepCoins earn rule (per-step amount), Profile-setup challenge
user_logged_inApp open / web session startCoins (5/day), Daily-login streak
post_publishedCompose-post submitCoins (20/post · cap 3/day), Weekly-clip challenge, Weekly leaderboard, Engagement score
comment_postedEvery comment submittedDaily-recurring challenge (5/day → 10 coins via reward)
game_stats_syncedTwitch/Riot/Steam OAuth callbackCoins (25/day), Game-stats badge
follower_count_changedSocial-graph service on follow/unfollowFollower-milestones challenge
engagement_score_updatedAnalytics worker — emits the score DELTA per rollupOptional quality-weighted contest scoring (payload.delta rule)
referral_convertedFirst-post event of an attributed refereeReferral program (double-sided), Squad-captain milestone
challenge_completedEmitted by the engine itselfCoins bonus, Badge grant, Webhook fan-out
reward.claimed.v1 (out)Emitted by the engine on prize allocationYour fulfilment service via HMAC-signed webhook
Default rule:Ten event types cover the entire stack. Adding a new mechanic later means adding rows here and a program — not a platform change.

What lives in the Bricqs tenant

The programs you provisioned to make all of this work

Every mechanic above is backed by a configured program. This is the full inventory — a checklist you can use when provisioning your own tenant.

ProgramTypePurpose
coinsPointsSingle named points program. Earn rules + spend menu.
creator_profile_setupChallenge5-step onboarding. Grants Streamer badge on completion.
weekly_clip_challengeChallengeRecurring weekly. Post 5 clips → Highlight Reel badge + coins.
follower_milestonesChallengeEvergreen score-threshold challenge with 4 tiered milestones.
daily_loginStreakDaily streak with 6h grace, 3 starting freezes, milestone badges.
weekly_valorant / apex / bgmiLeaderboard + Contest3 weekly-window leaderboards; one prize-pool contest per week (no auto-roll — script or duplicate in dashboard).
season_global_s14Contest12-week season. Tiered prize pool with Top 50/10/1% rewards.
squadReferralDouble-sided 200 coins + 5-friend milestone.
badges catalogBadgesFirst Post, Marathon, Streamer, Highlight Reel, Top 100, Rising Star, Squad Captain, Wk-MVPs, Season MVP, Legend, Diamond.
rewards catalogRewardsstreamer_merch, legend_payout, season_diamond, season_branded_merch, season_in_app_credit_500, weekly_mvp_badge, top_10_coins_500.
webhooksOutboundreward.claimed.v1, milestone.reached.v1, tier.changed.v1 → api.qlan.gg/bricqs/rewards.
Default rule:Eleven programs cover the entire stack. Provisioning is a one-time admin task — after that, the engine runs on inbound events alone.

Verify before shipping

Smoke-test each mechanic with one curl

Before any of this lands on real users, run a smoke test against your test tenant. One curl per mechanic, plus a follow-up read to confirm the engine moved. Wire these into CI so a regression never reaches production.

scripts/smoke.sh (test tenant)·bash
#!/usr/bin/env bash
set -euo pipefail

API="https://api.bricqs.co/api/v1"
KEY="$BRICQS_TEST_KEY"           # bq_test_...
ADMIN="$BRICQS_TEST_ADMIN_KEY"   # bq_test_...
P="smoke_$(date +%s)"            # fresh participant per run

post() {
  curl -sS -X POST "$API/gamify/events" \
    -H "X-API-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d "$1" | jq -c .
}

# 1. Onboarding — 5 step events
for step in display_name game_stats social_sync favorites squad; do
  post "{\"participant_id\":\"$P\",\"event_name\":\"profile_step_completed\",\"properties\":{\"step\":\"$step\"},\"idempotency_key\":\"$P:profile:$step\"}"
done

# 2. Daily activity — login + post + comment threshold + sync
post "{\"participant_id\":\"$P\",\"event_name\":\"user_logged_in\",\"idempotency_key\":\"$P:login:$(date +%F)\"}"
post "{\"participant_id\":\"$P\",\"event_name\":\"post_published\",\"properties\":{\"post_id\":\"post_1\",\"game_tag\":\"valorant\",\"has_clip\":true},\"idempotency_key\":\"$P:post:post_1\"}"
post "{\"participant_id\":\"$P\",\"event_name\":\"clip_posted\",\"properties\":{\"post_id\":\"post_1\"},\"idempotency_key\":\"$P:clip:post_1\"}"

# 3. Follower milestone fire
post "{\"participant_id\":\"$P\",\"event_name\":\"follower_count_changed\",\"properties\":{\"count\":50,\"delta\":50},\"idempotency_key\":\"$P:followers:50\"}"

# Verify ledger + badges + challenge progress all moved
curl -sS "$API/gamify/state/$P" -H "X-API-Key: $KEY" | jq '{
  coins: .points.balance,
  badges_earned: [.badges[] | select(.earned) | .code],
  active_challenges: .challenges.active
}'

Run on every PR. If the assertions on the final jq output ever change, your contract with the engine has drifted — catch it in CI, not production.

Inspect a participant’s state

bash
# Read the full participant snapshot — points, tier, badges, streaks, challenges.
curl "$API/gamify/state/$P" \
  -H "X-API-Key: $ADMIN" | jq

One round-trip returns the canonical participant state. When a user emails support saying 'I did the thing but I didn't get coins', diff this against the points transaction history (GET /gamify/participants/{id}/points/transactions) to see exactly what landed.

Production-grade integration

Error handling, retries, and rate limits

Three things will happen to your integration in production: the network will flake, you will hit a rate limit, and your worker will retry the same job twice. The patterns below cover all three.

1. Always include an Idempotency-Key

The engine treats the idempotency_key on an event POST as the source of truth. Repeat POSTs with the same key inside 24 hours return the same response and never write twice. Use a deterministic key derived from your source data — never a UUID generated at retry time.

lib/bricqsClient.ts·ts
// Good — deterministic; same source → same key forever.
idempotency_key: `${userId}:post:${postId}`,

// Bad — new UUID per call; retries double-credit.
idempotency_key: crypto.randomUUID(),

2. Retry with exponential backoff on 5xx / network errors

lib/bricqsClient.ts·ts
async function send(event: BricqsEvent, attempt = 0): Promise<void> {
  try {
    const res = await fetch(`${API}/gamify/events`, {
      method: "POST",
      headers: {
        "X-API-Key": process.env.BRICQS_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(event),
    });

    if (res.status === 429) {
      // Rate limited — read Retry-After
      const wait = Number(res.headers.get("Retry-After") ?? 1) * 1000;
      await sleep(wait);
      return send(event, attempt + 1);
    }

    if (res.status >= 500 && attempt < 5) {
      const wait = Math.min(2 ** attempt * 1000, 30_000) + Math.random() * 500;
      await sleep(wait);
      return send(event, attempt + 1);
    }

    if (!res.ok) {
      // 4xx (other than 429) — surface to your monitoring; do not retry.
      const body = await res.json().catch(() => ({}));
      throw new BricqsClientError(res.status, body.message);
    }
  } catch (err) {
    if (attempt < 5) {
      await sleep(2 ** attempt * 1000);
      return send(event, attempt + 1);
    }
    // Final failure — fall back to your durable event queue
    await dlq.push(event);
  }
}

Exponential backoff with jitter, capped at 30s. Final failures land in a DLQ that a separate worker drains. The idempotency key makes repeated DLQ drains safe.

3. Rate-limit budgets per tenant

The default per-tenant limit is 5,000 events per minute, with a 100/sec burst. If a tentpole moment (sponsored stream, championship final) puts you anywhere near the ceiling, raise the cap 48 hours ahead via the dashboard or by emailing support — Bricqs raises limits inside an hour for known events. Batch-friendly endpoints (POST /gamify/events/batch, up to 100 events per call) are roughly 25x more efficient than single POSTs.

4. Webhook resilience on your receiver

Webhooks retry up to 12 times over 24 hours on non-2xx responses. Three failure modes to plan for:

  • Slow handler: respond 2xx fast (under 10s timeout), enqueue work for async processing. Long synchronous handlers cause Bricqs to retry-storm.
  • Replay safety: deduplicate on the event.id field. Replays carry X-Bricqs-Replay: true.
  • Signature drift: always constant-time compare; verify the timestamp is within 300s to defeat replay attacks.

5. Common errors and what to do

StatusMeaningAction
400 invalid_payloadSchema mismatch on the event body.Fix the payload. Do not retry — it will fail again.
401 unauthorizedWrong key, missing scope, or expired participant token.Mint a fresh token; verify the key has the required scope.
404 unknown_programSlug does not exist (typo or program not provisioned).Re-run Step 1 provisioning. Verify slug spelling.
409 insufficient_fundsDeduct would push available_points negative.Surface to the user; do not retry.
429 rate_limitedOver per-key or per-tenant limit.Read Retry-After header; back off.
5xx engine_errorTransient platform-side failure. Idempotency-key protects you.Retry with exponential backoff.

From zero to live

A sequenced rollout: what to ship in week 1, 2, and 3

You do not ship all nine mechanics on day one. The order below derisks the integration — get the foundation right, then layer mechanics in a sequence that lets you measure each one independently.

Week 1 — Foundation

Step 0 (auth + Provider) and Step 1 (provision coins + tiers + badge catalog + rewards inventory + webhooks). Smoke-test against the test tenant. Ship onboarding (Mechanic 1) behind a feature flag for 10% of new signups. Measure: completion rate vs the non-gamified control.

Week 2 — Daily loops

Add daily activity coins (Mechanic 2), the daily login streak (Mechanic 3), and the badge surface (Mechanic 6). Ramp the onboarding flag to 100%. Measure: day-2 and day-7 return rates, average session count per active user.

Week 3 — Social + competitive

Add follower milestones (Mechanic 4), the weekly leaderboard (Mechanic 5), and the weekly challenge surface (Mechanic 8). Measure: posts per active user, week-1 vs week-2 cohort retention, leaderboard participation rate.

Week 4 — Acquisition

Add squad referrals (Mechanic 9) with the 30-day attribution window. Run an in-app prompt for the share flow. Measure: referral conversion rate (signup → first post within 7 days), viral coefficient.

Week 5 — Season

Spin up the first season contest (Mechanic 7) with a 12-week window and tiered prize pool. Wire the reward.claimed.v1 webhook receiver. Measure: prize liability vs cap, time-to-fulfilment after allocation, NPS of prize winners.

Ongoing — Tuning

Use the dashboard analytics views to compare cohorts pre- and post- each mechanic. Adjust earn rates if coin inflation rises; adjust streak grace if break rate is unhealthy. Every adjustment is a dashboard change, not a deploy.

What this example proves

Why this is the evaluation page, not just a tutorial

If you reached this section, you watched nine mechanics — across coins, streaks, badges, leaderboards, contests, referrals, and webhook-driven fulfilment — compose into a single product without any platform code change. That is the validity proof: the Bricqs engine is the substrate; your product is the configuration.

No platform code touched

Every mechanic was defined via the admin API or the dashboard. The engine itself stayed unchanged. Adding a tenth mechanic later requires the same: define a program, subscribe it to an event.

Atomic, idempotent, auditable

Every coin awarded, every badge granted, every contest entry, every reward allocation is a row in an append-only ledger keyed on (participant, source_fact). Replay-safe by construction.

Cheating-resistant by default

Scoring runs server-side. The client emits actions; the engine decides what they mean. Velocity caps, rank-jump detection, and per-event daily caps are configurable and enforced before any reward lands.

Composable across surfaces

Same engine renders into a React SDK on the web, a REST API on mobile, an iframe embed on a marketing microsite. Switching surfaces does not migrate data.

Developer FAQ

Common questions when integrating gamification with Bricqs.

Ready to build yours?

Bricqs handles the engine, you ship the product

Every mechanic in this page is configurable in the dashboard, callable from the REST API, and renderable through the headless React SDK. Start with a free tenant, copy the patterns, and you are live the same week.

1 brief to align the room2 mechanics max in version one
What happens next
01
Pick the mechanic
Choose the smallest working system for the brief.
02
Launch without rebuilds
Configure rules and rewards in one place.