Docs Asking questions
Retention
Of the people who did one thing in a period, how many came back and did another.
Of the people who did one thing in a period, how many came back and did another in the periods after it. Funnels ask where people stop inside one flow; this asks whether they return at all.
curl -s http://localhost:3000/v1/reports/retention \
-H "X-Lyraflow-Key: sk_..." -H 'Content-Type: application/json' \
-d '{ "start_event": "signed_up", "return_event": "project_created",
"granularity": "week", "periods": 8 }'
{ "granularity": "week", "periods": 8,
"cohorts": [
{ "cohort": "2026-06-01", "size": 412, "retained": [180, 96, 71, 64, 58, 55, 51, 49, 47] },
{ "cohort": "2026-06-08", "size": 388, "retained": [166, 88, 66, 60, 55, 51, 48, null, null] }
],
"computed_at": "2026-08-27T09:00:00.000Z", "warnings": [] }
retained[k] is how many of that cohort did the return event in period k.
Period 0 is the cohort’s own period — when the two events are the same it
is the whole cohort by construction, and when they differ it is usually the
most interesting number in the grid.
null is not zero. A cell is null when that period had not finished
when the grid was computed. This is the single most important thing about
reading one: a retention grid that reported unfinished periods as 0 would
show a collapse in its newest cohorts, in exactly the corner a reader scans
for a trend. computed_at says when “not yet” was decided; the same request
run later fills those cells in.
A grid can also be a saved object, named and reopened later the same
way a funnel is — see Saved retention reports
below. Saving one does not change how it runs: a saved report is still
answered by this same POST /v1/reports/retention call, with its stored
fields as the body.
What you choose
| field | meaning |
|---|---|
start_event | what puts somebody in a cohort. * means any event, so * cohorts people by when you first saw them. |
start_where | which occurrence of it counts — the same where grammar a funnel step and a segment behaviour take. |
return_event | what counts as coming back. May be the same as start_event; * means any activity. |
return_where | the same, for the return side, and independent of start_where. |
granularity | day, week or month. Weeks start Monday, and every bucket is UTC. |
periods | how many periods after the cohort’s own to measure, up to 26. |
since / until | bound who enters a cohort. Optional; defaults to the last periods periods. The Web UI offers presets and a two-date range for this. |
segment_id | restrict the whole grid to a saved segment’s population. |
The two where lists are what make one event name usable. On a site where
every navigation is a $page, “viewed the home page, then came back and
registered” is one event name and two different conditions:
{ "start_event": "$page",
"start_where": [{ "source": "attribute", "attribute": "path", "operator": "=", "value": "/" }],
"return_event": "$page",
"return_where": [{ "source": "attribute", "attribute": "path", "operator": "=", "value": "/register" }],
"granularity": "week", "periods": 8 }
Predicates on one side are ANDed together, and the two sides never see each
other’s. They are the same shape a funnel step’s where takes, including the
text, presence, boolean and relative-date operators — so a predicate is
written identically in all three places.
A person belongs to the cohort of their FIRST start event inside the range — not their first ever, which would make the range decorative — and to exactly one cohort per run. Doing the start event again later does not move them or count them twice.
since/until bound entry, not observation. Measuring period 8 of the
last cohort needs events from eight periods after until, and the scan runs
on to fetch them. This is the same entry/observation split funnels make.
Cohorts are calendar-anchored, so a row is “the week of 3 June” rather than “day 0–6 since signup”. One consequence is worth knowing: somebody who starts on a Sunday gets a one-day period 0. Rolling-from-signup retention is a different report and is not this one.
Limits, and what this does not do yet
A range wider than 60 cohorts is refused rather than truncated — a grid
silently missing its oldest rows is a chart with a trend that is not in the
data. periods is capped at 26.
There is no breakdown — you cannot split a grid by campaign or country. Nothing is cached; every run is a real scan, saved or not.
Saved retention reports
A saved retention report is a named, stored definition — the two events,
their conditions, the granularity, the period count and an optional segment
— that the Retention screen creates and reopens. There is no /run
endpoint here either: running a saved report, from the screen or by hand, is
the same POST /v1/reports/retention call above with its stored fields as
the body.
curl -X POST http://localhost:3000/v1/retention-reports \
-H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
-H 'content-type: application/json' \
-d '{ "name": "Signup to first project", "start_event": "signed_up",
"return_event": "project_created", "granularity": "week", "periods": 8 }'
| Method & path | Does |
|---|---|
GET /v1/retention-reports | List every saved retention report in the project |
POST /v1/retention-reports | Create one |
GET /v1/retention-reports/:id | Read one |
PATCH /v1/retention-reports/:id | Rename it, or change any of the fields above |
DELETE /v1/retention-reports/:id | Delete it — 204 |
A duplicate name within the same project is a 409. A non-numeric :id is a
400 naming invalid_retention_report_id; an id that does not exist, or
belongs to another project, is a 404.
A stored where list that no longer parses is marked, not hidden.
stale is on every row — true when a report’s start_where or
return_where no longer parse under today’s grammar, false otherwise —
and no route fails a report out for it: a row written by an older build
stays listed, readable, renameable and deletable even after the grammar
around it has moved on. The Retention screen reads the same field: opening
a stale report skips its automatic run and says “The filters saved with
this report no longer parse, so it cannot be reproduced as saved” — but
leaves Run enabled, so the operator can still run the degraded version
rather than being locked out of it.
What is not stored is the range, the same as a saved trend. Reopening a
report runs it over whatever range the screen currently has, and that range
— combined with the stored granularity — can land over the same 60-cohort
ceiling an ad hoc grid is held to. When it does, nothing is computed: Run is
disabled and the screen names the cohort count and the limit, the same
warning a fresh grid gets for the same reason. That is a different case from
the null cells above — those come from a grid that did run, on periods
too recent to have closed yet; a report reopened over too wide a range, or
too fine a granularity, never runs at all.
This page is the Retention section of the product
README at v0.15.0. It is generated from that file rather than
written here, so a correction belongs upstream.