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.
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.
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 serviceOne 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
# 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/webhooks3. Install the React SDK
npm install @bricqs/sdk-react
# or
pnpm add @bricqs/sdk-react4. 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.
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
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>
);
}"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
# 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.
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).
# 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.
# 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.
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
}'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
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.
250 coins to unlock the Streamer badge
1. Define the challenge (one time, admin)
# 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.)
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.
"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.
Daily login
2 min ago
Published post · 'IGL macro reads'
32 min ago
5 comments today
1 hr ago
Synced Valorant stats
5 hr ago
Follower #50 unlocked
yesterday
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.
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.”
# 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}/activateEach 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
// 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.
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
3 streak-freezes in your wallet · 18 days to the Marathon badge
Today: log in to extend
+5 coins · keeps the streak alive
1. Configure the streak program
{
"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)
// 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
"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.
50 followers
100 coins · Rising Star badge
250 followers
500 coins · Banner unlock
1,000 followers
Pro tier · branded merch
5,000 followers
Legend tier · creator payout
1. Emit follower events from the social graph service
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)
{ "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
Top 10 win the Wk 22 prize pool · resets Monday
1. One weekly-window leaderboard per game bracket
# 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
{
"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
"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.
// 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.
First Post
Rising Star
Streamer
Marathon
Top 100
Legend
Define the badge catalog
# 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 metricBadges 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
"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
1,240 / 1,240
Top 10%
₹500 in-app credit
12,400 / 12,400
Top 50%
Season MVP badge
47,820 / 62,000
Reward unlocked
Streamer tier · branded overlay pack
Earned at 1,000 followers + 30-day streak. Code emailed and added to your wallet.
1. Define the season contest with prize tiers
{
"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
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
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
{
"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.
Your code
You earn 200 coins for every friend who joins. At 5 referrals, both of you unlock the Squad Captain badge.
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.
// 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
"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.
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;
}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
# 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
| Mechanic | REST endpoint (read) | Mutation (write) | SDK equivalent |
|---|---|---|---|
| Coins balance | GET /gamify/participants/{pid}/points | POST /gamify/points/award · /deduct · /award-batch | usePoints |
| Coins transaction history | GET /gamify/participants/{pid}/points/transactions | — | usePoints().transactions |
| Tier + progress | GET /gamify/participants/{pid}/tier | POST /gamify/tiers/assign (admin) | useTier |
| Badges (earned + locked + progress) | GET /gamify/participants/{pid}/badges | POST /gamify/badges/award (admin) | useBadges |
| Streak detail (history, freezes) | GET /gamify/participants/{pid}/streaks | POST /gamify/streaks/record | useStreak |
| Leaderboard top-N | GET /gamify/leaderboards/{code} (authed) · GET /public/leaderboards/{code}?engagement_id= (credential-less) | — | useLeaderboard (uses the credential-less /public variant) |
| My rank + surrounding rows | GET /gamify/leaderboards/{code}/rank/{pid} | — | useLeaderboard().myRank |
| Reward catalog | GET /gamify/rewards | POST /gamify/rewards/{id}/claim | useRewards |
| Claimed rewards (codes, expiry) | GET /gamify/participants/{pid}/rewards | — | useRewards |
| Challenge progress | GET /gamify/challenges/{id}/progress | POST /challenges (admin) | useChallenge |
| Challenges available to me | GET /gamify/challenges/by-engagement/{eid}/all | — | useChallenges |
| Referral code + stats | GET /gamify/referrals/stats/{pid} | POST /gamify/referrals/generate · /convert | useReferral |
| Event history (last N) | GET /gamify/participants/{pid}/events | POST /gamify/events · /events/batch | client.events |
| Participant profile + state | GET /gamify/state/{pid} | POST /participants · PATCH /participants/{pid} | useParticipantState |
3. Three live UI examples in plain fetch — no React, no SDK
// 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.
// 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.
// 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.
| Surface | How “live” does it need to feel? | Recommended poll |
|---|---|---|
| Profile state (points, tier, badges) | Updates within a tab focus | Refetch on window focus + after every POST mutation |
| Coin balance (transactional display) | Within a few seconds of action | Refetch immediately after each event POST |
| Weekly leaderboard | Within ~15 seconds | Poll every 15s while the leaderboard is on screen |
| In-match / live contest leaderboard | Sub-second | Poll every 2-3s, or upgrade to the live-channel endpoint (see Gap analysis) |
| Streak card | Per session | Refetch on app open + after the daily-login event POST |
| Reward catalog | Per page view | Refetch on /rewards route mount |
| Challenge progress | Within a few seconds | Refetch after every event POST that could advance an objective |
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:
| Capability | SDK behavior | REST-only equivalent |
|---|---|---|
| Shared cache across components | Multiple components calling usePoints share one fetch | Use SWR, TanStack Query, Apollo, Pinia/Zustand store — same effect with one extra dep |
| Auto-revalidation on focus | Refetches stale resources when the tab regains focus | Add 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 mutations | Mutate cache instantly; reconcile on response | Update your local store immediately, roll back on error |
| Token refresh before TTL | Built-in helper that hits your /token route at ~80% TTL | Implement 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 type | Where it comes from | Programs that react |
|---|---|---|
| profile_step_completed | Onboarding wizard, per step | Coins earn rule (per-step amount), Profile-setup challenge |
| user_logged_in | App open / web session start | Coins (5/day), Daily-login streak |
| post_published | Compose-post submit | Coins (20/post · cap 3/day), Weekly-clip challenge, Weekly leaderboard, Engagement score |
| comment_posted | Every comment submitted | Daily-recurring challenge (5/day → 10 coins via reward) |
| game_stats_synced | Twitch/Riot/Steam OAuth callback | Coins (25/day), Game-stats badge |
| follower_count_changed | Social-graph service on follow/unfollow | Follower-milestones challenge |
| engagement_score_updated | Analytics worker — emits the score DELTA per rollup | Optional quality-weighted contest scoring (payload.delta rule) |
| referral_converted | First-post event of an attributed referee | Referral program (double-sided), Squad-captain milestone |
| challenge_completed | Emitted by the engine itself | Coins bonus, Badge grant, Webhook fan-out |
| reward.claimed.v1 (out) | Emitted by the engine on prize allocation | Your fulfilment service via HMAC-signed webhook |
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.
| Program | Type | Purpose |
|---|---|---|
| coins | Points | Single named points program. Earn rules + spend menu. |
| creator_profile_setup | Challenge | 5-step onboarding. Grants Streamer badge on completion. |
| weekly_clip_challenge | Challenge | Recurring weekly. Post 5 clips → Highlight Reel badge + coins. |
| follower_milestones | Challenge | Evergreen score-threshold challenge with 4 tiered milestones. |
| daily_login | Streak | Daily streak with 6h grace, 3 starting freezes, milestone badges. |
| weekly_valorant / apex / bgmi | Leaderboard + Contest | 3 weekly-window leaderboards; one prize-pool contest per week (no auto-roll — script or duplicate in dashboard). |
| season_global_s14 | Contest | 12-week season. Tiered prize pool with Top 50/10/1% rewards. |
| squad | Referral | Double-sided 200 coins + 5-friend milestone. |
| badges catalog | Badges | First Post, Marathon, Streamer, Highlight Reel, Top 100, Rising Star, Squad Captain, Wk-MVPs, Season MVP, Legend, Diamond. |
| rewards catalog | Rewards | streamer_merch, legend_payout, season_diamond, season_branded_merch, season_in_app_credit_500, weekly_mvp_badge, top_10_coins_500. |
| webhooks | Outbound | reward.claimed.v1, milestone.reached.v1, tier.changed.v1 → api.qlan.gg/bricqs/rewards. |
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.
#!/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
# Read the full participant snapshot — points, tier, badges, streaks, challenges.
curl "$API/gamify/state/$P" \
-H "X-API-Key: $ADMIN" | jqOne 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.
// 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
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.idfield. Replays carryX-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
| Status | Meaning | Action |
|---|---|---|
| 400 invalid_payload | Schema mismatch on the event body. | Fix the payload. Do not retry — it will fail again. |
| 401 unauthorized | Wrong key, missing scope, or expired participant token. | Mint a fresh token; verify the key has the required scope. |
| 404 unknown_program | Slug does not exist (typo or program not provisioned). | Re-run Step 1 provisioning. Verify slug spelling. |
| 409 insufficient_funds | Deduct would push available_points negative. | Surface to the user; do not retry. |
| 429 rate_limited | Over per-key or per-tenant limit. | Read Retry-After header; back off. |
| 5xx engine_error | Transient 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.
