Analytics
Everything under /v1/analytics is computed on read, straight off canonical_transactions,
ledger_entries, and members — there are no materialized read models or nightly-built rollup
tables here, so a number you read is always as fresh as the last committed transaction. Reads need
reports:read; CSV export needs the separate reports:export permission (a dashboard member role,
for example, can have one without the other).
Overview
GET /v1/analytics/overview?window=30d|90d|365d (default 30d) returns one snapshot:
| Field | What it means |
|---|---|
active_members |
Distinct members with at least one transaction in the window. |
new_members |
Members who joined in the window. |
repeat_rate |
Members with ≥2 transactions ÷ members with ≥1, in the window. null (never 0) when nobody transacted at all — a real zero-repeat rate and “no data” are different claims. |
redemption_rate |
Distinct redeemers ÷ distinct earners in the window. Same null-not-0 treatment when nobody earned. |
points_economy |
{earned, redeemed, expired, adjusted} for the window. adjusted is the signed net of ADJUST rows (referral bonuses, challenge/streak rewards, manual corrections) — reported on its own rather than folded into earned, because unlike the other three it can legitimately go negative. |
avg_transaction_value |
Member vs. non-member average sale size, with sample sizes and a lift ratio (member_avg / non_member_avg − 1). lift is null whenever the non-member sample is under 30 transactions — below that, the comparison is too noisy to call out a number, so the API says so instead of publishing a shaky one. |
Series
GET /v1/analytics/series?metric=<one of six>&window=30d|90d|365d returns weekly buckets
(transactions, new_members, points_earned, points_redeemed, points_adjusted, avg_rating).
Every week in the window is present in the response, including weeks with no data at all — for the
five count-shaped metrics a gap week reads 0; for avg_rating a gap week reads null, deliberately
never 0 (zero ratings that week is a different claim than an average rating of zero).
Enrollment
GET /v1/analytics/enrollment?window=30d|90d|365d returns by_source (a count per members.source
value) and a weekly join-count series over the same window. source is backfilled-approximate for
members enrolled before the CRM migration that introduced it — this endpoint makes no attempt to hide
that; if you’re building on it, treat pre-CRM sources as best-effort.
CSV export
GET /v1/analytics/export?report=members|transactions|feedback|referrals&window=... (window is
optional here, unlike the three reads above — an export’s natural default is “everything”).
Rows stream as text/csv, header first. There’s a hard 50,000-row bound: rather than silently
truncating a bigger export mid-stream, the endpoint counts first and answers an honest
422 EXPORT_TOO_LARGE up front, asking you to retry with a narrower window — you never get back a
file that looks complete but secretly isn’t.
Franchise views: filtering by location group
A tenant with multiple stores can group them (location_groups — plain {name} records a merchant
creates, renames, and deletes from the Locations settings page) and then filter the three read
endpoints above down to one group, without losing the honesty guarantees above.
Managing groups
POST /v1/location-groups,GET /v1/location-groups,PATCH /v1/location-groups/:id(rename only) —settings:read/settings:manage, the same permission pair/v1/locationsuses.GETincludes each group’s currentlocation_count, since that’s what a delete button needs to know before offering itself.DELETE /v1/location-groups/:idonly succeeds on an empty group — one with zero locations currently assigned. A non-empty group answers409 GROUP_NOT_EMPTYnaming how many locations are still assigned, rather than deleting out from under them.- A location joins a group through the location itself:
PATCH /v1/locations/:idwith agroup_id(that field has existed since the very first migration; it simply had no consumer before this). An unknown group id is refused rather than silently accepted.
The filter
?location_group=<group id> is accepted on overview, series, and enrollment — never on
export, which stays whole-tenant. An id that doesn’t exist (or belongs to a different tenant,
which RLS makes indistinguishable from “doesn’t exist”) is refused rather than silently answering
zero rows dressed up as a filtered result. Passing no filter at all is guaranteed byte-identical to
the pre-franchise response shape — filtering is purely additive, never a behavior change for existing
integrations that don’t pass it.
The honest limit: not every figure has a location
The underlying predicate is location_id IN (SELECT id FROM locations WHERE group_id = ...), applied
to canonical_transactions — the only table in this platform that actually records where a sale
happened. ledger_entries carry no location, and a member’s joined_at/profile data is a join with
no location at all. That means some figures genuinely cannot be scoped to a location group without
fabricating an attribution, and the API says so explicitly rather than quietly answering a
tenant-wide number as if it had been filtered: whenever location_group is passed, the response
carries a location_scope: {group_id, figures: {...}} object mapping every top-level figure to
whether the filter actually applied to it. The dashboard’s Analytics and Activity pages, which gain a
location-group selector (All locations / one group) alongside this filter, render a false
figure with a muted “not location-specific” marker rather than pretending it was scoped.
Exactly which figures are and aren’t location-scoped, verbatim from what the filter actually touches:
| Overview figure | Location-scoped? |
|---|---|
active_members |
Yes |
repeat_rate |
Yes |
avg_transaction_value |
Yes |
new_members |
No — a join, not a transaction |
redemption_rate |
No — reads ledger_entries, which carries no location |
points_economy |
No — same reason |
| Series metric | Location-scoped? |
|---|---|
transactions |
Yes — the only one |
new_members, points_earned, points_redeemed, points_adjusted, avg_rating |
No |
Enrollment’s by_source and weekly are never location-scoped — every enrollment figure is
member-join data with nothing to filter on, so a location_group param on that endpoint still
returns tenant-wide numbers, honestly marked as such.
Next steps
- Programs and rewards — the ledger data these figures are computed from.
- Analytics copilot — the platform’s natural-language interface over a related, but separately-guarded, read surface.
- API reference — full request/response shapes for
/v1/analytics*and/v1/location-groups*.