{"openapi":"3.1.0","info":{"title":"luchy","version":"1","description":"Privacy-friendly web and product analytics. Read metrics, people, customers and live visitors of a project, or run read-only SQL."},"servers":[{"url":"https://dash.luchy.app"}],"tags":[{"name":"analytics","description":"Read the analytics of the current project."},{"name":"admin","description":"Projects and keys. Owner only."}],"security":[{"bearer":[]}],"paths":{"/api/analytics.query":{"post":{"operationId":"analytics.query","tags":["analytics"],"summary":"Metrics for the project over a time range: totals, an optional breakdown, time series and a comparison with another period.","description":"Every requested metric is computed. visitors = distinct coalesce(user, visitor_hash); users = distinct identified users; avg_duration in seconds; bounce_rate in %.\n\nAdd `granularity` for a zero-filled time series and `compare: \"previous_period\"` to also get the period before with deltas; the result renders as a chart.\n\nExample — who used the app this week vs last: `{ metrics: [\"users\", \"events\"], granularity: \"day\", compare: \"previous_period\" }`.\n\nAnything this cannot express goes through `analytics.sql`.\n\nRequires the `analytics:read` permission.","x-permission":"analytics:read","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"from":{"description":"Inclusive start (ISO 8601). Default: 7 days before `to`.","type":"string"},"to":{"description":"Exclusive end (ISO 8601). Default: now.","type":"string"},"tz":{"description":"IANA timezone for buckets, hour and weekday. Default UTC.","type":"string"},"metrics":{"minItems":1,"type":"array","items":{"type":"string","enum":["events","pageviews","sessions","visitors","users","avg_duration","bounce_rate"]},"description":"e.g. [\"pageviews\", \"visitors\"]"},"breakdown":{"anyOf":[{"type":"string","enum":["page","event","type","source","referrer","utm_source","utm_medium","utm_campaign","country","device","browser","os","tenant","user","actor","hour","weekday"]},{"type":"string","pattern":"^props\\.([a-zA-Z0-9_]+)$"}],"description":"A fixed field or `props.<key>` (a key of the event payload). `hour` and `weekday` (0 = Sunday) are in `tz`. `utm_*` are first-touch UTMs of the session; `source` is utm_source, else the referrer host, else \"direct\"."},"filters":{"description":"[field, op, value]; `in` takes a list. e.g. [[\"props.role\", \"eq\", \"admin\"]]","type":"array","items":{"type":"array","prefixItems":[{"anyOf":[{"type":"string","enum":["page","event","type","source","referrer","utm_source","utm_medium","utm_campaign","country","device","browser","os","tenant","user","actor","hour","weekday"]},{"type":"string","pattern":"^props\\.([a-zA-Z0-9_]+)$"}],"description":"A fixed field or `props.<key>` (a key of the event payload). `hour` and `weekday` (0 = Sunday) are in `tz`. `utm_*` are first-touch UTMs of the session; `source` is utm_source, else the referrer host, else \"direct\"."},{"type":"string","enum":["eq","neq","in"]},{"anyOf":[{"type":["string","number","boolean","null"]},{"type":"array","items":{"type":["string","number","boolean","null"]}}]}],"items":false,"minItems":3,"maxItems":3}},"granularity":{"description":"Adds a zero-filled time series","type":"string","enum":["hour","day","week","month"]},"compare":{"description":"Also runs the query on another range and returns deltas","anyOf":[{"type":"string","const":"previous_period"},{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]}]},"limit":{"description":"Max breakdown rows (default 100)","type":"integer","minimum":1,"maximum":1000}},"required":["metrics"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"summary":{"type":"string","description":"The totals in one line, for reading aloud"},"current":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"totals":{"type":"object","properties":{"events":{"type":"number"},"pageviews":{"type":"number"},"sessions":{"type":"number"},"visitors":{"type":"number"},"users":{"type":"number"},"avg_duration":{"type":"number"},"bounce_rate":{"type":"number"}},"additionalProperties":false},"rows":{"type":"array","items":{"type":"object","properties":{"events":{"type":"number"},"pageviews":{"type":"number"},"sessions":{"type":"number"},"visitors":{"type":"number"},"users":{"type":"number"},"avg_duration":{"type":"number"},"bounce_rate":{"type":"number"},"key":{"type":["string","number","null"]}},"required":["key"],"additionalProperties":false}},"series":{"type":"array","items":{"type":"object","properties":{"events":{"type":"number"},"pageviews":{"type":"number"},"sessions":{"type":"number"},"visitors":{"type":"number"},"users":{"type":"number"},"avg_duration":{"type":"number"},"bounce_rate":{"type":"number"},"bucket":{"type":"string"}},"required":["bucket"],"additionalProperties":false}}},"required":["from","to","totals"],"additionalProperties":false},"previous":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"totals":{"type":"object","properties":{"events":{"type":"number"},"pageviews":{"type":"number"},"sessions":{"type":"number"},"visitors":{"type":"number"},"users":{"type":"number"},"avg_duration":{"type":"number"},"bounce_rate":{"type":"number"}},"additionalProperties":false},"rows":{"type":"array","items":{"type":"object","properties":{"events":{"type":"number"},"pageviews":{"type":"number"},"sessions":{"type":"number"},"visitors":{"type":"number"},"users":{"type":"number"},"avg_duration":{"type":"number"},"bounce_rate":{"type":"number"},"key":{"type":["string","number","null"]}},"required":["key"],"additionalProperties":false}},"series":{"type":"array","items":{"type":"object","properties":{"events":{"type":"number"},"pageviews":{"type":"number"},"sessions":{"type":"number"},"visitors":{"type":"number"},"users":{"type":"number"},"avg_duration":{"type":"number"},"bounce_rate":{"type":"number"},"bucket":{"type":"string"}},"required":["bucket"],"additionalProperties":false}}},"required":["from","to","totals"],"additionalProperties":false},"delta":{"type":"object","properties":{"events":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false},"pageviews":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false},"sessions":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false},"visitors":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false},"users":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false},"avg_duration":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false},"bounce_rate":{"type":"object","properties":{"abs":{"type":"number"},"pct":{"description":"null when the previous value is 0","type":["number","null"]}},"required":["abs","pct"],"additionalProperties":false}},"additionalProperties":false}},"required":["summary","current"],"additionalProperties":false}}}},"400":{"$ref":"#/components/responses/InvalidInput"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/analytics.sql":{"post":{"operationId":"analytics.sql","tags":["analytics"],"summary":"Run one read-only SELECT or WITH statement over the analytics database, for anything `analytics.query` cannot express.","description":"Not scoped to a project: filter on `project_id = ?` and pass the current project's id (MCP: it's in the server instructions) in `params`. Rows are capped by `limit`.\n\nLuchy analytics database (SQLite / Cloudflare D1). Read-only: one SELECT or WITH statement.\nTimestamps are ISO-8601 UTC strings ('2025-01-31T12:00:00.000Z'); compare them as strings or use datetime()/strftime().\nNot scoped to a project: filter on project_id.\n\nprojects — one tracked site/app\n  id TEXT PK, name TEXT, created_at TEXT\n\nsessions — one visit by one (user, actor) or one anonymous visitor; 30 min idle timeout, 24 h cap\n  id TEXT PK, project_id TEXT -> projects.id\n  visitor_hash TEXT   anonymous browser id (sha256 of a daily salt, project, ip, user agent); rotates daily\n  user TEXT           app-provided user id (signed identity), NULL when anonymous\n  actor TEXT          the real person when impersonating (user = who is viewed as), else NULL\n  started_at TEXT, last_seen_at TEXT   duration = (julianday(last_seen_at) - julianday(started_at)) * 86400 seconds\n  browser TEXT, os TEXT, device TEXT ('desktop' | 'mobile' | 'tablet' | ...), country TEXT (ISO 3166-1 alpha-2), language TEXT\n  entry_page TEXT     pathname of the first event\n  utm_source TEXT, utm_medium TEXT, utm_campaign TEXT   first-touch UTMs of the session, NULL when none\n\nevents — everything that happened, pageviews included\n  id TEXT PK, project_id TEXT -> projects.id, session_id TEXT -> sessions.id\n  type TEXT           'pageview' for pageviews; otherwise sender-defined ('server', 'click', ...) or NULL\n  name TEXT           'pageview' for pageviews; event name otherwise ('__pageleave' marks a page exit)\n  pathname TEXT, referrer TEXT\n  tenant TEXT         the consumer's client/workspace from the signed identity\n  payload TEXT        JSON object of props: json_extract(payload, '$.role')\n                      pageviews carry every utm_* of their URL (utm_source, utm_medium, utm_campaign, utm_term, utm_content)\n  created_at TEXT\n\npeople — person profiles sent via identify; one row per (project, user)\n  project_id TEXT -> projects.id, user TEXT (same id as sessions.user)\n  traits TEXT         JSON object (name, email, ...): json_extract(traits, '$.email')\n  created_at TEXT, updated_at TEXT\n\nConventions\n  visitors = COUNT(DISTINCT coalesce(s.user, s.visitor_hash)); users = COUNT(DISTINCT s.user)\n  source = coalesce(s.utm_source, referrer host without 'www.', 'direct'), lowercased; known sources folded (google.com and utm_source=google → google, ig and l.instagram.com → instagram, …)\n  Join: FROM events e JOIN sessions s ON s.id = e.session_id\n  Profiles: LEFT JOIN people p ON p.project_id = s.project_id AND p.user = s.user\n  Alias duplicate column names: rows are objects keyed by column name.\n\nRequires the `analytics:read` permission.","x-permission":"analytics:read","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"query":{"type":"string","minLength":1,"description":"e.g. SELECT tenant, COUNT(*) AS actions FROM events WHERE project_id = ? GROUP BY tenant"},"params":{"description":"Bound to `?` placeholders in order","type":"array","items":{"type":["string","number","boolean","null"]}},"limit":{"description":"Max rows (default 1000)","type":"integer","minimum":1,"maximum":10000}},"required":["query"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"columns":{"type":"array","items":{"type":"string"}},"rows":{"type":"array","items":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"truncated":{"type":"boolean"}},"required":["columns","rows","truncated"],"additionalProperties":false}}}},"400":{"$ref":"#/components/responses/InvalidInput"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/analytics.people":{"post":{"operationId":"analytics.people","tags":["analytics"],"summary":"Identified people active in the period or the one before, with sessions and actions this period vs the previous one. Quiet people (active before, not now) come last.","description":"Requires the `analytics:read` permission.","x-permission":"analytics:read","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"from":{"description":"Inclusive start (ISO 8601). Default: 7 days before `to`.","type":"string"},"to":{"description":"Exclusive end (ISO 8601). Default: now.","type":"string"},"tz":{"description":"IANA timezone for buckets, hour and weekday. Default UTC.","type":"string"},"tenant":{"description":"Only people acting for this customer","type":"string"},"limit":{"type":"integer","minimum":1,"maximum":1000}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"people":{"type":"array","items":{"type":"object","properties":{"user":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]},"tenant":{"type":["string","null"]},"lastSeen":{"type":"string"},"sessions":{"type":"number"},"actions":{"type":"number"},"pageviews":{"type":"number"},"helpedSessions":{"type":"number"},"previousActions":{"type":"number"},"quiet":{"type":"boolean"}},"required":["user","name","email","tenant","lastSeen","sessions","actions","pageviews","helpedSessions","previousActions","quiet"],"additionalProperties":false}}},"required":["from","to","people"],"additionalProperties":false}}}},"400":{"$ref":"#/components/responses/InvalidInput"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/analytics.customers":{"post":{"operationId":"analytics.customers","tags":["analytics"],"summary":"Customers (the `tenant` of events) active in the period or the one before: people and actions this period vs the previous one.","description":"Requires the `analytics:read` permission.","x-permission":"analytics:read","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"from":{"description":"Inclusive start (ISO 8601). Default: 7 days before `to`.","type":"string"},"to":{"description":"Exclusive end (ISO 8601). Default: now.","type":"string"},"tz":{"description":"IANA timezone for buckets, hour and weekday. Default UTC.","type":"string"},"limit":{"type":"integer","minimum":1,"maximum":1000}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"customers":{"type":"array","items":{"type":"object","properties":{"tenant":{"type":"string"},"people":{"type":"number"},"previousPeople":{"type":"number"},"actions":{"type":"number"},"previousActions":{"type":"number"},"helpedSessions":{"type":"number"},"lastSeen":{"type":"string"},"quiet":{"type":"boolean"}},"required":["tenant","people","previousPeople","actions","previousActions","helpedSessions","lastSeen","quiet"],"additionalProperties":false}}},"required":["from","to","customers"],"additionalProperties":false}}}},"400":{"$ref":"#/components/responses/InvalidInput"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/analytics.person":{"post":{"operationId":"analytics.person","tags":["analytics"],"summary":"One person, all time: profile traits, customers, who acted as them, and their latest sessions with what they did.","description":"Requires the `analytics:read` permission.","x-permission":"analytics:read","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"user":{"type":"string","minLength":1,"description":"The app-provided user id"},"sessions":{"description":"Latest sessions to include (default 30)","type":"integer","minimum":1,"maximum":200}},"required":["user"]}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]},"traits":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":["string","number","boolean"]}},"firstSeen":{"type":"string"},"lastSeen":{"type":"string"},"sessions":{"type":"number"},"actions":{"type":"number"},"tenants":{"type":"array","items":{"type":"object","properties":{"tenant":{"type":"string"},"actions":{"type":"number"}},"required":["tenant","actions"],"additionalProperties":false}},"actors":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","name","email"],"additionalProperties":false}},"timeline":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"startedAt":{"type":"string"},"lastSeenAt":{"type":["string","null"]},"actor":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","name","email"],"additionalProperties":false},{"type":"null"}]},"browser":{"type":["string","null"]},"os":{"type":["string","null"]},"device":{"type":["string","null"]},"country":{"type":["string","null"]},"events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":["string","null"]},"name":{"type":"string"},"pathname":{"type":"string"},"tenant":{"type":["string","null"]},"props":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"createdAt":{"type":"string"}},"required":["id","type","name","pathname","tenant","props","createdAt"],"additionalProperties":false}}},"required":["id","startedAt","lastSeenAt","actor","browser","os","device","country","events"],"additionalProperties":false}}},"required":["user","name","email","traits","firstSeen","lastSeen","sessions","actions","tenants","actors","timeline"],"additionalProperties":false}}}},"400":{"$ref":"#/components/responses/InvalidInput"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/analytics.live":{"post":{"operationId":"analytics.live","tags":["analytics"],"summary":"Who is on the site right now: open sessions with their current page, top pages, countries and referrers.","description":"Requires the `analytics:read` permission.","x-permission":"analytics:read","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"windowMinutes":{"description":"How recent a pageview counts as live (default 5)","type":"integer","minimum":1,"maximum":60}}}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"count":{"type":"number","description":"People on the site right now"},"sessions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"user":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","name","email"],"additionalProperties":false},{"type":"null"}]},"actor":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":["string","null"]}},"required":["id","name","email"],"additionalProperties":false},{"type":"null"}]},"country":{"type":["string","null"]},"browser":{"type":["string","null"]},"os":{"type":["string","null"]},"device":{"type":["string","null"]},"currentPage":{"type":"string"},"referrer":{"type":["string","null"]},"startedAt":{"type":"string"}},"required":["id","user","actor","country","browser","os","device","currentPage","referrer","startedAt"],"additionalProperties":false}},"pages":{"type":"array","items":{"type":"object","properties":{"count":{"type":"number"},"pathname":{"type":"string"}},"required":["count","pathname"],"additionalProperties":false}},"countries":{"type":"array","items":{"type":"object","properties":{"count":{"type":"number"},"country":{"type":"string"}},"required":["count","country"],"additionalProperties":false}},"referrers":{"type":"array","items":{"type":"object","properties":{"count":{"type":"number"},"referrer":{"type":"string"}},"required":["count","referrer"],"additionalProperties":false}}},"required":["count","sessions","pages","countries","referrers"],"additionalProperties":false}}}},"400":{"$ref":"#/components/responses/InvalidInput"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/admin.projects":{"post":{"operationId":"admin.projects","tags":["admin"],"summary":"List every project (tracked site or app) with its id. Use the id as ?project=<id> on /mcp or /api to switch project.","description":"Requires the `admin` permission.","x-permission":"admin","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"createdAt":{"type":"string"}},"required":["id","name","createdAt"],"additionalProperties":false}}},"required":["projects"],"additionalProperties":false}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}},"/api/admin.keys":{"post":{"operationId":"admin.keys","tags":["admin"],"summary":"The current project's ingest keys (public, for the browser script) and its secret key (signs identities, authenticates identify).","description":"Query keys (`wak_…`) live in the willy.im IdP and are managed from the dashboard; they are not listed here.\n\nRequires the `admin` permission.","x-permission":"admin","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"ingestKeys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"createdAt":{"type":"string"}},"required":["id","name","createdAt"],"additionalProperties":false}},"secretKey":{"type":["string","null"]}},"required":["ingestKeys","secretKey"],"additionalProperties":false}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"}}}}},"components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","description":"A luchy API key (`wak_…`) minted in the dashboard under Keys, or a willy.im IdP app token (`wat_…`)."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]},"InvalidInput":{"type":"object","properties":{"error":{"type":"string"},"fields":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["error","fields"]}},"responses":{"InvalidInput":{"description":"The input doesn't match the method's schema. `fields` maps each field to its errors.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvalidInput"}}}},"Unauthorized":{"description":"No credentials, or invalid or expired ones.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"The caller lacks the permission.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No such record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Conflict":{"description":"The record's state doesn't allow this.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}