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

Streaks in headless gamification

A streak is a count of consecutive periods in which a participant kept showing up. Daily logins, weekly workouts, monthly content reads. The mechanic is simple to describe and full of edge cases to implement: time zones, missed days, grace periods, retroactive backfill. This page covers the concept, the API, and the patterns that work in production.

/streaks/record
One endpoint handles increment, grace, and reset semantics.
Key takeaways
  • A streak is a count of consecutive periods in which a participant satisfied a defined action; it extends as long as they keep showing up within the grace window.
  • Streak ticks are idempotent within a period, so recording twice in the same day returns already_recorded: true and leaves the count unchanged.
  • The hardest design decision is the period boundary; pick UTC or participant-local and surface the rule in the UI so users never argue about when today ends.
  • Set grace_periods to 1 or 2 for daily streaks so a single missed day does not erase months of progress and generate support tickets.
  • Anchor streaks to server-observable actions like auth success or content-completed events; client-reported streaks can be gamed by changing the system clock.
1. The concept

What a streak is, in any gamification system

Before the API. Every streak 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 streak is a count of consecutive periods in which a participant satisfied a defined action. The count survives across periods as long as the participant keeps showing up (or stays within a grace window). It resets when too many periods pass without activity.

Who owns it

Engineers wiring streak rendering and ticking into client surfaces (login flows, daily prompts, push notifications); growth and lifecycle teams choosing the period and grace policy. Streaks are owned by product, computed by the platform, surfaced by engineering.

How it differs from adjacent mechanics
vs points

Points sum over a lifetime. A streak counts consecutive periods. Burning your points does not break your streak; missing a day does. They are independent meters.

vs badges

A badge is an event (a one-time award). A streak is state (an ongoing count). Many programs convert streak milestones (7-day, 30-day, 100-day) into badges so the recognition survives even after the streak breaks.

vs tiers

A tier is a status level. A streak is a continuous-action counter. They can pair (streak length feeds tier progress), but the contracts are different: streaks break, tiers are sticky until re-evaluated.

vs frequency caps

A cap limits how often a participant can do something. A streak counts how often they do. Caps are anti-abuse guardrails; streaks are pro-engagement meters.

The decomposition every streak system uses: a definition (the streak as a noun: code, name, period, grace policy) and a tick (the streak as a verb: this participant did the action at this time). Definitions are tenant-scoped configuration. Ticks are participant-scoped events that either extend, hold, or reset the count.

The three operations every API supports: record a tick (the participant did the action), read the current state for one participant (count, last activity, at-risk status), and list all streaks for a participant (cross-streak view for a profile page). Most calls are ticks; reads happen on demand from your UI.

The hardest design decision is not the schema, it is the period definition. Period keys decide what “same day” means. A user in Sydney and a user in Los Angeles record their daily streak at very different moments. Most teams settle on a fixed UTC day, which is simple but unfair to mobile users in extreme time zones. The alternative is participant-local day, which is correct but requires you to know the time zone at record time. Bricqs computes a period key from the timestamp you send, so you decide.

2. Design decisions

When streaks are the right tool, and when they are not

Streaks are powerful retention mechanics but they punish missed periods. Make sure the action is one users genuinely want to repeat.

Reach for a streak when
01Natural cadence

The action is naturally repeatable on a clear cadence

Daily login, weekly workout, monthly read. If the action does not have a natural rhythm, the streak feels arbitrary. Languages and fitness apps work; one-time purchase flows do not.

02Intrinsic value

The user benefits from the action even without the streak

Streaks should reward behaviour that is already valuable. If the user only does the action for the streak, you have built a treadmill and burnout follows.

03Lenient grace

You can afford to be lenient with grace periods

Streaks without grace are brittle. One missed day, year of progress lost, support ticket. Build in 1 to 2 grace credits per month so a sick day or a vacation does not kill the program.

04Retention signal

You want a soft retention signal you can monitor

Average streak length is one of the strongest retention metrics for daily-use apps. The distribution of streaks tells you how many users are in the habit loop vs casual.

Do not use a streak when
01Rare action

The action only happens a few times in the user lifetime

A 3-day streak is not a meaningful metric. If most users will never hit a meaningful streak length, the surface area is wasted and the longest-streak leaderboard becomes a list of outliers.

02Legitimate gaps

Users have legitimate reasons to miss long stretches

