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"
}
| Parameter | Meaning |
|---|---|
since | ISO 8601 datetime — only events at or after this instant |
until | ISO 8601 datetime — only events at or before this instant |
event | exact event name |
person | a person id, resolved exactly the way GET /v1/persons/:id resolves one (alias and device-id lookup — see Identity resolution above) |
limit | events per page, default 50, capped at 500 |
after | an opaque cursor from a previous response’s next_cursor, to walk forward from there |
before | an 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 } ] }
| Parameter | Meaning |
|---|---|
since | ISO 8601 datetime |
until | ISO 8601 datetime, defaults to now |
interval | 1m, 1h, or 1d; default 1h |
event | one event name, at most 128 characters |
group_by | event_name, attribute:<column>, or property:<key> |
where | a 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:
interval | Default window when since is omitted |
|---|---|
1m | 1 hour |
1h | 24 hours |
1d | 7 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.