Challenges and streaks
Gamification adds two member-facing mechanics on top of the ledger: challenges (merchant-defined
goals with a fixed point reward) and streaks (an automatic consecutive-week visit counter, with
optional milestone bonuses). Both are read live from canonical_transactions — nothing here is a
counter that can drift; progress is recomputed from the same purchase history the ledger itself is
built from, every time it’s shown.
Challenge types
A challenge (POST /v1/challenges, scoped to a program) is one of three types. Progress is a single
aggregate query over the member’s transactions inside the challenge’s evaluation window (below):
type |
Dashboard label | How progress is measured |
|---|---|---|
visit_count |
Visit count | Distinct calendar days with a purchase inside the window (COUNT(DISTINCT occurred_at::date)) — two sales on the same day count once. |
spend_total |
Spend total | Net spend inside the window, summed in the program currency’s minor units (COALESCE(SUM(net), 0)) — refunds lower it. |
distinct_weeks |
Distinct weeks | Distinct ISO weeks with at least one purchase inside the window (COUNT(DISTINCT date_trunc('week', occurred_at))). |
target, window_days, and reward_points are all positive integers — enforced both by the request
schema and by database CHECK constraints, so a malformed challenge can’t be stored even by a direct
API call.
The evaluation window
Every challenge has a rolling window_days-day look-back, but two things clamp it:
window = [ max(starts_at, now − window_days), min(now, ends_at ?? now) ]
- It never reaches back before the challenge’s own
starts_at— a brand-new challenge can’t credit purchases from before it existed. - Once a challenge has an
ends_atand that instant has passed, the window’s end is pinned there — an ended window stops accruing the moment it ends, not at whatever time someone happens to check. - A transaction landing exactly at the window’s start boundary counts (the comparison is inclusive on both ends).
This is the same window a merchant’s dashboard view (GET /v1/members/:id/challenges) and a member’s
own view (GET /v1/me/challenges, see the member portal guide) both call —
one formula, so the two surfaces can never disagree about a member’s progress.
Lifecycle
A challenge always starts draft (status isn’t caller-settable at creation) and moves through a
linear lifecycle — there’s no paused state the way promotions have one:
draft → active → ended
PATCH /v1/challenges/:idonly works while a challenge isdraft— once it’sactive, its rules are locked so that progress members have already earned stays comparable; attempting to edit an active or ended challenge is a409 INVALID_STATE.POST /v1/challenges/:id/activatemovesdraft → active. It refuses to activate a challenge whoseends_atis already in the past — an already-dead window could never award anything, so it’s rejected up front rather than silently going live and doing nothing.POST /v1/challenges/:id/endmovesactive → ended— terminal, mirroring the sweep’s own behavior (an ended challenge is simply excluded from every future run).- There is no delete. A challenge you created by mistake just stays in
draft— completion history must never lose its parent row once a challenge has actually run.
Rewards land on the nightly run, not the sale
This is the one piece of timing worth being explicit about: completing a challenge does not pay
out immediately. The platform never writes a reward synchronously while a sale is being processed —
a slow or failing reward write must never be able to hold up checkout. Instead, a nightly sweep looks
for members whose computed progress has reached target and pays them then.
That sweep is one of three independent legs dispatched from the jobs worker’s 30 1 * * * UTC
cron slot (alongside the existing nightly RFM-scoring and wallet-offer-expiry sweeps — each leg has
its own try/catch, so a failure in one never blocks the others). Concretely: a member’s purchase can
push their progress bar to 100% at any time of day, but the completion — the payout, the ledger entry,
and the “Completed” badge in the portal — only appears after that night’s run. The member portal’s
Challenges page says this plainly once a bar is full but not yet awarded: “Target reached — your
bonus lands after the nightly award run.”
The sweep is idempotent: running it twice awards nothing the second time. Idempotency is
belt-and-braces — a UNIQUE(tenant_id, challenge_id, member_id) row in challenge_completions is the
primary gate (one completion per member per challenge, non-repeating in v1 — completing a
challenge once is the only award it will ever pay a given member, even if it stayed active and their
progress reset and refilled later), and the paired ledger entry is written with a deterministic
external_id (challenge-<challengeId>-<memberId>) that the ledger’s own uniqueness constraint would
refuse a second time regardless. The reward itself is an ADJUST entry (not EARN — there’s no
earning rule behind a challenge bonus), and it also writes a timeline event so it shows up in the
member’s activity history on the merchant side.
Streaks
Unlike challenges, a streak needs no setup — every member has one, computed the same way: the consecutive chain of ISO weeks (Monday-anchored, evaluated in UTC) in which the member made at least one purchase.
- Current is the chain ending at this week (if they’ve already bought something this week) or last week (a week still in progress doesn’t break the chain retroactively) — if their most recent purchase was two or more weeks ago, current resets to zero.
- Best is the longest chain they’ve ever had; best is always ≥ current.
- A gap of even one week with no purchase breaks the chain.
Milestones (optional)
A program can optionally configure streak milestones — {weeks, reward_points} pairs, edited on the
program’s settings page in the dashboard (validated: positive integers, no duplicate weeks values,
since a member can only ever be awarded once per distinct weeks value). With no milestones
configured, members still see their current/best streak; no bonus is ever paid. Where milestones
exist, the same nightly sweep awards each one the first time a member’s current streak reaches it,
using the ledger’s own uniqueness (external_id: streak-<memberId>-<weeks>w) as the idempotency
guard directly — there’s no separate completions table for streaks the way there is for challenges,
because that key is already naturally non-repeating.
Where members see this
Everything above is merchant/API-side. Members see their live progress, streak, and completion history in the member portal — see the member portal guide for the Home and Challenges pages, and how a member gets there in the first place.
API surface (merchant-facing)
All of these require the same permission family as promotions and campaigns — campaigns:read for
the GETs, campaigns:manage for everything that writes:
POST /v1/challenges,GET /v1/challenges,GET /v1/challenges/:id,PATCH /v1/challenges/:idPOST /v1/challenges/:id/activate,POST /v1/challenges/:id/endGET /v1/challenges/:id/stats— a completions count, computed at read time (never a cached counter, matching the “progress is always computed” rule above)GET /v1/members/:id/challenges— one member’s live progress across every active challenge, the same computation the member’s own portal view uses
In the dashboard, all of this lives under Engagement → Challenges (list, create/edit, and the lifecycle actions), with the streak-milestones editor on each program’s settings page. Full request/ response shapes are in the API reference.
Next steps
- Member portal — where a member actually sees their challenges, streak, and rewards.
- Programs and rewards — the program and ledger model challenges and streaks are built on top of.
- API reference — every field and error code for
/v1/challenges*.