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

The filter

?location_group=<group id> is accepted on overview, series, and enrollmentnever 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