Pattern: streak with freeze
A daily streak with a freeze-token safety net and milestones at 7, 30, 100, and 365 days. Server-side config, server-side ticks, client-side rendering. Roughly 80 lines of code total.
Key takeaways
Quick read- Streak config lives server-side: period (daily), grace_periods, and the fact_type label on the streak definition; freeze allowance on the Builder program. The 7/30/100/365 celebrations are YOUR handler reacting to streak.continued.v1 counts.
- Tick from the server when the qualifying action lands: POST /gamify/streaks/record (events alone never tick a streak).
- Client renders count, freeze inventory, grace state, and the next milestone with one hook.
- Milestones fire webhooks; route them to your ESP for celebration emails.
- Recovery: freezes are consumed automatically on a would-be break; your job is the at-risk nudge (isAtRisk) and showing the remaining freeze inventory.
Anatomy
What you are building
API
POST /gamify/admin/streaks defines the streak (code, name, fact_type, period, grace_periods). Ticking is an EXPLICIT call: POST /gamify/streaks/record is the only streak writer in the engine — events never advance a streak (pinned by test_esports_mechanic_wiring). streak.continued.v1 and streak.broken.v1 webhooks fire from the record call.
SDK
useStreak(code) returns a StreakStatus: currentCount, longestCount, freezesAvailable, isAtRisk. Freezes are spent automatically on a miss.
User sees
A flame counter that grows each day, the freeze inventory with an at-risk warning (the engine spends a freeze automatically on a miss), and confetti at 7, 30, 100, 365 days.
Step 1: config
Define the streak once
curl -X POST https://api.bricqs.co/api/v1/gamify/admin/streaks \
-H "X-API-Key: bq_live_..." \
-H "Content-Type: application/json" \
-d '{
"code": "daily_practice",
"name": "Daily practice",
"fact_type": "practice_completed",
"period": "daily",
"grace_periods": 1,
"reset_on_miss": true,
"description": "Practice once per day to keep the flame alive"
}'The streak definition itself is this small: code, name, fact_type, period, and grace_periods. fact_type LABELS the activity the streak represents — it is not an event subscription; ticking is the explicit record call in Step 2. The FREEZE ALLOWANCE is configured on the program in the Builder (freeze_tokens_allowed); the 7/30/100/365-day CELEBRATIONS ride on streak.continued.v1 webhooks (read the count from the payload in your handler). Day boundaries are UTC.
Step 2: tick
Server-side, on every qualifying action
import { emitToBricqs, recordStreak } from "@/lib/bricqs";
export async function logPractice(userId: string) {
await savePractice(userId);
// 1. Tick the streak — POST /gamify/streaks/record is the ONLY streak
// writer in the engine. Naturally idempotent per period: a second call
// the same day returns already_recorded: true.
await recordStreak(userId, "daily_practice");
// 2. Emit the event for everything ELSE the action should feed —
// challenges, rules, contests. Events never advance streaks.
const today = new Date().toISOString().slice(0, 10);
await emitToBricqs(
userId,
"practice_completed",
{ practice_id: "daily" },
`p_${userId}:practice_completed:${today}`
);
}
// lib/bricqs.ts addition:
// export const recordStreak = (participantId: string, streakCode: string) =>
// fetch(API + "/api/v1/gamify/streaks/record", {
// method: "POST",
// headers: { "X-API-Key": process.env.BRICQS_API_KEY!, "Content-Type": "application/json" },
// body: JSON.stringify({ participant_id: participantId, streak_code: streakCode }),
// });Two calls per qualifying action, each with its own job: record ticks the streak (grace/freeze consumption happens inside the engine on this call), the event feeds challenges, rules, and contests. This split is the real contract — the engine has no event-to-streak bridge, so an event-only integration silently never ticks. Both calls are idempotent (record per period; the event per its idempotency key), so retries are safe.
Step 3: render
One hook, full state
"use client";
import { useStreak } from "@bricqs/sdk-react";
export function StreakWidget() {
// participantId inherited from <BricqsProvider> / participantToken
const { streak } = useStreak("daily_practice");
if (!streak) return null;
return (
<section className="rounded-xl border p-5">
<header className="flex items-baseline justify-between mb-2">
<h3 className="font-bold text-2xl">{streak.currentCount} day streak</h3>
{streak.freezesAvailable > 0 && (
<span className="text-sm text-slate-500">
{streak.freezesAvailable} freeze{streak.freezesAvailable === 1 ? "" : "s"} left
</span>
)}
</header>
{streak.isAtRisk && (
<p className="text-amber-600 text-sm">
You haven't recorded today. Record before the period ends, or a freeze is
spent automatically while any remain.
</p>
)}
<p className="text-sm text-slate-500 mt-3">Longest run: {streak.longestCount} days</p>
</section>
);
}Step 4: celebrations
Wire streak webhooks to your ESP
// Envelope: { id, event, timestamp, tenant_id, participant_id, data }
// streak.continued.v1 payload (events.json): current_count, longest_count,
// streak_code. There is no built-in streak-milestone emitter — celebration
// thresholds are YOUR check on the count:
const CELEBRATE = new Set([7, 30, 100, 365]);
if (envelope.event === "streak.continued.v1") {
const { streak_code, current_count } = envelope.data;
if (streak_code === "daily_practice" && CELEBRATE.has(current_count)) {
await sendCelebrationEmail(envelope.participant_id, {
template: `streak_${current_count}_reached`,
days: current_count,
});
}
}
// streak.broken.v1 fires when a record call lands after the grace window —
// the natural trigger for a "start again" re-engagement email.Pre-launch checklist
Before you ship
Configuration
[ ] Streak created (POST /gamify/admin/streaks: code, name, fact_type, period, grace_periods)
[ ] Freeze allowance set on the program in the Builder (freeze_tokens_allowed)
[ ] Celebration thresholds (7/30/100/365) handled in your streak.continued.v1 webhook handler
Server
[ ] /gamify/streaks/record called on every qualifying action (the ONLY
streak writer — events alone never tick)
[ ] practice_completed event fires alongside it for challenges/rules
[ ] Event idempotency key uses the UTC date (streak day boundaries are UTC;
keying by user-local date can double-count across the boundary)
[ ] Webhook handler verifies HMAC and dedupes on X-Bricqs-Delivery-Id
Client
[ ] StreakWidget on home screen
[ ] Freeze button visible during grace
[ ] Confetti / toast on milestone
Recovery
[ ] Email at 24h after miss with freeze CTA
[ ] Email at 48h after miss confirming streak ended (if not frozen)Developer FAQ
Common questions when integrating gamification with Bricqs.
Ready to ship?
Wire it up with the Bricqs SDK or API
Headless SDK for React UIs, REST API for any backend. Same engine behind both.
