Docs Asking questions

Funnels

Steps in order over a window, with conditions per step and steps that may be optional.

A funnel is an ordered list of steps — “landed, clicked login, registered, paid” — and one question: how many people got through each one, and where did the rest stop.

Funnels are saved objects. You create one, give it a name, and re-run it over whatever date range you care about. There is a funnel screen in the Web UI too (see below) — this section documents the full HTTP API and the CLI, which is what that screen itself is built on.

Defining one

curl -X POST https://analytics.example.com/v1/funnels \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "signup",
    "window_seconds": 604800,
    "steps": [
      { "event": "$page", "where": [{ "property": "path", "operator": "=", "value": "/" }] },
      { "event": "login_click" },
      { "event": "signed_up", "where": [{ "property": "method", "operator": "=", "value": "email" }],
        "audience": { "kind": "behavior", "event": "$page", "aggregate": "count", "operator": ">=",
          "value": 3, "window": { "kind": "last", "n": 30, "unit": "days" } } }
    ]
  }'

A step is one event, plus two independent things you can constrain about it. where narrows which occurrence of the event counts — a property of the event itself, exactly the shape a segment behaviour uses — { property, operator, value }, with the same operators a segment condition takes (see Operators) — so you write a predicate the same way in both places. Predicates matter more than they look: a page-view funnel is several $page steps that differ only by path.

audience narrows which person may advance past the step — a claim about them, not about the event. It is a segment FilterNode tree verbatim, the same grammar POST /v1/segments takes (see Node types below), and it is optional per step. A person who fails it is not removed from the report — they are still counted at the step they did reach, just not advanced past this one. That is the difference from the funnel’s own segment_id: a person outside the funnel’s segment never appears in the report at all, while a person who fails a step’s audience still shows up, one step short. Both exist because those are different questions — “should this person be in this report at all” versus “does this person, having reached step 2, look the way step 3 requires before they can be counted at step 3.”

A step’s audience tree is capped like any segment tree, but there is also a funnel-wide cap: the behavioural conditions across every step’s audience in one funnel may total at most 25 (MAX_FUNNEL_BEHAVIOR_NODES, packages/core/src/funnels/validate.ts). That covers the embedded audiences only — the tree behind segment_id is a separate segment, capped separately at 25 behavioural nodes of its own when it was saved — so one run’s real worst case is up to 50 behavioural conditions, not 25. A trait-only audience costs nothing against that cap but still adds its own subquery, one per step at most — bounded by the eight-step ceiling above, not by MAX_FUNNEL_BEHAVIOR_NODES.

Two steps minimum, eight maximum.

Optional steps

A step can carry "optional": true. Absent means required — which is what every step was before this, so an existing funnel with no optional steps reports exactly what it always reported.

"steps": [
  { "event": "$page" },
  { "event": "signed_up" },
  { "event": "subscription_started" },
  { "event": "video_submitted", "optional": true },
  { "event": "subscription_canceled" }
]

Someone who skips video_submitted still counts at subscription_canceled — an optional step does not disqualify anyone from the steps after it. Conversion is measured over the required steps only.

An optional step branches off the last required step before it, not off whatever step precedes it in the definition — two optional steps back to back both branch from the same required step, not from each other. Its from_previous is a share of that branch point’s population, not of the step written just above it. Its result carries optional: true, a skipped count — the people who reached the branch point and did not do this step inside the window — and a continued count: of the people who did reach this step, how many went on to the next required step through it. people is the leg into the step and continued is the leg out of it; the gap between them is this step’s own drop-off. A required step’s result carries none of the three.

The first and last steps cannot be optional. Step 1 defines entry — it is what bounds who enters within the range — and the last step defines conversion; making either optional leaves both undefined.

The funnel chart in the Web UI draws all of this as a flow diagram, not a stack of bars: the required steps form a spine, an optional step hangs off it as a branch, and the people counted in continued rejoin the spine at the next required step — a fork that rejoins, the same shape described above.

