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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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).
{
"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.
{
"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.
{
"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.
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.
- 01
Your code calls POST /streaks/record
The payload includes the participant id, the streak code, and an optional timestamp (defaults to now). - 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. - 03
Same period as the last record short-circuits
The response includesalready_recorded: trueand the count is unchanged. Repeat calls in the same period are no-ops. - 04
Gap of one period increments the streak
The participant’scurrent_countgoes up by one, andlongest_countupdates if the new count beats the previous best. - 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. - 06
Gap beyond the grace window resets the streak
The count goes to 1 (this tick starts a new streak). If the definition hasreset_on_miss: false, the count is preserved but no longer counts as active. - 07
A streak-recorded fact is emitted
The factbehavior.streak_recordedincludes the streak code and the new count. Webhook destinations (if configured) receive the event for downstream sync.
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.
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.
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.
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}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.
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."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.
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.
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.
// 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.
/streaks/record: read new_count from the response and award the badge inline.// 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Where to go next
Concepts, references, and patterns this page anchors. Pick the next read based on what you are about to build.
Progression overview
How streaks, badges, tiers, points, and milestones fit together.
Read itBadges
Common pairing: streak milestones converted into permanent badges so the recognition survives even after the streak breaks.
Read itPoints
Pair streaks with points multipliers: a long active streak boosts every point a participant earns. Includes the ledger and idempotency.
Read itStreaks REST API
Full endpoint detail: every parameter, every response field, every status code.
Read itHeadless SDK reference
Current shipped hooks (useTier, useBadgesHeadless, usePoints). useStreaks is on the roadmap.
Read itWebhooks (integration guide)
Set up outbound destinations and signature verification. Needed for Pattern 3 (award reward when streak hits a milestone).
Read itCommon questions when integrating
Build with streaks
Pair this with the strategy guide on streak systems, or jump straight to the SDK setup page.
