Contests in headless gamification
A contest is a time-bound competition with ranked winners. The hard parts are not the leaderboard; the hard parts are fair scoring, fraud detection, tie-breaking, and prize allocation that does not over-spend the budget when the entry count surprises you. This page covers what a contest system actually is, how Bricqs handles those edges, and the patterns you will reach for.
- A contest is the only place where score, rank, and prize allocation are all guaranteed to agree; use it whenever prizes change hands and use a leaderboard otherwise.
- Each scoring fact is counted exactly once thanks to a unique constraint on the source fact id, so retried or replayed events never double-score.
- Always set max_reward_liability to a number you can write a cheque for; the allocation worker stops cleanly when the cap is reached instead of overspending.
- Scoring rules are frozen at publish; if rules need to change, cancel the contest and launch a new one rather than re-publishing mid-flight.
- Use the consistency_label field (realtime_estimated vs final_verified) to gate winner announcements; only announce after the contest hits completed status.
What a contest is, in any gamification system
Before the API. Every contest system, regardless of vendor, decomposes into the same primitives. If you understand these, the Bricqs implementation is just one mapping you could make.
A contest is a named competition with a defined time window, a scoring contract that maps actions to points, an entry list of participants, a live ranking, fraud and budget controls, and a prize map that allocates rewards to top ranks when the window closes. The contest is the only place where score, rank, and prize allocation are all guaranteed to agree.
Engineers wiring contest surfaces (entry button, live leaderboard, prize map) and integrating contest events with downstream systems; product managers designing the rules and prize tiers. Contests are the most visible part of a competitive gamification program; getting them right earns trust.
A leaderboard is a live ranking, computed on read from the underlying data. A contest wraps a leaderboard with scoring rules, fraud detection, prize allocation, and a defined window. Use a leaderboard for ongoing visibility; use a contest when prizes change hands.
A challenge has progression rewards for everyone who completes. A contest has prizes for the top ranks. Both can run on the same audience; they answer different motivational questions.
A milestone fires once a threshold is crossed and pays one participant their reward. A contest pays the top N by rank. Use a milestone for absolute achievement; use a contest for relative competition.
A raffle is a contest with completion_type: raffle. Winner is drawn weighted by score (each point is a ticket), not strictly top-down by rank. Use raffles for fairness across skill levels; use ranked contests when you want a meritocratic top-N.
The decomposition every contest system uses: a definition (the contest as a noun: code, name, duration, scoring rules, prize map, fraud config), an entry (a participant’s opt-in, one per participant per contest), a score event (one row per action that contributed to a participant’s score), and a prize allocation (one row per winner, generated at completion). Definitions are tenant-scoped configuration. Entries, score events, and allocations are all participant-scoped and append-only.
The three operations every API supports: list active contests, enter a contest, and read your standing (rank, score, total_participants). The leaderboard and prize-map endpoints round out the surface; everything else is admin or webhook plumbing.
The hardest design decision is not the schema, it is the scoring contract. How do actions map to points? Three patterns dominate. (1) Rules engine: a list of { fact_name, points, condition } entries. Most expressive, requires you to enumerate what counts. (2) Points aggregation: every points-awarded event adds its delta. Simplest, ties the contest to your existing points economy. (3) Metric-driven: a frozen plan from a metric definition, supporting count or sum with filters. Best when you already have a metric defined for analytics. Pick before launch; changing the contract mid-flight invalidates the score history.
When the contest system is the right tool, and when it is not
Contests have real money or real recognition attached. Get the contracts right before you ship; the cost of a disputed prize is much higher than the cost of an extra hour of design.
You have ranked winners with real prizes
The contest system handles fraud detection, tie-breaking, and prize allocation with budget caps. Doing this on top of a raw leaderboard is fragile; the dedicated system already exists.
You need a verifiable score audit trail
Every scoring event is recorded with the source fact id, the rule that fired, and the points awarded. When a user disputes their score, you can replay the trail and explain exactly what they earned.
Fraud is a real risk on the metric
Velocity caps and auto-flag/auto-disqualify ship by default. Configure thresholds appropriate to your context; the system handles the bookkeeping.
You want to set a hard prize budget
max_reward_liability caps the total prize spend. The allocation worker stops when the next allocation would breach the cap. Prevents the “more entries than expected, blew the prize budget” scenario.
Everyone who hits a target gets the same reward
That is a challenge or a milestone, not a contest. Contests are about relative ranking, not absolute achievement. If the prize map does not depend on rank, you have a challenge.
The window is open-ended
Contests need a defined end so prize allocation can run. Long-running “contest” surfaces with no end date are leaderboards in contest clothing; use the leaderboard primitive instead.
Scoring rules will change after participants enter
Scoring rules are frozen at publish. Re-publishing a contest mid-flight is allowed but invalidates trust; participants who scored under old rules get a confusing replay. Pick the rules carefully before going live.
You cannot afford the budget cap
Uncapped prize maps with surprise entry counts have ended programs. Always set max_reward_liability to a number you can write a cheque for; the system will stop allocations cleanly when the cap is reached.
You only need one winner from a small group
A contest is heavy for “pick the top 1 of 5”. A simple admin action on a leaderboard view is cheaper and clearer. Reserve contests for the cases where scoring, fraud, and audit actually matter.
- Looking for the marketing-team take? The strategy guide on contests covers when contests fit a program, scoring shape, fairness design, KPIs, and example tournaments.
- Configure in the dashboard: Engagements → Contests → New Contest.
Bricqs models contests with the four-part decomposition above: definitions, entries, score events, and prize allocations. You create definitions through the dashboard or the admin API. Participants enter through a public endpoint. Score events accumulate as relevant facts land. The lifecycle worker handles state transitions and prize allocation on a 5-minute tick.
Bricqs handles the heavy parts for you. Each scoring fact is counted exactly once (the score event table enforces a unique constraint on the source fact). Fraud controls run inline on every scoring decision. Prize allocation works off the final score snapshot, with a budget cap and a reward outbox that retries failed deliveries.
What a contest looks like
Three shapes you will see in API responses. The first is the active-contest listing. The second is the participant’s standing. The third is the public leaderboard.
{
"id": "ctst_uuid_123",
"name": "May Weekly Sprint",
"slug": "may-weekly-sprint",
"description": "Top 10 quiz scorers this week win store credit.",
"starts_at": "2026-05-20T00:00:00Z",
"ends_at": "2026-05-26T23:59:59Z",
"scoring_cutoff_at": "2026-05-27T00:05:00Z",
"status": "active", // scheduled | active | scoring | completed
"completion_type": "ranked", // ranked | raffle | top_n
"max_participants": null,
"total_entries": 248,
"engagement_ids": ["eng_uuid_456"]
}scoring_cutoff_at is not the same as ends_at. The cutoff is the deadline by which late-arriving score events for that contest will still be accepted. Defaults to ends_at + 5 minutes so a fact that fires right at the end is not dropped because it landed a millisecond after the window closed. After the cutoff, scores freeze; prize allocation runs.
completion_type drives the prize logic. ranked walks the leaderboard top-down; raffle draws weighted by score; top_n allocates a flat list to the first N.
{
"entered": true,
"entry_id": "ent_uuid_789",
"current_score": 175,
"rank": 14,
"total_participants": 248,
"score_breakdown": {
"quiz_completed": 120,
"spin_completed": 55
},
"entered_at": "2026-05-20T10:42:00Z",
"prize_eligible": false
}Score breakdowns are first-class. Each entry tracks how the score was earned (by rule, by fact name). Surface this in your “Your standing” UI so participants can see what they did and what would help them score more.
prize_eligible reflects current rank. Recomputed on every score event. Use it to render “in line for a prize” nudges while the contest is live.
{
"consistency_label": "realtime_estimated", // final_verified after completion
"total_participants": 248,
"eligible_participants": 187, // entries with score > 0
"leaderboard": [
{ "rank": 1, "display_name": "Alex", "score": 410, "entered_at": "...", "is_current_user": false },
{ "rank": 2, "display_name": "Riley", "score": 380, "entered_at": "...", "is_current_user": false },
{ "rank": 3, "display_name": "Jordan","score": 350, "entered_at": "...", "is_current_user": true }
],
"my_entry": { "rank": 3, "score": 350 },
"display_config": {
"hide_below_rank": null,
"show_score": true
}
}consistency_label tells your UI what to render. While the contest is active and scoring, the leaderboard is realtime_estimated: the live rank, subject to last-second changes. After completion it becomes final_verified: the official standings. Render a small badge so participants know which view they are seeing.
eligible_participants filters out zero-score entries. Use the count to communicate scale honestly (“187 competitors with at least one point” reads better than the raw 248).
How a contest progresses from draft to completed
Two parallel lifecycles run on a contest: the admin states (created, published, transitions handled by the worker) and the participant journey (enter, accumulate score, win or not).
Admin states: draft => scheduled (after publish, before starts_at) => active (entries and scoring accepted) => scoring (after ends_at, late events still scored until cutoff) => completed (final scores, prizes allocated). Plus optional cancelled and archived.
All admin transitions after publish are handled by the lifecycle worker (a 5-minute tick). You do not move the contest through states yourself; the worker does it based on the time fields you set.
- 01
A participant enters
POST /contests/{id}/enter. The engine checks the contest is active, the participant does not already have an entry, andmax_participantshas room. One entry is created, and a factbehavior.contest_entered.v1is emitted. - 02
A scoring event lands
The participant does something that matches the contest's scoring rules (a quiz, a spin, a purchase). The scoring service looks at the fact, applies the matching rule, and writes one score event row keyed on the source fact id. - 03
The unique constraint prevents double counting
If the same source fact lands twice (retry, replay, duplicate delivery), the second insert is a no-op. The participant never scores the same event twice. - 04
Fraud checks run inline
Velocity caps (per minute, hour, day) are checked. If a cap is exceeded, the score is held back andvelocity_violationsincrements on the entry. Auto-flag (default 3) and auto-disqualify (default 5) kick in at the configured counts. - 05
The leaderboard reflects live
The entry is re-ranked. The public leaderboard endpoint returns the latest state with the realtime_estimated consistency label. - 06
The contest enters scoring at ends_at
New entries are rejected; late-arriving facts can still score until scoring_cutoff_at. - 07
The contest completes
The worker rebuilds the final scores from the score event table, walks the leaderboard against the prize tiers, writes one allocation per winner, and routes each allocation through the reward outbox. Failed deliveries are retried with backoff (1s, 5s, 25s, 125s, 625s) and move to dead-letter after the configured max attempts.
contest.entry_submitted.v1, contest.completed.v1, contest.prize_allocated.v1) is on the platform roadmap. Today, facts are emitted internally; the SDK hook reacts to contest:entered and activity:completed in-session. For cross-system sync, poll /contests/{id}/me and /contests/{id}/leaderboard/public.Enter, read, and rank from any backend
Four endpoints cover almost every case. All endpoints are under /api/v1/contests/ today; the planned move to /api/v1/gamify/contests/ is part of the platform namespace consolidation.
List active contests
Returns up to 20 currently-active contests, optionally filtered to those wired to a specific engagement.
curl 'https://api.bricqs.com/api/v1/contests/active?engagement_id=eng_uuid_456' \
-H "Authorization: Bearer bq_live_xxxxx"
# Response (200 OK)
{
"contests": [ { ...active contest row, see data model... } ]
}Cap at 20. Tenants rarely run more than a handful of contests at once; the surface is intentionally shallow. If you need broader admin views, the admin endpoints expose paginated CRUD.
The engagement filter scopes results. Pass an engagement id to render the “contests running on this engagement” widget without client-side filtering.
Enter a contest
Creates one entry per (contest, participant). Idempotent on the composite: re-entering returns the existing entry with already_entered: true rather than failing.
curl -X POST https://api.bricqs.com/api/v1/contests/ctst_uuid_123/enter \
-H "Authorization: Bearer bq_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"participant_id": "user_42",
"engagement_id": "eng_uuid_456"
}'
# Response (200 OK)
{
"success": true,
"entry_id": "ent_uuid_789",
"already_entered": false,
"message": "Entered the contest."
}Idempotent on (contest, participant). Calling again returns the original entry_id with already_entered: true. Safe to retry from a flaky network without double-creating entries.
A fact is emitted on first entry. behavior.contest_entered.v1 lands on the fact bus; subsequent idempotent calls do not re-emit.
Read your standing
Returns the participant’s entry state, current score, rank, and the breakdown of how their score was earned. Use this for the “Your standing” widget alongside the public leaderboard.
curl 'https://api.bricqs.com/api/v1/contests/ctst_uuid_123/me?participant_id=user_42' \
-H "Authorization: Bearer bq_live_xxxxx"
# Response (200 OK), see the data-model section aboveRead the public leaderboard
Returns the top entries (excluding disqualified) plus the participant’s own entry. Excludes participants with no score. Paginates with page.
curl 'https://api.bricqs.com/api/v1/contests/ctst_uuid_123/leaderboard/public?participant_id=user_42&page=1' \
-H "Authorization: Bearer bq_live_xxxxx"
# Response (200 OK), see the data-model section above/contests/ today and are scheduled to move under /gamify/contests/ as part of the participant-data namespace consolidation. The SDK hook abstracts the path; if you use the React hook you do not need to track the migration.Render a contest with one hook
The useContest hook returns entry state, your rank, the leaderboard, and the prize map in one call. It re-fetches automatically on contest:entered and activity:completed client events.
"use client";
import { useContest } from "@bricqs/sdk-react";
export function ContestPanel({ contestId, participantId }: { contestId: string; participantId: string }) {
const {
isEntered,
entry, // your entry record (score, breakdown)
myRank, // your live rank
leaderboard, // top entries
totalParticipants,
prizes, // prize tier definitions
myPrizeTier, // which tier (if any) your current rank maps to
isLoading, error,
enter, // call to enter the contest
refresh,
} = useContest({ contestId, participantId, autoEnter: false, refreshInterval: 30000 });
if (isLoading) return <p>Loading contest...</p>;
if (error) return <p>Could not load contest.</p>;
if (!isEntered) {
return <button onClick={() => enter()}>Enter the contest</button>;
}
return (
<article>
<h2>Your standing: rank {myRank?.rank} of {totalParticipants}</h2>
<p>Score: {entry?.current_score}</p>
{myPrizeTier && <p>Currently in line for: {myPrizeTier.name}</p>}
<ol className="leaderboard">
{leaderboard.map((e) => (
<li key={e.rank} className={e.is_current_user ? "me" : ""}>
{e.rank}. {e.display_name}, {e.score}
</li>
))}
</ol>
</article>
);
}autoEnter: true is a campaign anti-pattern. Contests are competitive; entering a participant without their knowledge undermines trust. Always make the enter button explicit. The hook supports autoEnter for cases where the entry is part of an opt-in flow you have already designed.
The hook reacts to in-session events. When the user completes a quiz that contributes to the contest, the hook receives the client event and re-fetches the leaderboard so the rank updates without waiting for the next poll tick.
Use the prize tier shortcut for upsell prompts. myPrizeTier tells you which prize tier the participant’s current rank maps to. Render a “You are 50 points from the next tier” nudge using this plus the next prize tier’s minimum rank.
Recipes you will reach for
Four patterns that cover most contest implementations in production. Each is a small composition of the primitives above.
Pattern 1: Weekly quiz contest, ranked top-10 prizes
The most common contest shape. Define scoring rules that award points for quiz completion (with an accuracy multiplier). Set a top-10 prize map. Let the lifecycle worker handle weekly resets by scheduling the next contest for the following week.
{
"name": "Weekly Quiz Sprint",
"starts_at": "2026-05-27T00:00:00Z",
"ends_at": "2026-06-02T23:59:59Z",
"completion_type": "ranked",
"score_config": {
"scoring_rules": [
{ "fact_name": "behavior.quiz_completed.v1",
"points": "payload.score",
"cap_per_occurrence": 100 }
]
},
"prize_config": [
{ "rank_from": 1, "rank_to": 1, "reward": { "type": "voucher", "code": "WK_GOLD" }, "monetary_value": 100 },
{ "rank_from": 2, "rank_to": 3, "reward": { "type": "voucher", "code": "WK_SILVER" }, "monetary_value": 50 },
{ "rank_from": 4, "rank_to": 10, "reward": { "type": "voucher", "code": "WK_BRONZE" }, "monetary_value": 20 }
],
"max_reward_liability": 250,
"fraud_config": {
"max_score_per_minute": 100,
"max_score_per_hour": 400,
"auto_flag_threshold": 3,
"auto_disqualify_threshold": 5
}
}The total prize liability (100 + 2x50 + 7x20 = 340) exceeds the budget cap (250), so the worker will allocate top winners until the cap is reached and skip the rest. Tune the cap or the prize map together; do not let one outpace the other.
Pattern 2: Raffle weighted by activity score
When the audience is wide and ranked competition is unfair (skilled users would always win), use completion_type: raffle. Every point earned is a ticket; the winner is drawn weighted by score. Casual users still have a chance; power users still get rewarded for activity.
{
"name": "Activity Raffle",
"completion_type": "raffle",
"starts_at": "2026-05-20T00:00:00Z",
"ends_at": "2026-05-26T23:59:59Z",
"score_config": { "source": "points" },
"prize_config": [
{ "rank_from": 1, "rank_to": 1, "reward": { "type": "coupon", "code": "RAFFLE_WIN" }, "monetary_value": 50 }
]
}Source "points" means every points-awarded fact contributes the delta as score. Pair the raffle with your points economy and the contest needs no per-action rules.
Pattern 3: Cohort-scoped contest for fairness
Global contests reward incumbents. Use the cohort_config field to scope by signup month, region, plan, or team, so a new participant can see a credible path to a top spot.
{
"name": "May 2026 New-Joiner Sprint",
"starts_at": "2026-05-27T00:00:00Z",
"ends_at": "2026-06-02T23:59:59Z",
"completion_type": "ranked",
"cohort_config": {
"signup_month": "2026-05"
},
"score_config": { "source": "points" },
"prize_config": [
{ "rank_from": 1, "rank_to": 5, "reward": { "type": "voucher", "code": "WELCOME_PRIZE" }, "monetary_value": 25 }
]
}Run cohort-scoped contests alongside a global one. New users see themselves in a fair fight; competitive users still get the global ranks they care about. Most successful consumer programs run both at once.
Pattern 4: Sync prize allocations to your fulfillment system
Until outbound webhook delivery for contest events ships, poll the allocations endpoint (admin scope) once after the contest enters completed status. The list is final; re-running the poll is a no-op.
contest.prize_allocated.v1. The data shape will be the same.// Run after the contest's scoring_cutoff_at
const res = await fetch(
`https://api.bricqs.com/api/v1/contests/${CONTEST_ID}/prizes/allocations`,
{ headers: { Authorization: `Bearer ${process.env.BRICQS_ADMIN_API_KEY}` } }
);
const { allocations } = await res.json();
for (const a of allocations) {
// a.participant_id, a.rank, a.reward, a.monetary_value, a.code_value
await fulfillment.shipPrize({
user_id: a.participant_id,
sku: a.reward.code,
voucher: a.code_value,
});
}The allocations list is finalised when the contest hits completed status. Re-running this poll is safe (the underlying allocations are append-only and your fulfillment side should be keyed on allocation id). When the webhook delivery channel ships, point the same handler at the event payload.
Design choices teams regret later
Each pitfall is a structural decision you make at design time. Catch them in the dashboard and the spec, not after the program is live.
Setting an uncapped prize map without max_reward_liability. The contest exceeds expected entry count, the prize map spends 10x the budget, finance is unhappy.
Design choice: always set max_reward_liability to a number you can write a cheque for. The allocation worker stops cleanly when the cap is reached. The remaining ranks get a runner-up communication instead of a real prize, and the program survives the surprise.
Treating scoring rules as something you can tune after launch. Participants who scored under the old rules see their numbers replay; the leaderboard shuffles unexpectedly and trust evaporates.
Design choice: treat scoring rules as frozen at publish. If the rules need to evolve, cancel the contest, communicate clearly, and launch a new one with the corrected rules. Bake this into the runbook before the first contest goes live.
Disabling fraud detection during testing and shipping that config to production. The contest goes live with no velocity caps; a small ring of accounts scores enough to win the entire prize pool.
Design choice: ship contests with fraud detection enabled by default. The defaults (per-minute, per-hour, per-day caps with auto-flag at 3 violations and auto-disqualify at 5) are good baselines. Tune them up or down for your context, but never to zero in production.
Conflating scoring_cutoff_at with ends_at when designing the announcement plan. The team reads “contest ended at midnight”, expects the leaderboard to be final at 00:00:01, calls a winner, then the scoring window catches a late fact that changes the rank.
Design choice: build the comms plan around completed status, not ends_at. Render the consistency_label in your UI so participants see realtime_estimated vs final_verified, and only announce winners after the contest hits completed.
Reaching for a contest when a plain leaderboard would do. The team wraps a no-prize ranking surface in the contest system “to be consistent” and inherits the scoring contract, fraud config, and budget machinery for no benefit.
Design choice: use a contest only when prizes change hands. For ongoing visibility (top contributors this month with no prize), the raw leaderboard primitive is lighter, easier to operate, and clearer to the participant.
Where to go next
Concepts, references, and patterns this page anchors. Pick the next read based on what you are about to build.
Challenges
When everyone-who-completes-wins is the right shape, use challenges instead. Pair a challenge with a contest for “complete the journey and place in the top 10” programs.
Read itLeaderboard
Contests wrap a leaderboard with scoring, fraud, and prize allocation. The leaderboard concept page covers the underlying ranking primitive.
Read itPoints
Many contests score by points aggregation. Covers the ledger model, balance vs lifetime, and how points-awarded facts flow into contest scoring.
Read itRewards
Contest prize allocations route through the rewards engine. Covers code inventory, idempotent claims, and the one-step points deduction model.
Read itHeadless SDK reference
useContest reference plus the other gamification hooks.
Read itPattern: weekly contest
End-to-end walkthrough of a recurring weekly contest using the contest API plus the rewards engine.
Read itAuthentication and API keys
How bq_live_ and bq_test_ keys work, scopes, and which key to use where.
Read itCommon questions when integrating
Build with contests
Pair this with the strategy guide on contests, or jump straight to the SDK setup page.