What the widths mean. Every node and every band is drawn on one scale, set by the number of people who entered. A given thickness means the same number of people wherever it appears, so bands are comparable to each other and to the nodes they touch, anywhere on the chart.

Two things follow, and both are the point rather than side effects.

The space under a node’s bands is its drop-off. A node holding 800 people whose outgoing bands carry 500 of them leaves 300 people’s worth of its edge empty, and that gap is drawn where the loss happened.

Bands can run past a node, and that means something too. Someone who does an optional step after a later step is counted on both legs leaving the step before it — genuinely on two paths, because the funnel cannot tell which came first. Where that happens the stack extends past the node’s own edge, and the node’s caption names the number: 3 counted twice. It is the same windowFunnel limit documented above under ordering, seen from the chart.

An earlier version scaled each node’s bands to fill its edge exactly. The geometry always added up, at the cost of both readings above: a width meant something only against the one node it touched, and a node’s edge was always fully covered — so a funnel that converts everyone drew as a solid rectangle with no space anywhere in it.

A funnel may have up to MAX_OPTIONAL_STEPS (2) optional steps, inside the same eight-step ceiling as before. Each optional step costs two extra windowFunnel chains, and both copy the SQL text of every condition before them — one measured shape at three optional steps compiles past ClickHouse’s 262,144-byte max_query_size and fails outright, which is why the limit is 2 and not 3.

A funnel that compiles past that same limit some other way — enough where predicates or step audiences, even with two or fewer optional steps — is refused with a 400 before it ever reaches ClickHouse, naming what to remove: predicates, audiences, or optional steps. That cap bounds the failure; it does not fix it — #200 tracks compiling this down instead of capping it.

The limit worth knowing before you rely on this: an optional step counts any time after the required step before it and inside the window — including after a later step. Someone who cancels and then submits a video afterward is still counted as having done the optional step. windowFunnel reports a chain length, not the instants it matched at, so “step 4 happened before step 5” is not a question this can answer without a different query shape. That is also why the two bands leaving a branch point on the chart do not have to sum to the branch point’s own count: someone who does the optional step after the required step it feeds into is counted on both legs leaving that branch point — once heading into the optional step, once heading straight past it on the required chain — the same order-blindness, seen from the chart instead of from /dropoff.

The three clocks

This is the part worth reading twice, because getting it wrong makes a funnel quietly report the wrong number.

  • window_seconds belongs to the funnel. It is how long one person gets to finish once they have started. Maximum 30 days.
  • since and until belong to the question, and are supplied per run, never stored. They bound who enters the funnel — a person enters by matching step 1 inside that range.
  • A step’s audience window belongs to the person, measured from now. Its last window (like a segment’s) looks back from the moment the funnel runs, not from the run’s since/until and not from when that person entered. Run the same funnel over an older range and a step’s audience is still judging people against today, not against the range you asked about or the day they took step 1 — the Web UI’s funnel builder says this on screen for the same reason it is said here.

Because those are different things, a run observes conversions past the end of the range: someone who entered an hour before until still gets their full window to finish, so the query reads on to until + window (or to now, whichever comes first). Without that, a funnel would report as failures people who simply had not finished yet.

That leaves one honest gap, and the response names it rather than hiding it. Someone who entered ten minutes ago has not had their seven-day window. They are counted in entered — dropping them would misstate the population — and also reported separately as partial_window_entrants, with a warning saying how many. They can still convert. If a recent funnel looks worse than you expected, that number is the first thing to check.

Running one

curl -X POST https://analytics.example.com/v1/funnels/3/run \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{ "since": "2026-08-01T00:00:00Z", "until": "2026-08-08T00:00:00Z" }'

Omit both and you get the last seven days.

For a range relative to now, send days instead of a since:

curl -X POST https://analytics.example.com/v1/funnels/3/run \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{ "days": 90 }'

