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

fieldmeaning
start_eventwhat puts somebody in a cohort. * means any event, so * cohorts people by when you first saw them.
start_wherewhich occurrence of it counts — the same where grammar a funnel step and a segment behaviour take.
return_eventwhat counts as coming back. May be the same as start_event; * means any activity.
return_wherethe same, for the return side, and independent of start_where.
granularityday, week or month. Weeks start Monday, and every bucket is UTC.
periodshow many periods after the cohort’s own to measure, up to 26.
since / untilbound who enters a cohort. Optional; defaults to the last periods periods. The Web UI offers presets and a two-date range for this.
segment_idrestrict 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 & pathDoes
GET /v1/retention-reportsList every saved retention report in the project
POST /v1/retention-reportsCreate one
GET /v1/retention-reports/:idRead one
PATCH /v1/retention-reports/:idRename it, or change any of the fields above
DELETE /v1/retention-reports/:idDelete 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.