B2B workflows that pause over weekends or holidays look like “broken streaks” even when the user is engaged. Pick a different metric like cumulative activity over a rolling window.

03Ambiguous period

You cannot communicate the period clearly

If users disagree about when “today” ends (time zone, late-night sessions), every reset becomes a support escalation. Pick a period rule, document it, and surface it in the UI before the streak starts.

04Purchase driver

You want it to drive purchase decisions

Streaks reward habit, not spend. If the goal is to drive a purchase, use a tier, a milestone, or a contest. Streak rewards work for re-engagement, not for conversion.

05Short campaign

It is a one-off campaign

Streaks need time to build social proof and behavioural anchoring. A two-week streak campaign produces low engagement and confused users. Reserve streaks for the always-on retention layer.

Before you read on
  • Looking for the marketing-team take? The strategy guide on streak systems covers when streaks fit a program, period and grace design, KPIs, and example flows.
  • Configure in the dashboard: Progression → Streaks → New Streak.

Bricqs models streaks with the standard two-part decomposition above: a definition (the streak rules) and a tick (the participant’s record). You create the definition through the dashboard or the admin API. You record ticks by calling POST /streaks/record whenever the participant does the action.

Bricqs handles the period arithmetic for you. The engine computes a period key from the timestamp you send, compares it to the last recorded period, and decides whether to increment, hold, or reset. Ticks are idempotent within a period, so wiring the record call into a hot path is safe.

3. Data model

What a streak looks like

Two shapes you will see in API responses. The first is the streak definition (the template). The second is a participant view (current count, longest count, at-risk status).

jsonStreak definition
{
  "code": "daily_login",         // your code, what you use in API calls
  "name": "Daily Login",         // shown in the UI
  "description": "Log in every day to keep the streak alive.",
  "period": "daily",             // daily | weekly | monthly
  "grace_periods": 1,            // periods of inactivity allowed before reset
  "reset_on_miss": true,         // false = pause without resetting count
  "is_active": true              // false = paused, ticks return 404
}

period drives the engine. Daily, weekly, and monthly are the supported keys. The engine derives the period key from the timestamp you send, so picking a coarser unit automatically widens the window in which a tick counts as “same period”.

grace_periods is forgiveness, not slack. Set to 1 or 2 for daily streaks so a missed day does not erase months of progress. With reset_on_miss: false, the count is preserved on a miss instead of being reset, useful when streaks are aspirational rather than punitive.

jsonStreak record response (after a successful tick)
{
  "streak_code": "daily_login",
  "new_count": 7,                 // updated count after this tick
  "longest_count": 21,            // all-time longest for this participant
  "is_new_record": false,         // true if new_count > longest_count
  "already_recorded": false       // true if same period as the last record
}

already_recorded is your idempotency signal. When the participant records twice in the same period, the second call returns the same count with this flag set. Wiring the record call into a hot path is safe.

is_new_record is best used to celebrate. Treat it as a UI cue (confetti, toast, push) rather than a business signal; it flips back to false on the next tick at the same level.

jsonParticipant streak status (one entry per active streak)
{
  "code": "daily_login",
  "name": "Daily Login",
  "current_count": 7,
  "longest_count": 21,
  "last_recorded_at": "2026-05-23T08:15:00Z",
  "is_at_risk": false,            // true if grace credits are about to run out
  "period": "daily"
}

Period keys are computed from the timestamp. If you omit timestamp, the engine uses the current server time. To honour user-local time zones, send your own timestamp computed in the user’s zone (shift to UTC before sending; the engine treats it as a point in time, not a clock reading).

is_at_risk is derived live. The flag is computed from the gap between the current period and the last recorded period. It is not stored. Use it in your UI to prompt at-risk users (“Your 30-day streak ends in 6 hours”) and in push-notification campaigns.

4. Lifecycle

What happens when a tick arrives