Prefer days over a bare since for a relative range. They are not equivalent. since with no until leaves the server to fill in until from its own clock, which is later than yours by however long the request took to arrive, so the span you get is slightly longer than the one you asked for. A range may span at most 90 days, so "days": 90 is accepted and a since of 90 days ago is not. days cannot be combined with since or until — a body carrying both is refused rather than resolved by a precedence rule you would have to know about.

The response always echoes the range it actually used:

{
  "entered": 1284,
  "converted": 212,
  "conversion_rate": 0.165,
  "steps": [
    { "index": 1, "event": "$page", "people": 1284, "from_previous": 1, "from_start": 1 },
    { "index": 2, "event": "login_click", "people": 507, "from_previous": 0.395, "from_start": 0.395 },
    { "index": 3, "event": "signed_up", "people": 212, "from_previous": 0.418, "from_start": 0.165 }
  ],
  "partial_window_entrants": 96,
  "range": { "since": "2026-08-01T00:00:00.000Z", "until": "2026-08-08T00:00:00.000Z" },
  "as_of": "2026-08-08T09:31:02.000Z",
  "warnings": [  ]
}

A step’s people is everyone who reached at least that step. Both rates are given because deriving one from the other is a multiplication that is easy to get subtly wrong.

POST /v1/funnels/preview takes the same body plus a full definition and runs it without saving anything — for trying a funnel out before committing to it.

Every run also updates the funnel’s cached summarylast_entered, last_converted, last_evaluated_at and last_range — which is what GET /v1/funnels returns so a list of N funnels renders without N scans.

last_range is the window those counts came from, as { "since": …, "until": … }, and it exists because the counts are meaningless without it: running a funnel over 90 days used to leave the list showing a 90-day rate with nothing to say it was not the seven-day default. Always render the rate beside both its range and its timestamp, never as a bare number.

It is null in two cases, and they are not the same: a funnel that has never run has null counts too, while a funnel summarised before ranges were recorded has real counts and no record of what they answer. Neither should be labelled with a guess.

All four are cleared together by a PATCH that changes steps, window_seconds or segment_id. A stored range describes the definition it was computed from just as much as the counts do.

How a person is counted

  • In order. Steps must happen in the order listed. Unrelated events in between are fine.
  • Best attempt. Someone who abandoned on Monday and completed on Tuesday counts as converted. The window slides to find their best run through, so one bad start does not condemn them forever.
  • Once. A person appears at exactly one step — the furthest they reached.
  • As one person. Steps taken anonymously and steps taken after logging in belong to the same person, provided the device was identified (see Identity resolution).
  • Not at all, if they asked to be deleted. The same suppression boundary every other read path enforces.

Who dropped out

curl -X POST https://analytics.example.com/v1/funnels/3/dropoff \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{ "step": 2 }'

Lists the people who reached step 2 and went no further. Steps are numbered from 1, matching index in the run response. Paged with an opaque cursor, and bounded the same way the segment members preview is — 100 per page, 1,000 total. It is a preview of a population, not an export of it.

/dropoff predates /people below and is retained for compatibility, unchanged — its own response shape, its own cursor label, so a caller that already scripts against it never sees a difference. It is not two ways of doing one thing: /people is the general endpoint, and /dropoff is the one call it happens to always make (mode: "dropped").

On an optional step, /dropoff is refused with a 400 — skipping a step means never stopping there, so “stopped exactly here” is not a population an optional step has. The error points the caller at /people, where reached or skipped are the two readings that mean something.

Who reached a step, or stopped there

curl -X POST https://analytics.example.com/v1/funnels/3/people \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{ "step": 2, "mode": "reached" }'

mode is required — there is no default. reached (level >= step, everyone who got at least that far) and dropped (level = step, everyone who stopped exactly there) differ by a factor of three on a real funnel, and whichever way a default fell, the other reading is what a caller would get by accident.

A third mode, skipped, exists for optional steps only — the people who reached the required step this one branches off and did not do this step inside the window, the same count the run response’s skipped field gives for that step. Asking for mode: "dropped" on an optional step is refused with a 400 (code: "mode"): skipping it means never stopping there, so that reading is not a population an optional step has. Asking for mode: "skipped" on a required step is refused the same way — nobody can skip a required step, so the mode means nothing there.

