Querying funnels and retention without a BI stack
Three query endpoints answer the questions a dashboard would: event lists, path trees and retention cohorts. What each one returns and where its limits are.
The usual path from “we track events” to “we can answer questions” runs through a warehouse, a transformation layer and a BI tool. Infrai’s analytics namespace skips that for the three questions teams actually ask first: POST /v1/analytics/query/events lists matching events, POST /v1/analytics/query/path returns what users did next, and POST /v1/analytics/query/retention builds cohorts.
Three calls, no pipeline. They won’t replace a warehouse, and they answer the questions that come up in a weekly review.
List events in a window
curl -sS -X POST "https://api.infrai.cc/v1/analytics/query/events" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"since": "2026-09-01T00:00:00Z",
"until": "2026-09-21T00:00:00Z",
"filter": {"event": "subscription_started", "properties": {"plan": "pro"}},
"limit": 100
}'
{
"ok": true,
"data": {
"items": [
{"event": "subscription_started", "distinct_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"properties": {"plan": "pro", "seats": 5, "annual": true},
"timestamp": "2026-09-14T11:02:41Z"}
],
"next_cursor": null,
"total_estimate": 84
}
}
since and until are both required, which is the right constraint — an unbounded event query over a busy account is a query that times out and teaches you nothing.
total_estimate is an estimate, so use it for “roughly how many” and not for a number in an invoice. When you need exactness, page through with next_cursor and count.
What users did next
curl -sS -X POST "https://api.infrai.cc/v1/analytics/query/path" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"start_event": "signup_completed",
"since": "2026-09-01T00:00:00Z",
"until": "2026-09-21T00:00:00Z",
"max_depth": 4
}'
The response is a root with children — a tree of what followed the start event, with counts. That’s your funnel, read backwards: the branch that drops off is the step people don’t take.
max_depth bounds the tree. Four is usually the useful depth; past that the branches split into noise.
Retention cohorts
curl -sS -X POST "https://api.infrai.cc/v1/analytics/query/retention" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"born_event": "signup_completed",
"return_event": "export_requested",
"since": "2026-07-01T00:00:00Z",
"until": "2026-09-21T00:00:00Z",
"interval": "week"
}'
The born_event defines the cohort and the return_event defines what counts as retained.
Separating those two is what makes the answer worth having: retention measured on “logged in again” flatters every product, because people log in to cancel, to check a bill, or because a notification told them to — whereas retention measured on “did the thing the product exists to do” is a number you can act on, and it is almost always lower than the one in the board deck.
interval is day, week or month. Weekly is right for most B2B products; daily for consumer apps with a daily habit.
One script, three answers
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def query(path: str, body: dict) -> dict:
resp = SESSION.post(f"{API}{path}", json=body, timeout=90)
payload = resp.json()
if not payload.get("ok"):
raise RuntimeError(f"{path}: {payload.get('error', {}).get('code')}")
return payload["data"]
def weekly_review(since: str, until: str) -> dict:
"""The three questions a weekly review asks, answered without a warehouse."""
conversions = query("/v1/analytics/query/events", {
"since": since, "until": until,
"filter": {"event": "subscription_started"}, "limit": 1,
})
journey = query("/v1/analytics/query/path", {
"start_event": "signup_completed", "since": since, "until": until, "max_depth": 4,
})
retention = query("/v1/analytics/query/retention", {
"born_event": "signup_completed", "return_event": "export_requested",
"since": since, "until": until, "interval": "week",
})
children = journey.get("root", {}).get("children") or []
ranked = sorted(children, key=lambda c: -(c.get("count") or 0))
return {
"subscriptions_estimate": conversions.get("total_estimate"),
"top_next_steps": [{"event": c.get("event"), "count": c.get("count")} for c in ranked[:5]],
# A cohort that never returns is the finding; a cohort that halves is normal.
"cohorts": [{"cohort": c.get("cohort"), "sizes": c.get("values")}
for c in (retention.get("cohorts") or [])][:6],
}
if __name__ == "__main__":
for key, value in weekly_review("2026-09-01T00:00:00Z", "2026-09-21T00:00:00Z").items():
print(f"{key}: {value}")
Put that on POST /v1/cron/create and have it email the result with POST /v1/email/send — a weekly review that arrives without anyone opening a dashboard gets read, and both calls are on the same key as the queries.
Where these three stop
| Question | Answerable here? |
|---|---|
How many X events in a window | yes |
What do users do after X | yes, query/path |
| Do cohorts come back | yes, query/retention |
| Revenue by plan by month, joined to your billing table | no — that’s a warehouse question |
| Arbitrary group-by across four dimensions | no |
| Ad-hoc exploration by a non-engineer | no — there’s no query UI |
Rows four to six are the honest boundary, and they’re where a real analytics product earns its price. PostHog and Mixpanel give a product manager a UI to explore without writing code, and a warehouse plus a BI tool answers questions nobody anticipated. If your team wants to explore rather than to instrument, buy one of those.
Feed the numbers somewhere durable
Query results are point-in-time. If you want a trend of your own conversion rate, record the answer rather than re-querying history: POST /v1/metrics/report takes the number, GET /v1/metrics/query reads the series back, and now you have a chart of a derived metric that no analytics product computed for you.
That’s the compounding benefit of one credential: the events, the queries over them, the derived metric series, the schedule that computes it and the email that delivers it are one account and one GET /v1/account/usage — rather than an analytics vendor, a metrics vendor and a scheduler with three invoices.
Limitations
There’s no SQL and no custom aggregation: the three query shapes are the query surface, so a question that doesn’t fit one of them needs the raw events paged out of query/events and processed on your side. total_estimate is approximate. And there’s no dashboard, so every answer is something you render or email yourself.
Query endpoints report billing_class: free in discovery, so asking costs nothing; the tracking that populated them is the billable part, live in GET /v1/discovery/analytics.track (verified 2026-09-21), with rates drifting downward as vendor contracts improve.