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.
- One reward.allocated webhook drives 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/ingest/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/ingest/events { post_published, profile_step_completed, ... }
│
▼
Bricqs ingestion (Redis Streams · 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 daily_login_streak advanced with grace + freeze
├─▶ Follower milestones challenge with milestone targets at 50/250/1k/5k
├─▶ Weekly leaderboards Redis ZSET 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, create a tenant for Qlan.gg. Each tenant ships with two key pairs: bq_test_* (sandbox — free to mutate) and bq_live_* (production — real ledger, real rewards). Inside each pair, you have a participant key (safe for read-mostly client integrations) and an admin key (CRUD on programs, scope gamify:admin).
The admin key never reaches the browser. The participant key is for server-to-server traffic on your backend. The client gets short-lived participant tokens, minted on demand from a server route (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
# Server-only — never prefix with NEXT_PUBLIC_
BRICQS_ADMIN_KEY=bq_live_admin_aV7p...
BRICQS_PARTICIPANT_KEY=bq_live_part_xZ91...
BRICQS_WEBHOOK_SECRET=whsec_... # for the reward.allocated handler3. Install the React SDK
npm install @bricqs/headless-react
# or
pnpm add @bricqs/headless-react4. Mint participant tokens server-side
The browser never holds an admin key. On login, your backend trades the admin key for a participant-scoped JWT (1-hour TTL by default), stores it in an HttpOnly cookie, and ships it to the SDK Provider on the next render.
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: {
"Authorization": `Bearer ${process.env.BRICQS_ADMIN_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" },
ttl_seconds: 3600,
}),
});
const { token } = await res.json();
cookies().set("bq_token", token, {
httpOnly: true,
sameSite: "lax",
maxAge: 3600,
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/headless-react";
export function Providers({
children,
participantToken,
}: {
children: React.ReactNode;
participantToken: string | null;
}) {
return (
<BricqsProvider
tenantId={process.env.NEXT_PUBLIC_BRICQS_TENANT!}
participantToken={participantToken ?? undefined}
env={process.env.NEXT_PUBLIC_BRICQS_ENV as "production" | "test"}
apiBaseUrl={process.env.NEXT_PUBLIC_BRICQS_API_BASE!}
onTokenExpiringSoon={async () => {
// Called at ~80% of TTL. Refresh by re-hitting the token route.
await fetch("/api/bricqs/token", { method: "POST" });
}}
>
{children}
</BricqsProvider>
);
}Hooks called below this provider read the canonical state. Provider also handles SWR-style caching and revalidation.
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/ingest/events \
-H "Authorization: Bearer $BRICQS_PARTICIPANT_KEY" \
-H "Content-Type: application/json" \
-d '{
"participant_id": "smoke_test_user",
"event_type": "smoke_test_ping",
"attributes": { "from": "setup-check" },
"idempotency_key": "smoke-once"
}'
# Then verify the participant was created and the fact landed:
curl https://api.bricqs.co/api/v1/gamify/state/smoke_test_user \
-H "Authorization: Bearer $BRICQS_PARTICIPANT_KEY"The participant is auto-created on first event. You never need a separate /participants endpoint unless you want to seed profile data.
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” points program
Coins are a labeled instance of the points primitive. Every earn rule, every direct grant, every redemption is keyed to this program slug.
curl -X POST https://api.bricqs.co/api/v1/programs/points \
-H "Authorization: Bearer $BRICQS_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "coins",
"display_name": "Coins",
"currency_symbol": "⊙",
"decimals": 0,
"expiry": { "type": "sliding", "days": 365 },
"ledger": { "mode": "append_only" }
}'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. Tier promotions fire a tier.changed webhook.
{
"slug": "creator_tiers",
"qualifying_metric": "lifetime_coins",
"tiers": [
{ "slug": "rookie", "name": "Rookie", "min": 0, "perks": [] },
{ "slug": "rising", "name": "Rising", "min": 500, "perks": ["banner_color"] },
{ "slug": "pro_creator", "name": "Pro Creator", "min": 5000, "perks": ["banner_color", "merch_eligible", "1_5x_multiplier"] },
{ "slug": "legend", "name": "Legend", "min": 25000, "perks": ["banner_color", "merch_eligible", "2x_multiplier", "creator_payout"] }
]
}The 1_5x_multiplier and 2x_multiplier perks are read by the points-engine multiplier-rule resolver and applied to every coin award atomically.
3. Create the badge catalog
Twelve badges across three families. Each carries a slug, display name, color, icon, description, and a condition the engine evaluates. Once defined, badges are auto-awarded — your code never calls a “grant badge” endpoint.
# Full schema for the Marathon badge — replicate for the other 11
curl -X POST https://api.bricqs.co/api/v1/badges \
-H "Authorization: Bearer $BRICQS_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "marathon",
"name": "Marathon",
"description": "Maintained the daily login streak for 30 days.",
"color": "#7C3AED",
"icon": "trophy",
"rarity": "rare",
"condition": {
"streak_slug": "daily_login",
"days": 30
}
}'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. Codes are uploaded once via CSV (or POST'd in bulk); the engine atomically pops one per allocation so two parallel claims never collide.
curl -X POST https://api.bricqs.co/api/v1/rewards \
-H "Authorization: Bearer $BRICQS_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "streamer_merch",
"type": "physical",
"display_name": "Qlan.gg branded overlay pack",
"description": "Twitch overlay + emote pack + sticker sheet.",
"expires_in_days": 90,
"inventory_mode": "code_pool",
"low_inventory_alert_at": 50
}'# Bulk-add codes from a CSV
curl -X POST https://api.bricqs.co/api/v1/rewards/streamer_merch/codes \
-H "Authorization: Bearer $BRICQS_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"codes": [
{ "code": "STR-A4F2", "expires_at": "2026-12-31T23:59:59Z" },
{ "code": "STR-9B17", "expires_at": "2026-12-31T23:59:59Z" }
]
}'
# Or upload a CSV file
curl -X POST https://api.bricqs.co/api/v1/rewards/streamer_merch/codes/csv \
-H "Authorization: Bearer $BRICQS_ADMIN_KEY" \
-F "file=@streamer_codes.csv"Inventory exhaustion returns a no-codes error and the reward is disabled in the catalog until refilled.
5. Subscribe to outbound webhooks
curl -X POST https://api.bricqs.co/api/v1/webhooks \
-H "Authorization: Bearer $BRICQS_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.qlan.gg/bricqs/events",
"events": [
"reward.allocated", "reward.claimed", "reward.expired",
"tier.changed",
"challenge.completed", "challenge.milestone_reached",
"badge.earned"
],
"secret": "whsec_REPLACE_WITH_OPENSSL_RAND_HEX_32"
}'One webhook endpoint subscribing to seven event types. Store the secret in BRICQS_WEBHOOK_SECRET — used to verify signatures on receive.
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 state through useChallenge.
250 coins to unlock the Streamer badge
1. Define the challenge (one time, admin)
curl -X POST https://api.bricqs.co/api/v1/challenges \
-H "Authorization: Bearer bq_live_admin_..." \
-H "Content-Type: application/json" \
-d '{
"slug": "creator_profile_setup",
"name": "Build your gamer profile",
"objectives": [
{ "slug": "display_name", "evaluator": "activity_count", "event_type": "profile_step_completed", "filter": { "step": "display_name" }, "target": 1 },
{ "slug": "game_stats", "evaluator": "activity_count", "event_type": "profile_step_completed", "filter": { "step": "game_stats" }, "target": 1 },
{ "slug": "social_sync", "evaluator": "activity_count", "event_type": "profile_step_completed", "filter": { "step": "social_sync" }, "target": 1 },
{ "slug": "favorites", "evaluator": "activity_count", "event_type": "profile_step_completed", "filter": { "step": "favorites" }, "target": 1 },
{ "slug": "squad", "evaluator": "activity_count", "event_type": "profile_step_completed", "filter": { "step": "squad" }, "target": 1 }
],
"reward": {
"type": "badge_grant",
"badge_slug": "streamer"
}
}'2. Define the per-step coin earn rules
# Each rule fires when a profile_step_completed event for that step arrives.
# The engine writes a ledger entry; the participant's coin balance updates atomically.
for step in display_name:50 game_stats:75 social_sync:100 favorites:50 squad:100; do
S=${step%:*}; C=${step##*:}
curl -X POST https://api.bricqs.co/api/v1/programs/points/earn-rules \
-H "Authorization: Bearer bq_live_admin_..." \
-H "Content-Type: application/json" \
-d "{
\"program_slug\": \"coins\",
\"event_type\": \"profile_step_completed\",
\"filter\": { \"step\": \"$S\" },
\"amount\": $C
}"
done3. Your backend posts an event when the user finishes a step
import { bricqs } from "./bricqsClient";
export async function recordProfileStep(userId: string, step: ProfileStep) {
await bricqs.events.send({
participant_id: userId,
event_type: "profile_step_completed",
attributes: { step }, // "display_name" | "game_stats" | "social_sync" | "favorites" | "squad"
idempotency_key: `${userId}:profile_step:${step}`,
});
}The idempotency key is deterministic — repeated POSTs (mobile retries, browser back-button) never double-credit.
4. The React app reads challenge state through useChallenge
"use client";
import { useChallenge, usePoints } from "@bricqs/headless-react";
export function ProfileProgress() {
const { objectives, progress } = useChallenge("creator_profile_setup");
const { available } = usePoints("coins");
return (
<div>
<p>{available} / 375 coins</p>
{objectives.map((o) => (
<Step
key={o.slug}
label={LABELS[o.slug]}
done={o.current >= o.target}
coins={COIN_PER_STEP[o.slug]}
/>
))}
</div>
);
}No optimistic UI required — the engine writes the fact in <500ms and the hook revalidates within the same window.
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 earn rules (1-event → fixed reward)
For the three events where one action equals one reward, configure earn rules with a daily cap. The engine handles the rest — every duplicate event past the cap still writes a fact but skips the coin award.
{
"rules": [
{ "event_type": "user_logged_in", "amount": 5, "daily_cap_per_participant": 1 },
{ "event_type": "post_published", "amount": 20, "daily_cap_per_participant": 3 },
{ "event_type": "game_stats_synced", "amount": 25, "daily_cap_per_participant": 1 }
]
}No counter, no aggregation, no server-side state. The engine enforces caps and writes ledger entries atomically.
“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 Redis key, no edge-case handling, no “what if my counter drifts.”
{
"slug": "daily_comments_5",
"name": "Comment activist — 5 a day",
"schedule": {
"recurrence": "daily",
"tz": "Asia/Kolkata"
},
"objectives": [
{
"slug": "comments",
"evaluator": "activity_count",
"event_type": "comment_posted",
"target": 5
}
],
"reward": { "type": "points", "amount": 10 }
}The lifecycle worker rolls a fresh instance every day at midnight tz. Each participant gets their own progress; the engine grants 10 coins the moment the 5th qualifying event lands.
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_type: "comment_posted",
attributes: { comment_id: commentId },
idempotency_key: `${userId}:comment:${commentId}`,
});
}The customer-side Redis counter pattern is unnecessary here — challenges are the right primitive for threshold-style rewards. Save Redis for when you actually need it (rate limits, dedupe-by-content, etc).
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_type: "game_stats_synced",
attributes: { 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
{
"slug": "daily_login",
"period": "day",
"grace_hours": 6,
"qualifying_event_type": "user_logged_in",
"freezes": {
"starting_balance": 3,
"earn_rule": { "every_n_streak_days": 7, "amount": 1 }
},
"milestone_badges": [
{ "days": 7, "badge_slug": "week_one" },
{ "days": 30, "badge_slug": "marathon" },
{ "days": 100,"badge_slug": "legend" }
]
}2. React renders the streak
"use client";
import { useStreak } from "@bricqs/headless-react";
export function StreakCard() {
const { current, longest, freezes, breaksAt, history } = useStreak("daily_login");
return (
<div>
<h3>{current} day streak</h3>
<p>{freezes} freezes available · breaks {formatRelative(breaksAt)}</p>
<DayGrid days={history.slice(-7)} />
</div>
);
}No backend code needed — logging in already POSTs user_logged_in, which the streak program subscribes to.
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) {
const { newCount } = await graphDb.follow(followerId, followeeId);
// Tell Bricqs about the follower count delta on the followee.
await bricqs.events.send({
participant_id: followeeId,
event_type: "follower_count_changed",
attributes: { count: newCount, delta: +1, follower_id: followerId },
idempotency_key: `${followeeId}:follow:${followerId}`,
});
}2. Configure the milestone challenge
{
"slug": "follower_milestones",
"name": "Grow your audience",
"evergreen": true,
"objectives": [
{
"slug": "follower_count",
"evaluator": "score_threshold",
"event_type": "follower_count_changed",
"score_attribute": "count",
"milestones": [
{ "target": 50, "reward": { "type": "points", "amount": 100 }, "badge_slug": "rising_star" },
{ "target": 250, "reward": { "type": "points", "amount": 500 }, "feature_unlock": "banner_customization" },
{ "target": 1000, "reward": { "type": "reward", "reward_slug": "streamer_merch" }, "tier_promotion": "pro_creator" },
{ "target": 5000, "reward": { "type": "reward", "reward_slug": "legend_payout" }, "tier_promotion": "legend" }
]
}
]
}One challenge, four milestone tiers. The engine fires per-milestone webhooks (challenge.milestone_reached) so your service can grant feature flags or run physical fulfilment.
Mechanic 5
Weekly leaderboards: one per game, resets Monday
Live, sub-10ms leaderboards keyed on the participant's coins earned this week, bracketed per game. Top 10 get auto-entered into next week's prize pool. Powered by a Bricqs contest with a weekly window.
Weekly leaderboard
Valorant · Wk 21
Top 10 win the Wk 22 prize pool · resets Monday
1. Spin up a weekly contest per game
# One contest definition, three brackets (Valorant, Apex, BGMI),
# all weekly. The lifecycle worker auto-rolls a new contest every Monday.
curl -X POST https://api.bricqs.co/api/v1/contests \
-H "Authorization: Bearer bq_live_admin_..." \
-H "Content-Type: application/json" \
-d '{
"slug": "weekly_valorant",
"scoring": {
"event_type": "post_published",
"filter": { "game_tag": "valorant" },
"score_attribute": "engagement_score"
},
"schedule": { "recurrence": "weekly", "starts_on": "monday", "tz": "Asia/Kolkata" },
"prizes": {
"1-1": { "reward_slug": "weekly_mvp_badge" },
"2-10": { "reward_slug": "top_10_coins_500" }
},
"anti_fraud": {
"velocity_per_hour": 20,
"rank_jump_threshold": 250
}
}'2. The leaderboard hook
"use client";
import { useLeaderboard } from "@bricqs/headless-react";
export function WeeklyLeaderboard({ game }: { game: "valorant" | "apex" | "bgmi" }) {
const { entries, participantRank, isLive } = useLeaderboard(`weekly_${game}`, { limit: 10 });
return (
<Card live={isLive}>
{entries.map((e) => (
<Row key={e.participant_id} {...e} isYou={e.rank === participantRank} />
))}
{participantRank > 10 && <YouFooter rank={participantRank} />}
</Card>
);
}No polling. The SDK opens a single revalidate channel and the engine pushes a diff when ranks shift.
3. Compute and emit engagement_score (the scoring input)
The contest scores on engagement_score, not raw posts. A small analytics worker in your backend rolls up posts + likes + watch-minutes into a per-game weekly score and emits it whenever it changes.
// 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;
await bricqs.events.send({
participant_id: userId,
event_type: "engagement_score_updated",
attributes: { game_tag: gameTag, engagement_score: score, week_start: since.toISOString() },
idempotency_key: `${userId}:eng:${gameTag}:${since.toISOString().slice(0,10)}`,
});
}Score is monotonically increasing within a week; idempotency_key keyed on the week start lets you safely re-emit on every recomputation without polluting the ledger.
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
# Activity badges — condition-based, auto-awarded when met
POST /api/v1/badges { "slug": "first_post", "condition": { "event_type": "post_published", "count": 1 } }
POST /api/v1/badges { "slug": "marathon", "condition": { "streak_slug": "daily_login", "days": 30 } }
POST /api/v1/badges { "slug": "streamer", "condition": { "challenge_slug": "creator_profile_setup", "state": "completed" } }
POST /api/v1/badges { "slug": "highlight_reel", "condition": { "challenge_slug": "weekly_clip_challenge", "state": "completed" } }
# Positional badges — granted by contest scoring service on rank
POST /api/v1/badges { "slug": "top_100", "condition": { "leaderboard_slug": "season_global", "rank_lte": 100 } }
POST /api/v1/badges { "slug": "rising_star", "condition": { "challenge_slug": "follower_milestones", "milestone": "50" } }Once the catalog is defined, no manual grants are needed. Every fact the engine processes is matched against badge conditions atomically.
Render the catalog with useBadges
"use client";
import { useBadges } from "@bricqs/headless-react";
export function BadgeCabinet() {
const { earned, locked } = useBadges({ includeLocked: true });
return (
<Grid>
{[...earned, ...locked].map((b) => (
<BadgeTile
key={b.slug}
name={b.name}
color={b.color}
earned={b.earned_at !== null}
progress={b.progress /* 0..1 for locked */}
/>
))}
</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.allocated 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
{
"slug": "season_global_s14",
"scoring": {
"event_type": "engagement_score_updated",
"score_attribute": "season_score"
},
"schedule": {
"starts_at": "2026-06-01T00:00:00Z",
"ends_at": "2026-08-23T23:59:59Z",
"tz": "Asia/Kolkata"
},
"prizes": {
"1-1": { "reward_slug": "season_diamond", "tier_promotion": "legend" },
"2-100": { "reward_slug": "season_branded_merch" },
"101-1240": { "reward_slug": "season_in_app_credit_500" },
"1241-6200":{ "reward_slug": "season_mvp_badge_only" }
},
"max_reward_liability": 250000,
"anti_fraud": { "velocity_per_hour": 50, "rank_jump_threshold": 500 }
}2. Subscribe to the reward.allocated webhook
curl -X POST https://api.bricqs.co/api/v1/webhooks \
-H "Authorization: Bearer bq_live_admin_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.qlan.gg/bricqs/rewards",
"events": ["reward.allocated", "reward.claimed", "reward.expired"],
"secret": "whsec_REPLACE_WITH_OPENSSL_RAND_HEX_32"
}'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.allocated") {
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
{
"slug": "weekly_clip_challenge",
"name": "Post 5 clips this week",
"schedule": { "recurrence": "weekly", "starts_on": "monday", "tz": "Asia/Kolkata" },
"objectives": [
{ "slug": "clip_count", "evaluator": "activity_count",
"event_type": "post_published", "filter": { "has_clip": true },
"target": 5 }
],
"reward": {
"type": "bundle",
"items": [
{ "type": "points", "amount": 250 },
{ "type": "badge", "badge_slug": "highlight_reel" }
]
}
}The lifecycle worker rolls a fresh instance every Monday. Progress resets; the badge persists if earned in any week.
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. Configure the program
{
"slug": "squad",
"conversion_event": "first_post_published",
"attribution_window_days": 30,
"rewards": {
"referrer": { "type": "points", "amount": 200 },
"referee": { "type": "points", "amount": 200 }
},
"milestones": [
{ "successful_referrals": 5,
"reward": { "type": "badge", "badge_slug": "squad_captain", "grant_to": "both" } }
]
}Conversion fires on first_post_published, not signup — defeats fake-account farming and lines rewards up with real engagement.
2. Render share + invite history with useReferral
"use client";
import { useReferral } from "@bricqs/headless-react";
export function ReferralCard() {
const { code, shareUrl, copyToClipboard, history, milestone } = useReferral("squad");
return (
<Card>
<Code value={code} onCopy={copyToClipboard} />
<MilestoneBar current={milestone.current} target={milestone.target} reward="Squad Captain" />
{history.map((h) => <InviteRow key={h.id} {...h} />)}
</Card>
);
}The hook reads canonical state — conversions, attributions, milestone progress — 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_type: "participant_created",
attributes: { 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/headless-react.
One endpoint, everything
GET /api/v1/gamify/participants/{participant_id}/state 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
curl https://api.bricqs.co/api/v1/gamify/participants/p_qlan_arjun/state \
-H "Authorization: Bearer $BRICQS_PARTICIPANT_KEY" | jq
# Response
{
"participant_id": "p_qlan_arjun",
"points": {
"available": 2840,
"lifetime": 12420,
"currency": "coins"
},
"tier": {
"slug": "pro_creator",
"name": "Pro Creator",
"progress_to_next": { "current": 12420, "target": 25000, "next_tier": "legend" }
},
"badges": [
{ "slug": "first_post", "earned_at": "2026-04-12T10:14:00Z" },
{ "slug": "rising_star","earned_at": "2026-04-19T08:42:00Z" },
{ "slug": "streamer", "earned_at": "2026-05-02T11:08:00Z" },
{ "slug": "marathon", "earned_at": null, "progress": { "current": 12, "target": 30 } }
],
"streaks": {
"daily_login": { "current": 12, "longest": 47, "freezes": 3, "breaks_at": "2026-05-15T05:59:59+05:30" }
},
"challenges": {
"creator_profile_setup": { "state": "completed", "completed_at": "2026-05-02T11:08:00Z" },
"follower_milestones": { "state": "in_progress", "current": 187, "next_milestone": 250 },
"daily_comments_5": { "state": "in_progress", "current": 3, "target": 5 },
"weekly_clip_challenge": { "state": "in_progress", "current": 3, "target": 5 }
},
"rewards": {
"claimed": 2,
"available": 3,
"items": [ { "slug": "streamer_merch", "claimed_at": "2026-05-08T..."} ]
}
}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} | — | useLeaderboard |
| My rank + surrounding rows | GET /gamify/leaderboards/{code}/rank/{pid} | — | useLeaderboard().participantRank |
| Reward catalog | GET /gamify/rewards | POST /gamify/rewards/{id}/claim | useRewards |
| Claimed rewards (codes, expiry) | GET /gamify/participants/{pid}/rewards | — | useRewards().claimed |
| Challenge progress | GET /challenges/{id}/progress | POST /challenges (admin) | useChallenge |
| Challenges available to me | GET /challenges/available | — | useChallenges |
| Referral code + stats | GET /referrals/stats/{pid} | POST /referrals/generate · /convert | useReferral |
| Event history (last N) | GET /gamify/participants/{pid}/events | POST /gamify/events · /events/batch | useEventHistory |
| Participant profile | GET /gamify/participants/{pid} | POST /participants · PATCH /participants/{pid} | useParticipant |
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}`;
const [top, mine] = await Promise.all([
fetch(`https://api.bricqs.co/api/v1/gamify/leaderboards/${code}?limit=10`, {
headers: { Authorization: `Bearer ${PARTICIPANT_KEY}` },
}).then(r => r.json()),
fetch(`https://api.bricqs.co/api/v1/gamify/leaderboards/${code}/rank/${me}`, {
headers: { Authorization: `Bearer ${PARTICIPANT_KEY}` },
}).then(r => r.json()),
]);
return {
rows: top.entries, // [{ rank, participant_id, score, display_name }]
me: mine, // { rank: 9142, score: 2340, neighbors: [{...}, {...}, {...}] }
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. Polling at 15s is plenty for a weekly leaderboard — sub-second updates are only relevant for live in-event leaderboards.
// Render the badge cabinet — earned + locked + per-badge progress.
const res = await fetch(
`https://api.bricqs.co/api/v1/gamify/participants/${userId}/badges?include_locked=true`,
{ headers: { Authorization: `Bearer ${PARTICIPANT_KEY}` } },
);
const { badges } = await res.json();
// badges = [
// { slug: "first_post", name: "First Post", icon: "..", color: "..", earned_at: "2026-04-12T...", rarity: "common" },
// { slug: "marathon", name: "Marathon", icon: "..", color: "..", earned_at: null, progress: { current: 12, target: 30 }, rarity: "rare" },
// ...
// ]Locked badges include progress, so a 'you're 12 of 30 days to Marathon' tile is one render away — no derived state, no extra calls.
// Catalog + claim flow with codes + expiry — no SDK.
async function loadRewards(userId: string) {
const [catalog, mine] = await Promise.all([
fetch(`https://api.bricqs.co/api/v1/gamify/rewards?participant_id=${userId}`, {
headers: { Authorization: `Bearer ${PARTICIPANT_KEY}` },
}).then(r => r.json()), // { rewards: [{ id, slug, cost, tier_lock, eligible }, ...] }
fetch(`https://api.bricqs.co/api/v1/gamify/participants/${userId}/rewards`, {
headers: { Authorization: `Bearer ${PARTICIPANT_KEY}` },
}).then(r => r.json()), // { claimed: [{ id, code, claimed_at, expires_at, status }, ...] }
]);
return { catalog: catalog.rewards, claimed: mine.claimed };
}
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 ${PARTICIPANT_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `${userId}:reward:${rewardId}`,
},
body: JSON.stringify({ participant_id: userId }),
});
if (res.status === 409) throw new Error("Insufficient coins or already claimed");
return res.json(); // { code: "STR-9F2B", expires_at: "2026-08-31T...", status: "claimed" }
}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) | Open WebSocket / SSE under the hood | Currently SDK-only. REST consumers poll at the cadence above. SSE endpoint is on the roadmap — see callout below. |
| 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 headless SDK currently uses a long-poll channel under the hood. A public SSE endpoint at GET /api/v1/gamify/participants/{pid}/stream is planned so non-React clients can subscribe to participant-scoped updates (points, badges, challenge progress, leaderboard rank diffs) without polling. Until it ships, polling at the cadences in the table above covers every shipped use case.
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/ingest/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 — composite of posts, likes, watch | Season contest, Top-100 badge |
| 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.allocated (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 | Contest | 3 weekly contests, bracketed by game. Auto-rolls Monday. |
| 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.allocated, reward.claimed, reward.expired → 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_part_...
ADMIN="$BRICQS_TEST_ADMIN_KEY" # bq_test_admin_...
P="smoke_$(date +%s)" # fresh participant per run
post() {
curl -sS -X POST "$API/ingest/events" \
-H "Authorization: Bearer $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_type\":\"profile_step_completed\",\"attributes\":{\"step\":\"$step\"},\"idempotency_key\":\"$P:profile:$step\"}"
done
# 2. Daily activity — login + post + comment threshold + sync
post "{\"participant_id\":\"$P\",\"event_type\":\"user_logged_in\",\"idempotency_key\":\"$P:login:$(date +%F)\"}"
post "{\"participant_id\":\"$P\",\"event_type\":\"post_published\",\"attributes\":{\"post_id\":\"post_1\",\"game_tag\":\"valorant\",\"has_clip\":true},\"idempotency_key\":\"$P:post:post_1\"}"
# 3. Follower milestone fire
post "{\"participant_id\":\"$P\",\"event_type\":\"follower_count_changed\",\"attributes\":{\"count\":50,\"delta\":50},\"idempotency_key\":\"$P:followers:50\"}"
# Verify ledger + badges + challenge progress all moved
curl -sS "$API/gamify/state/$P" -H "Authorization: Bearer $KEY" | jq '{
coins: .points.available,
badges_earned: [.badges[] | select(.earned_at != null) | .slug],
active_challenges: [.challenges[] | { slug, progress }]
}'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 single fact
# Trace what happened to one event. Returns the fact + every program decision.
curl "$API/debug/facts?idempotency_key=$P:post:post_1" \
-H "Authorization: Bearer $ADMIN" | jqThe debug endpoint returns the canonical fact and the per-program audit trail (which earn rule fired, which challenge advanced, why). Indispensable when a user emails support saying 'I did the thing but I didn't get coins'.
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}/ingest/events`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BRICQS_PARTICIPANT_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 /ingest/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.allocated webhook receiver. Measure: prize liability vs cap, time-to-fulfilment after allocation, NPS of prize winners.
Ongoing — Tuning
Use /api/v1/admin/analytics 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.