reached is the population behind the number on the chart: step N’s people in the run response above is exactly this count, at whatever instant the run was taken. dropped is deliberately a different, usually smaller, number — the same population /dropoff lists.

One case looks wrong and is not: at the funnel’s last step, dropped is not empty. A level means “got no further than this level” (see How a person is counted above), so someone who converted all the way through is at the last step’s level too — there is no level past it to place them at instead. mode: "dropped" on the final step therefore returns the same people mode: "reached" does. This is not new behavior particular to /people/dropoff has always answered the final step this way — /people just makes it visible as a “dropped” label sitting next to someone who converted.

The response:

{
  "members": [
    { "person_id": "user-42", "first_seen": "2026-07-01T00:00:00.000Z",
      "last_seen": "2026-08-08T09:20:00.000Z", "entered_at": "2026-08-08T09:12:00.000Z",
      "identified": true,
      "country": "US", "region": "CA", "city": "San Francisco", "device_type": "desktop",
      "os": "macOS", "browser": "Chrome", "referrer": "https://google.com",
      "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "launch",
      "traits": { "plan": "trial" }, "traits_num": {}, "trait_total": 1 }
  ],
  "person_count": 212,
  "range": { "since": "2026-08-01T00:00:00.000Z", "until": "2026-08-08T00:00:00.000Z" },
  "as_of": "2026-08-08T09:31:02.000Z",
  "next_cursor": "eyJ...base64url...",
  "window_exhausted": false
}

Each row is a segment member row plus entered_at, identified included — see Retrieving members, not just the count above for what that field means and why a caller wants it.

traits can be empty on a row that clearly has traits, and that is a known limitation rather than a bug in your data. A funnel resolves each person at their own event’s timestamp, because a funnel is a claim about what someone did and when — an event has to belong to whoever owned that device at the time. Traits are resolved at the current instant, because a trait carries no event time and “this person’s traits today” is what a trait is. For a device that was rebound to a different person between the funnel event and your request, those two readings name different people and the row comes back with no traits.

It cannot show you someone else’s traits. Both sides derive the person the same way, so a disagreement drops the traits rather than attaching the wrong ones — a blank cell, never a wrong one, and never a privacy problem. Read a person’s traits from GET /v1/persons/:id if you need them for certain.

person_count is its own query, taken at the same as_of the page is — never the run’s cached step number, because a run and a /people call can land at different instants, and a stale count printed beside a fresh page is exactly the kind of mismatch that makes both look wrong. Paged and bounded the same way /dropoff and the segment members preview are: 100 per page, window_exhausted: true once 1,000 rows have been served, and a cursor that only replays against this route, never /dropoff or a segment walk.

There is no CLI command for /people yet — see From the CLI below. The funnel screen in the Web UI does have it: click a step in a saved funnel and a Reached/Dropped toggle opens beneath the chart, each option carrying its own count.

From the CLI

lyraflow funnels list
lyraflow funnels run signup --since 7d
lyraflow funnels run signup --since 7d --json
lyraflow funnels preview --file signup.json
lyraflow funnels dropoff signup --step 2

Funnels are addressed by name. With --json, the step table goes to stdout as one JSON object per line and everything else — the summary and any warnings — goes to stderr, so a pipeline stays parseable and a human still sees the caveats.

What this does not do yet

There is no time-to-convert: you get how many people reached each step, not how long it took them. There is no breakdown — you cannot split a funnel by campaign, device or country, though a trend can be split that way (see Trends). There is no strict mode, where a later step appearing early breaks the chain. Path analysis is not here either. All are planned; none exist today. Retention grids and trends do now exist — see below.

A funnel is computed on demand every time you run it, with nothing cached and nothing precomputed. A wide range over a high-volume event like $page is a large scan, and the response will warn you when it is about to be one.

This page is the Funnels section of the product README at v0.15.0. It is generated from that file rather than written here, so a correction belongs upstream.