One sequence covers every kind of tick: first-ever, same-period repeat, on-time, late within grace, and missed.

  1. 01

    Your code calls POST /streaks/record

    The payload includes the participant id, the streak code, and an optional timestamp (defaults to now).
  2. 02

    The engine computes a period key

    It uses the streak definition's period field (daily, weekly, monthly) to derive a key from the timestamp.
  3. 03

    Same period as the last record short-circuits

    The response includes already_recorded: true and the count is unchanged. Repeat calls in the same period are no-ops.
  4. 04

    Gap of one period increments the streak

    The participant’s current_count goes up by one, and longest_count updates if the new count beats the previous best.
  5. 05

    Gap within the grace window continues with a credit consumed

    Same increment behaviour as the on-time case, but a grace credit is tracked so future misses within the same period chain do not all get free passes.
  6. 06

    Gap beyond the grace window resets the streak

    The count goes to 1 (this tick starts a new streak). If the definition has reset_on_miss: false, the count is preserved but no longer counts as active.
  7. 07

    A streak-recorded fact is emitted

    The fact behavior.streak_recorded includes the streak code and the new count. Webhook destinations (if configured) receive the event for downstream sync.
Safe under loadThe engine processes ticks for a participant one at a time, so two ticks landing at the same instant never double-count the same day. Safe to wire into a high-traffic path (every login, every visit) without throttling in your own code.
5. REST API

Record and read streaks from any backend

Two endpoints cover almost every case. All endpoints are under /api/v1/gamify/ and authenticate with an API key (bq_live_ for production, bq_test_ for sandbox).

Record a streak activity

Call this whenever the participant did the action. The engine decides whether to increment, hold, or reset. Idempotent within a period, so safe to retry and safe to wire into hot paths.

bashPOST /api/v1/gamify/streaks/record
curl -X POST https://api.bricqs.com/api/v1/gamify/streaks/record \
  -H "Authorization: Bearer bq_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "participant_id": "user_42",
    "streak_code": "daily_login",
    "timestamp": "2026-05-23T08:15:00Z"   // optional, defaults to now
  }'

# Day 1 response (first ever record)
{
  "streak_code": "daily_login",
  "new_count": 1,
  "longest_count": 1,
  "is_new_record": true,
  "already_recorded": false
}

# Same day again, idempotent no-op
{
  "streak_code": "daily_login",
  "new_count": 1,
  "longest_count": 1,
  "is_new_record": false,
  "already_recorded": true
}

# Next day, increments
{
  "streak_code": "daily_login",
  "new_count": 2,
  "longest_count": 2,
  "is_new_record": true,
  "already_recorded": false
}

One endpoint, three outcomes. Increment, idempotent no-op, or reset; the engine picks based on period-key arithmetic. Your call site stays identical across all three cases.

Timestamps are optional. Omit timestamp for server-time semantics. Provide one when you need participant-local periods (compute it in their zone and send the UTC instant).

Safe to retry. Within-period idempotency means a network-induced retry never double-counts. Wire the record call into hot paths (login, visit, completion) without guarding it.

List all streaks for a participant

Returns every active streak for the participant with current count, longest count, and at-risk flag. Use this to render a profile streak panel.

bashGET /api/v1/gamify/participants/{participant_id}/streaks
curl https://api.bricqs.com/api/v1/gamify/participants/user_42/streaks \
  -H "Authorization: Bearer bq_live_xxxxx"

# Response (200 OK)
{
  "participant_id": "user_42",
  "streaks": [
    {
      "code": "daily_login",
      "name": "Daily Login",
      "current_count": 7,
      "longest_count": 21,
      "last_recorded_at": "2026-05-23T08:15:00Z",
      "is_at_risk": false,
      "period": "daily"
    },
    {
      "code": "weekly_workout",
      "name": "Weekly Workout",
      "current_count": 3,
      "longest_count": 12,
      "last_recorded_at": "2026-05-18T17:42:00Z",
      "is_at_risk": true,
      "period": "weekly"
    }
  ]
}

Manage streak definitions (admin only)

Most teams create and edit streaks in the dashboard. If you want to script it (e.g. seed streaks in CI), use the admin endpoints. They require an admin-scoped API key.

bashStreak definition CRUD (admin key required)
GET    /api/v1/gamify/streaks
GET    /api/v1/gamify/streaks/{streak_code}
POST   /api/v1/gamify/streaks
       { "code": "daily_login", "name": "Daily Login", "period": "daily", "grace_periods": 1 }
PATCH  /api/v1/gamify/streaks/{streak_code}
       { "grace_periods": 2 }
DELETE /api/v1/gamify/streaks/{streak_code}
6. React SDK

Reading streaks today: REST first, hook coming

A dedicated useStreaks hook for the Gamify streak resource is not yet shipped. In the meantime, fetch the REST endpoint directly or build a thin wrapper. The pattern below is the one we plan to mirror in the hook when it ships.

