Docs Asking questions

Reading events

Reading raw events back out, with the cursor rules that make paging safe.

Two read endpoints answer “what happened” and “how much, over time” directly against the event store, with no filter tree to write first — the first thing anyone reaches for after instrumenting a site. Both are server-key only, like every other read below, and both exclude events belonging to a person who has been deleted — the same suppression boundary every other read path enforces (see Privacy: deletion and export below) applies here too, from the moment a deletion is accepted, not only once the purge finishes.

GET /v1/events

The event feed, always ordered oldest-first:

curl -s "http://localhost:3000/v1/events?since=2026-08-09T03:16:00.000Z&limit=2" \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
{
  "events": [
    { "event_id": "22222222-2222-2222-2222-222222222223", "timestamp": "2026-08-09T03:17:34.357Z", "event_name": "signup", "anonymous_id": "visitor-3", "user_id": "", "properties": {"plan":"trial"}, "properties_num": {}, "url": "", "path": "", "referrer": "", "utm_source": "", "utm_medium": "", "utm_campaign": "", "utm_term": "", "utm_content": "", "device_type": "desktop", "os": "macos", "browser": "chrome", "country": "", "region": "", "city": "" },
    { "event_id": "22222222-2222-2222-2222-222222222224", "timestamp": "2026-08-09T03:17:34.364Z", "event_name": "signup", "anonymous_id": "visitor-4", "user_id": "", "properties": {"plan":"trial"}, "properties_num": {}, "url": "", "path": "", "referrer": "", "utm_source": "", "utm_medium": "", "utm_campaign": "", "utm_term": "", "utm_content": "", "device_type": "desktop", "os": "macos", "browser": "chrome", "country": "", "region": "", "city": "" }
  ],
  "next_cursor": "WyIyMDI2LTA4LTA5IDAzOjE3OjM0LjM2NCIsIjIyMjIyMjIyLTIyMjItMjIyMi0yMjIyLTIyMjIyMjIyMjIyNCJd",
  "prev_cursor": "WyIyMDI2LTA4LTA5IDAzOjE3OjM0LjM1NyIsIjIyMjIyMjIyLTIyMjItMjIyMi0yMjIyLTIyMjIyMjIyMjIyMyJd"
}
ParameterMeaning
sinceISO 8601 datetime — only events at or after this instant
untilISO 8601 datetime — only events at or before this instant
eventexact event name
persona person id, resolved exactly the way GET /v1/persons/:id resolves one (alias and device-id lookup — see Identity resolution above)
limitevents per page, default 50, capped at 500
afteran opaque cursor from a previous response’s next_cursor, to walk forward from there
beforean opaque cursor from a previous response’s prev_cursor, to walk backward from there

before and after are mutually exclusive — sending both is 400. They name opposite directions over the same keyset, and every response is ordered oldest-first regardless of which one you sent: prev_cursor is always the page’s own oldest row and next_cursor its own newest, whichever direction produced the page. A screen paging backwards (the person profile’s timeline does) reverses the page itself to show newest-first; the wire contract stays one ordering, always.

When since is omitted and no cursor (after or before) is given either, the server defaults to the last 24 hours. That default deliberately does not apply once a cursor is present: a cursor already carries its own lower bound, and stacking the 24-hour default on top of an older cursor would silently drop every event between the cursor’s real position and the default’s edge — a gap that, once next_cursor has advanced past it, is never reachable again. An explicit since alongside a cursor still applies normally; it is only the default that backs off in a cursor’s presence.

next_cursor and prev_cursor are keyset positions over (timestamp, event_id), and opaque — treat either as an opaque token, never decoded or constructed by hand. Unlike the segment cursor above, neither is signed: forging one only lets a caller holding the server key read their own project’s events in a different order, which they could already do by choosing their own since/until, so there is nothing here for a signature to protect. Both are null on an empty page. limit above 500 is rejected with 400 {"error":"invalid_query"}, never silently clamped. A malformed, truncated, or hand-built after or before is a 400:

{ "error": "invalid_cursor" }

person follows the same device-window ceiling GET /v1/persons/:id does (see Identity resolution above): a person spanning more than 200 device windows is 400 person_history_too_fragmented rather than an unbounded query, with the same shape that read already documents.

GET /v1/events/stats

Time-bucketed counts — “how much, over time” rather than “what happened”:

curl -s "http://localhost:3000/v1/events/stats?since=2026-08-09T03:00:00.000Z&interval=1h" \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY"
{ "buckets": [ { "bucket": "2026-08-09T03:00:00.000Z", "events": 8 } ] }

Add group_by=event_name to split each bucket by event name — event_name is present on a bucket only when grouping was requested:

{ "buckets": [ { "bucket": "2026-08-09T03:00:00.000Z", "event_name": "signup", "events": 8 } ] }
ParameterMeaning
sinceISO 8601 datetime
untilISO 8601 datetime, defaults to now
interval1m, 1h, or 1d; default 1h
eventone event name, at most 128 characters
group_byevent_name, attribute:<column>, or property:<key>
wherea JSON array of predicates, at most 10; see Filtering below

event narrows the aggregate to a single event name, exactly as it does on GET /v1/events, and works with or without group_by. It is applied before the counts are grouped, so it narrows the scan rather than the result.

There is a hard cap of 1,000 buckets per request. This route sums groups server-side rather than paging rows, so unlike the feed it has no limit to hide an oversized window behind — a window whose bucket count at the requested resolution would exceed 1,000 is rejected before any query runs:

{ "error": "window_too_large", "detail": "this window at 1h resolution would produce 57892 buckets, above the limit of 1000" }

The default window, when since is omitted, scales with interval rather than a single fixed span — a flat 24-hour default collides with the 1,000-bucket cap at fine resolutions, so a bare ?interval=1m with nothing else would otherwise be an unconditional 400:

intervalDefault window when since is omitted
1m1 hour
1h24 hours
1d7 days

401 for a missing or invalid server key, on both endpoints.

Filtering

where narrows which occurrences of the event are counted. It carries the same predicate grammar a segment behaviour and a funnel step use, as a JSON array:

curl -sG -H "x-lyraflow-server-key: $KEY" \
  --data-urlencode 'event=$page' \
  --data-urlencode 'interval=1d' \
  --data-urlencode 'where=[{"property":"path","operator":"=","value":"/register"}]' \
  "http://localhost:3000/v1/events/stats"

Without it, a site whose every navigation is a $page can only chart “any page viewed”, so $page where path = /register and $page where path = / are the same chart.

At most 10 predicates, ANDed. {"source":"attribute","attribute":"utm_source",…} filters on a column of the event; the default reads the event’s own properties, from both bags, so a numeric property filters by value rather than reading as unset. Anything that is not a valid predicate list is a 400 invalid_where rather than a silently wider answer. where is independent of event: with no event name it asks the question of every event.

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