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

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.

useContest
One hook handles enter, score, rank, prize tier preview.
Key takeaways
  • 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.
1. The concept

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.

Definition

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.

Who owns it

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.

How it differs from adjacent mechanics
vs raw leaderboards

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.

vs challenges

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.

vs milestone payouts

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.

vs raffles

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.

2. Design decisions

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.

Reach for the contest system when
01Real prizes

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.

02Score audit

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.

03Fraud risk

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.

04Budget cap

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.

Do not use a contest when
01Same reward

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.

02Open ended

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.

03Shifting rules

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.

04Uncapped spend

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.

05Tiny group

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.

Before you read on
  • 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.

3. Data model

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.

jsonActive contest (one row in GET /contests/active)
{
  "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.

jsonYour standing (GET /contests/{id}/me)
{
  "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.

jsonPublic leaderboard (GET /contests/{id}/leaderboard/public)
{
  "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).

4. Lifecycle

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.

  1. 01

    A participant enters

    POST /contests/{id}/enter. The engine checks the contest is active, the participant does not already have an entry, and max_participants has room. One entry is created, and a fact behavior.contest_entered.v1 is emitted.
  2. 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.
  3. 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.
  4. 04

    Fraud checks run inline

    Velocity caps (per minute, hour, day) are checked. If a cap is exceeded, the score is held back and velocity_violations increments on the entry. Auto-flag (default 3) and auto-disqualify (default 5) kick in at the configured counts.
  5. 05

    The leaderboard reflects live

    The entry is re-ranked. The public leaderboard endpoint returns the latest state with the realtime_estimated consistency label.
  6. 06

    The contest enters scoring at ends_at

    New entries are rejected; late-arriving facts can still score until scoring_cutoff_at.
  7. 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.
RoadmapOutbound webhook delivery for contest events (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.
5. REST API

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.

bashGET /api/v1/contests/active
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.

bashPOST /api/v1/contests/{contest_id}/enter
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.

bashGET /api/v1/contests/{contest_id}/me
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 above

Read the public leaderboard

Returns the top entries (excluding disqualified) plus the participant’s own entry. Excludes participants with no score. Paginates with page.

bashGET /api/v1/contests/{contest_id}/leaderboard/public
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
Path roadmapThe contest endpoints sit under /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.
6. React SDK

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.

tsxcomponents/ContestPanel.tsx
"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.

7. Common patterns

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.

jsonContest definition (admin)
{
  "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.

jsonRaffle definition
{
  "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.

jsonCohort-scoped contest
{
  "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.

RoadmapWhen outbound webhook delivery for contest events ships, replace the poll with a handler keyed on contest.prize_allocated.v1. The data shape will be the same.
tsjobs/syncContestPrizes.ts
// 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.

8. Design pitfalls

Design choices teams regret later

Each pitfall is a structural decision you make at design time. Catch them in the dashboard and the spec, not after the program is live.

Mistake

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.

Fix

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.

Mistake

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.

Fix

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.

Mistake

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.

Fix

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.

Mistake

Conflating scoring_cutoff_at with ends_at when designing the announcement plan. The team reads &ldquo;contest ended at midnight&rdquo;, 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.

Fix

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.

Mistake

Reaching for a contest when a plain leaderboard would do. The team wraps a no-prize ranking surface in the contest system &ldquo;to be consistent&rdquo; and inherits the scoring contract, fraud config, and budget machinery for no benefit.

Fix

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.

FAQ

Common questions when integrating

Wire it up

Build with contests

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