RoadmapA dedicated useStreaks hook is on the same roadmap as the other progression hooks. Today, useTier, useBadgesHeadless, and usePoints ship; for streaks, build a thin REST wrapper using the pattern below.
tsxcomponents/StreakPanel.tsx
"use client";
import { useEffect, useState } from "react";

type StreakStatus = {
  code: string;
  name: string;
  current_count: number;
  longest_count: number;
  last_recorded_at: string;
  is_at_risk: boolean;
  period: "daily" | "weekly" | "monthly";
};

export function StreakPanel({ participantId }: { participantId: string }) {
  const [streaks, setStreaks] = useState<StreakStatus[] | null>(null);

  useEffect(() => {
    async function load() {
      const res = await fetch(
        `/api/bricqs/participants/${participantId}/streaks`
        // proxy to /api/v1/gamify on your backend so the API key stays server-side
      );
      const data = await res.json();
      setStreaks(data.streaks);
    }
    load();
    // Re-fetch on a long interval; streak state only changes on ticks
    const id = setInterval(load, 60_000);
    return () => clearInterval(id);
  }, [participantId]);

  if (!streaks) return null;

  return (
    <ul className="space-y-2">
      {streaks.map((s) => (
        <li
          key={s.code}
          className={`rounded-xl border p-3 ${
            s.is_at_risk ? "border-amber-300 bg-amber-50" : "border-slate-200"
          }`}
        >
          <div className="flex items-baseline justify-between">
            <span className="font-semibold">{s.name}</span>
            <span className="text-sm">{s.current_count}-{s.period}</span>
          </div>
          <div className="text-xs text-slate-500">
            Best: {s.longest_count}
            {s.is_at_risk && " · at risk"}
          </div>
        </li>
      ))}
    </ul>
  );
}

Always proxy the API key through your server. Never call /api/v1/gamify directly from the browser with a bq_live_ key. Set up a thin proxy route (Next.js route handler, edge function, BFF) so the key stays server-side.

Ticks happen on the server. Call /streaks/record from your backend in the same handler that processes the action (login, content read, workout complete). Client-side ticks are untrusted; the user can manipulate the call.

Refresh is interval-based for now. Until the hook ships and listens on the client event bus, a 60-second poll is a fine default since streak state only changes on ticks.

7. Common patterns

Recipes you will reach for

Four patterns that cover most streak implementations in production. Each is a small composition of the primitives above.

Pattern 1: Tick on every login from your auth backend

The most common streak shape is a daily login streak. Wire the record call into your authentication success handler. The within-period idempotency means the user can log in five times a day and the streak only increments once.

tsauth/onLoginSuccess.ts
export async function onLoginSuccess(userId: string) {
  // existing post-login logic, session set, last_seen updated, etc.

  // Fire-and-forget: don't block the login response on the streak call
  fetch("https://api.bricqs.com/api/v1/gamify/streaks/record", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      participant_id: userId,
      streak_code: "daily_login",
    }),
  }).catch((err) => {
    // log but do not crash; streak ticks are non-critical
    logger.warn({ err, userId }, "streak tick failed");
  });
}

Always fire-and-forget for streak ticks. The login response is user-visible; the streak tick is a background nicety. Log failures and move on.

Pattern 2: At-risk push notification campaign

List streaks where is_at_risk is true and the count is long enough to be worth saving (e.g. 7 or more). Send a push notification or email. The pattern works best when the message references the actual count (Do not lose your 23-day streak) rather than generic copy.

tsjobs/streakAtRiskCampaign.ts
// Run this every few hours
const at_risk = await db.query(`
  -- Your analytics view that mirrors the participant streaks
  SELECT participant_id, streak_code, current_count
    FROM participant_streaks_view
   WHERE is_at_risk = true AND current_count >= 7
`);

for (const row of at_risk.rows) {
  await pushNotification.send({
    user_id: row.participant_id,
    title: `Don't lose your ${row.current_count}-day streak`,
    body: "Open the app to keep it alive.",
    deep_link: "/streaks",
  });
}

Bound the campaign to participants with meaningful streaks; new users with a 2-day streak do not need a save-my-streak push. Pair this with a quiet-hours window (no pushes between 22:00 and 08:00 in the user's zone) and a frequency cap.

Pattern 3: Award a reward when a streak crosses a milestone

Subscribe to the streak-recorded fact and award a one-time reward (badge, points, voucher) at meaningful counts. The fact includes the new count, so your handler can match on exact thresholds.

RoadmapOutbound webhook delivery for progression events (including streak continuation and break) is the next major addition to the platform. Today, the streak-recorded fact is emitted internally but not delivered to your endpoint. Until the webhook ships, run this pattern from the same server handler that calls /streaks/record: read new_count from the response and award the badge inline.
tsYour webhook handler
// POST /webhooks/bricqs, subscribed to behavior.streak_recorded
const MILESTONE_BADGES: Record<string, Record<number, string>> = {
  daily_login: { 7: "week_warrior", 30: "month_master", 100: "century_streak" },
};

export async function POST(req: Request) {
  const event = await verifyAndParse(req); // your signature check
  const { streak_code, count } = event.payload;

  const badge_code = MILESTONE_BADGES[streak_code]?.[count];
  if (!badge_code) return new Response(null, { status: 200 });

  await fetch("https://api.bricqs.com/api/v1/gamify/badges/award", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      participant_id: event.participant_id,
      badge_code,
    }),
  });

  return new Response(null, { status: 200 });
}

Badge awards are composite-idempotent on (participant_id, badge_code), so duplicate webhook deliveries do not double-award. If a participant somehow misses the exact milestone count (e.g. a backfill bumps them from 6 directly to 8), award the badge on the next exact match or extend the handler to award on count >= milestone.

Pattern 4: Daily streak in the participant's timezone

Send the period boundary you want the engine to honour. If participant A is in Sydney and participant B is in Los Angeles, construct the timestamp at their local midnight (or any consistent local hour) and convert to UTC before sending. The engine treats your timestamp as a point in time, so the period key it computes matches the local-day intent.

tsstreaks/localDailyTick.ts
import { fromZonedTime } from "date-fns-tz";

export async function recordLocalDailyLogin(
  participantId: string,
  localTimezone: string, // e.g. "Australia/Sydney"
) {
  // Build the local "now" as a UTC instant
  const localNow = new Date(); // server is in UTC
  const sentAt = fromZonedTime(localNow, localTimezone).toISOString();

  await fetch("https://api.bricqs.com/api/v1/gamify/streaks/record", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.BRICQS_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      participant_id: participantId,
      streak_code: "daily_login",
      timestamp: sentAt,
    }),
  });
}

Store the participant's timezone in your own user record (collected at signup, updated when they change it). Never trust browser-reported timezone for security-relevant streak logic; you can spoof a streak by changing your system clock.

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

Picking a period unit (daily) without thinking about the natural cadence of the action. Users who genuinely engage twice a week look broken on a daily streak; the program signals failure where there is none.

Fix

Design choice: match the period to the action's natural cadence. Weekly for workouts, monthly for premium content reads, daily only when the action is genuinely daily. Coarser periods give grace for free.

Mistake

Setting grace_periods to 0 to be strict. Users lose multi-month streaks because of a single missed day, generate support tickets, and stop trusting the program.

Fix

Design choice: 1 to 2 grace credits per month is the standard for daily streaks. The streak loses none of its meaning; the program loses none of its honesty; users get a margin for life. Decide this in the dashboard before launch.

Mistake

Treating UTC as the period boundary without telling users. A late-night session in Sydney crosses midnight UTC mid-stream and the user wakes up to a broken streak they thought they completed.

Fix

Design choice: pick a period boundary you can explain. Either pin to participant-local day (and capture timezone at signup) or pin to UTC and surface it in the streak UI (Resets at midnight UTC). Document the rule in the benefits page.

Mistake

Building a streak on a signal you do not actually control or trust (client-reported workouts, self-reported reads). Users gaming the count erodes the recognition for everyone honest.

Fix

Design choice: anchor the streak to a server-observable action (auth success, content-completed event, workout-saved endpoint). If the signal cannot be verified server-side, the streak is decoration; do not award rewards against it.

Mistake

Treating the streak count as a leaderboard signal without a window. A four-year-old streak ranks above users who started last month and rocket-streaked; the leaderboard becomes a list of incumbents.

Fix

Design choice: rank on a rolling window (last 30 days streak length) or on longest_count within a time-bound contest. Reserve pure all-time leaderboards for contests, not for the long-running streak surface.

FAQ

Common questions when integrating

Wire it up

Build with streaks

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