{"openapi":"3.1.0","info":{"title":"Statlark API","version":"v1","description":"Read your revenue-first analytics and push conversions, revenue and identity server-side. Authenticate with a bearer token created in Settings → API keys. All revenue is in MAJOR units of the site's configured currency (GET /site), net of refunds; payments in any other currency are excluded from every report, never converted. Every 2xx and every 429 carries the X-RateLimit-* trio; other error statuses carry none. See https://statlark.com/docs/api."},"servers":[{"url":"https://app.statlark.com/api/v1"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"read","description":"Analytics reads (scope: analytics:read)."},{"name":"write","description":"Server-side ingestion (scope: ingest:write)."}],"paths":{"/overview":{"get":{"tags":["read"],"operationId":"getOverview","summary":"Headline metrics with a preceding-period comparison.","description":"Revenue, visitors, paying conversion and revenue per visitor for [from, to), each paired with its value over the immediately preceding equal-length window [from − (to − from), from). That comparison window is implicit and never echoed — compute deltas client-side. No metric is ever null: the RPC's SQL NULLs (a ratio with no denominator) are coerced to 0, so a 0 ratio may mean \"no visitors\" rather than \"0%\".","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}},{"name":"from","in":"query","required":true,"description":"Window start — ISO date (YYYY-MM-DD) or timestamp. UTC, inclusive.","schema":{"type":"string","examples":["2026-06-01"]}},{"name":"to","in":"query","required":true,"description":"Window end — ISO date or timestamp. UTC, exclusive. ≤ 366 days from `from`.","schema":{"type":"string","examples":["2026-07-01"]}}],"responses":{"200":{"description":"Revenue, visitors, paying conversion and revenue/visitor, each with its previous-period value.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OverviewResponse"},"example":{"site":"sl_xxxx","range":{"from":"2026-06-01T00:00:00.000Z","to":"2026-07-01T00:00:00.000Z"},"metrics":{"revenue":4820.5,"previous_revenue":3905,"visitors":12500,"previous_visitors":11000,"paying_conversion":0.02144,"previous_paying_conversion":0.019,"revenue_per_visitor":0.38564,"previous_revenue_per_visitor":0.355}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/timeseries":{"get":{"tags":["read"],"operationId":"getRevenueTimeseries","summary":"Revenue over time, gap-filled into buckets.","description":"One point per bucket, ascending, gap-filled — a bucket with no revenue is present with revenue 0, never omitted, so a 30-day window with bucket=day returns exactly 30 points. Buckets are truncated in the SITE's timezone, so on a non-UTC site the first `bucket_start` precedes `range.from` and a 28-day UTC window can yield 29 daily buckets. The example below is a UTC site.","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}},{"name":"from","in":"query","required":true,"description":"Window start — ISO date (YYYY-MM-DD) or timestamp. UTC, inclusive.","schema":{"type":"string","examples":["2026-06-01"]}},{"name":"to","in":"query","required":true,"description":"Window end — ISO date or timestamp. UTC, exclusive. ≤ 366 days from `from`.","schema":{"type":"string","examples":["2026-07-01"]}},{"name":"bucket","in":"query","required":false,"description":"hour | day (default) | week | month. Validated after auth and the range, so an invalid value still costs a rate-limit token.","schema":{"$ref":"#/components/schemas/Bucket"}}],"responses":{"200":{"description":"Bucketed net revenue, one point per bucket in the window.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeseriesResponse"},"example":{"site":"sl_xxxx","range":{"from":"2026-06-01T00:00:00.000Z","to":"2026-06-04T00:00:00.000Z"},"bucket":"day","points":[{"bucket_start":"2026-06-01T00:00:00+00:00","revenue":149.7},{"bucket_start":"2026-06-02T00:00:00+00:00","revenue":0},{"bucket_start":"2026-06-03T00:00:00+00:00","revenue":99.8}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/sources":{"get":{"tags":["read"],"operationId":"getTrafficSources","summary":"Traffic sources ranked by revenue.","description":"One row per distinct first-touch source, ordered by revenue desc, then visitors desc, then source asc. Uncapped, unpaginated, and with no `Other` bucket (unlike /breakdowns). Visitors with no first-touch row are excluded, so this endpoint's visitor total can be lower than /breakdowns' `totals.visitors`. `?grouping` is validated after auth and the range, so an invalid value still costs a rate-limit token.","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}},{"name":"from","in":"query","required":true,"description":"Window start — ISO date (YYYY-MM-DD) or timestamp. UTC, inclusive.","schema":{"type":"string","examples":["2026-06-01"]}},{"name":"to","in":"query","required":true,"description":"Window end — ISO date or timestamp. UTC, exclusive. ≤ 366 days from `from`.","schema":{"type":"string","examples":["2026-07-01"]}},{"name":"grouping","in":"query","required":false,"description":"channel (default) | utm_source | referrer | adclick.","schema":{"type":"string","enum":["channel","utm_source","referrer","adclick"],"default":"channel"}}],"responses":{"200":{"description":"Sources with visitors, revenue, revenue_per_visitor and paying_conversion.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourcesResponse"},"example":{"site":"sl_xxxx","range":{"from":"2026-06-01T00:00:00.000Z","to":"2026-07-01T00:00:00.000Z"},"grouping":"channel","sources":[{"source":"Organic","visitors":3072,"revenue":1152,"revenue_per_visitor":0.375,"paying_conversion":0.0078125},{"source":"Ads","visitors":1536,"revenue":768,"revenue_per_visitor":0.5,"paying_conversion":0.015625},{"source":"Referral","visitors":768,"revenue":384,"revenue_per_visitor":0.5,"paying_conversion":0.0078125},{"source":"Email","visitors":256,"revenue":128,"revenue_per_visitor":0.5,"paying_conversion":0.015625},{"source":"Social","visitors":512,"revenue":96,"revenue_per_visitor":0.1875,"paying_conversion":0.00390625},{"source":"Direct","visitors":1024,"revenue":0,"revenue_per_visitor":0,"paying_conversion":0}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/breakdowns":{"get":{"tags":["read"],"operationId":"getAudienceBreakdowns","summary":"Audience breakdowns — countries, cities, devices, browsers, hostnames.","description":"Five dimensions, each capped at 10 rows (top 8 + `Unknown` + `Other`). No filters are applied, so every visitor contributes exactly one row to every dimension: each dimension's `visitors` sums to `totals.visitors` and its `revenue` sums to `totals.revenue` — the example satisfies this exactly. There is no dimension-selection, limit or pagination parameter.","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}},{"name":"from","in":"query","required":true,"description":"Window start — ISO date (YYYY-MM-DD) or timestamp. UTC, inclusive.","schema":{"type":"string","examples":["2026-06-01"]}},{"name":"to","in":"query","required":true,"description":"Window end — ISO date or timestamp. UTC, exclusive. ≤ 366 days from `from`.","schema":{"type":"string","examples":["2026-07-01"]}}],"responses":{"200":{"description":"Each dimension as rows of { key, visitors, revenue, revenue_per_visitor }, plus totals.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BreakdownsResponse"},"example":{"site":"sl_xxxx","range":{"from":"2026-06-01T00:00:00.000Z","to":"2026-07-01T00:00:00.000Z"},"totals":{"visitors":6400,"revenue":2480,"identified":512,"customers":48},"countries":[{"key":"US","visitors":3072,"revenue":1536,"revenue_per_visitor":0.5},{"key":"GB","visitors":1024,"revenue":384,"revenue_per_visitor":0.375},{"key":"DE","visitors":512,"revenue":192,"revenue_per_visitor":0.375},{"key":"CA","visitors":384,"revenue":96,"revenue_per_visitor":0.25},{"key":"FR","visitors":256,"revenue":64,"revenue_per_visitor":0.25},{"key":"AU","visitors":192,"revenue":48,"revenue_per_visitor":0.25},{"key":"RO","visitors":128,"revenue":32,"revenue_per_visitor":0.25},{"key":"NL","visitors":96,"revenue":24,"revenue_per_visitor":0.25},{"key":"Unknown","visitors":320,"revenue":0,"revenue_per_visitor":0},{"key":"Other","visitors":416,"revenue":104,"revenue_per_visitor":0.25}],"cities":[{"key":"New York","visitors":1024,"revenue":512,"revenue_per_visitor":0.5},{"key":"London","visitors":768,"revenue":288,"revenue_per_visitor":0.375},{"key":"San Francisco","visitors":512,"revenue":320,"revenue_per_visitor":0.625},{"key":"Berlin","visitors":384,"revenue":96,"revenue_per_visitor":0.25},{"key":"Bucharest","visitors":256,"revenue":112,"revenue_per_visitor":0.4375},{"key":"Toronto","visitors":192,"revenue":48,"revenue_per_visitor":0.25},{"key":"Amsterdam","visitors":128,"revenue":32,"revenue_per_visitor":0.25},{"key":"Sydney","visitors":96,"revenue":24,"revenue_per_visitor":0.25},{"key":"Unknown","visitors":2304,"revenue":864,"revenue_per_visitor":0.375},{"key":"Other","visitors":736,"revenue":184,"revenue_per_visitor":0.25}],"devices":[{"key":"desktop","visitors":3584,"revenue":1344,"revenue_per_visitor":0.375},{"key":"mobile","visitors":2560,"revenue":1024,"revenue_per_visitor":0.4},{"key":"tablet","visitors":224,"revenue":112,"revenue_per_visitor":0.5},{"key":"bot","visitors":24,"revenue":0,"revenue_per_visitor":0},{"key":"unknown","visitors":8,"revenue":0,"revenue_per_visitor":0}],"browsers":[{"key":"Chrome","visitors":3200,"revenue":1200,"revenue_per_visitor":0.375},{"key":"Safari","visitors":2048,"revenue":896,"revenue_per_visitor":0.4375},{"key":"Firefox","visitors":640,"revenue":240,"revenue_per_visitor":0.375},{"key":"Edge","visitors":448,"revenue":112,"revenue_per_visitor":0.25},{"key":"Unknown","visitors":64,"revenue":32,"revenue_per_visitor":0.5}],"hostnames":[{"key":"example.com","visitors":5120,"revenue":2080,"revenue_per_visitor":0.40625},{"key":"app.example.com","visitors":1024,"revenue":384,"revenue_per_visitor":0.375},{"key":"blog.example.com","visitors":256,"revenue":16,"revenue_per_visitor":0.0625}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/pages":{"get":{"tags":["read"],"operationId":"listPages","summary":"Pages ranked by pageviews, with landing-attributed revenue.","description":"One row per distinct pageview path, ordered by pageviews desc, then revenue desc, then path asc. Uncapped and unpaginated. `revenue` is LANDING-attributed — the net revenue of visitors whose frozen first-touch landing path is this page, not revenue earned on the page — so a page can carry revenue with `entries: 0`. `revenue_per_visitor` divides that by LANDING visitors, not by the row's `visitors`; it is the only nullable field in this response, and the only nullable metric anywhere in the read surface (every other ratio and revenue figure is coerced to 0).","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}},{"name":"from","in":"query","required":true,"description":"Window start — ISO date (YYYY-MM-DD) or timestamp. UTC, inclusive.","schema":{"type":"string","examples":["2026-06-01"]}},{"name":"to","in":"query","required":true,"description":"Window end — ISO date or timestamp. UTC, exclusive. ≤ 366 days from `from`.","schema":{"type":"string","examples":["2026-07-01"]}},{"name":"entry_only","in":"query","required":false,"description":"true restricts to entry (landing) pages. Matched as the exact lowercase string `entry_only=true` — `1`, `TRUE` and `yes` silently mean false, with no error.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Pages with pageviews, visitors, entries, revenue and revenue_per_visitor.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PagesResponse"},"example":{"site":"sl_xxxx","range":{"from":"2026-06-01T00:00:00.000Z","to":"2026-07-01T00:00:00.000Z"},"entry_only":false,"pages":[{"path":"/pricing","pageviews":4100,"visitors":2600,"entries":900,"revenue":1899.5,"revenue_per_visitor":1.938265306122449},{"path":"/blog/revenue-attribution","pageviews":2380,"visitors":1740,"entries":1310,"revenue":412.25,"revenue_per_visitor":0.2903169014084507},{"path":"/docs/api","pageviews":960,"visitors":610,"entries":0,"revenue":0,"revenue_per_visitor":null}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/goals":{"get":{"tags":["read"],"operationId":"listGoals","summary":"List goal names, or one goal's conversions over time + by entry page.","description":"Two response shapes, chosen by `?goal`. LIST branch (`?goal` absent, empty or whitespace-only — never a 400): every goal name ever recorded for the site. It is range-free — `?from`/`?to`/`?bucket` are accepted and ignored, and the list spans all time. ANALYSIS branch (`?goal` non-empty): requires `?from` and `?to`, and returns the goal's gap-filled timeseries plus `conversions_by_landing`. Two traps on the analysis branch: `conversions_by_landing` is NOT scoped to the goal — it lists every range-active visitor's landing path and the goal only affects the conversion columns, so an unknown goal name returns the same rows with all zeros; and it is UNCAPPED, with raw `ft_landing_path` values including query strings, so one logical page appears many times and a low-traffic site can return hundreds of rows.","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}},{"name":"goal","in":"query","required":false,"description":"A goal name. Omit (or send blank) to list all goals.","schema":{"type":"string","examples":["signup"]}},{"name":"from","in":"query","required":false,"description":"Required when `goal` is set, ignored otherwise. See /overview.","schema":{"type":"string","examples":["2026-06-01"]}},{"name":"to","in":"query","required":false,"description":"Required when `goal` is set, ignored otherwise. See /overview.","schema":{"type":"string","examples":["2026-06-04"]}},{"name":"bucket","in":"query","required":false,"description":"hour | day (default) | week | month. Ignored on the list branch.","schema":{"$ref":"#/components/schemas/Bucket"}}],"responses":{"200":{"description":"Either the site's goal names, or one goal's timeseries + conversions_by_landing.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GoalsResponse"},"examples":{"goalList":{"summary":"List branch — ?goal omitted, empty or whitespace-only","value":{"site":"sl_xxxx","goals":["signup","video_cta_click"]}},"goalAnalysis":{"summary":"Analysis branch — ?goal=signup on a UTC site","value":{"site":"sl_xxxx","goal":"signup","range":{"from":"2026-06-01T00:00:00.000Z","to":"2026-06-04T00:00:00.000Z"},"bucket":"day","timeseries":[{"bucket_start":"2026-06-01T00:00:00+00:00","conversions":18},{"bucket_start":"2026-06-02T00:00:00+00:00","conversions":9},{"bucket_start":"2026-06-03T00:00:00+00:00","conversions":13}],"conversions_by_landing":[{"landing_path":"/pricing","landing_visitors":900,"conversions":34,"conversion_rate":0.03777777777777778},{"landing_path":"/?utm_source=toolify","landing_visitors":240,"conversions":6,"conversion_rate":0.025},{"landing_path":"/blog/revenue-attribution","landing_visitors":612,"conversions":0,"conversion_rate":0}]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["write"],"operationId":"recordGoalConversion","summary":"Record a goal conversion server-side (scope: ingest:write).","description":"NOT IDEMPOTENT — a plain insert with no unique key, so a retried webhook records a SECOND conversion. Dedupe on your side. (The sibling POST /payments *is* idempotent; don't carry that assumption over.) CANNOT BACKDATE — there is no `occurred_at`, so the event lands at `now()`; a replay a day late falls in today's bucket. An unrecognised `visitor_id` is SILENTLY CREATED with every first-touch column null, and first touch is frozen on insert — that visitor is then invisible to /sources and `conversions_by_landing` forever. Only send an id captured client-side with `statlark.getVisitorId()`. There is no `?site`: writes always target the token's own site.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordGoalRequest"},"example":{"visitor_id":"5daeab41-5a9d-4b76-9d03-fd268adc1e12","name":"signup","props":{"method":"password","plan":"pro","seats":5,"trial":true},"session_id":"53b87e45-e6ca-4f4a-8cf2-ab117eee1e6d"}}}},"responses":{"200":{"description":"Recorded. A fixed acknowledgement — no event id, and nothing distinguishing a first conversion from a duplicate.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteAck"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/realtime":{"get":{"tags":["read"],"operationId":"getRealtimeSnapshot","summary":"The right-now snapshot: active visitors + recent events. No range.","description":"`active_visitors` counts distinct visitors in the last 5 minutes; `recent_events` covers the last 30 minutes, newest first, HARD-CAPPED AT 50 with no pagination, cursor or truncation flag — never treat the array as complete. `?from`/`?to` are ignored. An unknown site id returns 403, not 404.","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}}],"responses":{"200":{"description":"active_visitors and recent_events (last 30 min, newest first, ≤ 50).","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RealtimeResponse"},"example":{"site":"sl_xxxx","active_visitors":7,"recent_events":[{"occurred_at":"2026-07-01T12:04:11.812431+00:00","type":"goal","name":"signup","path":"/sign-up","country":"US","region":"CA","city":"San Francisco","channel":"Organic","visitor_id":"b1e8f0a2-4c77-4b3e-9a51-2f0d6c8ea310"},{"occurred_at":"2026-07-01T12:03:58.204915+00:00","type":"pageview","name":null,"path":"/pricing","country":"GB","region":"ENG","city":"London","channel":"Ads","visitor_id":"7d41c6ba-9e02-42f1-8f77-1c3b5a9d4e60"},{"occurred_at":"2026-07-01T12:02:44.51+00:00","type":"identify","name":null,"path":null,"country":"RO","region":"B","city":"Bucharest","channel":"Direct","visitor_id":"161a5609-6402-4024-a5c0-8aaf443538d9"},{"occurred_at":"2026-07-01T12:01:07+00:00","type":"identify","name":null,"path":null,"country":null,"region":null,"city":null,"channel":"Direct","visitor_id":"b1e8f0a2-4c77-4b3e-9a51-2f0d6c8ea310"}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/site":{"get":{"tags":["read"],"operationId":"getSiteMetadata","summary":"Site metadata: domain, timezone, currency, #1 KPI goal. No range.","description":"The configuration every other endpoint is expressed in: `currency` is the unit of all revenue, `timezone` is what day/week/month buckets are truncated in. `?from`/`?to` are neither required nor read — sending them is silently ignored. Requires `analytics:read` despite returning configuration rather than analytics.","parameters":[{"name":"site","in":"query","required":true,"description":"Your public site id (sl_…), the same value on the tracker snippet.","schema":{"$ref":"#/components/schemas/SiteId"}}],"responses":{"200":{"description":"Site configuration.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SiteResponse"},"example":{"site":"sl_xxxx","domain":"example.com","timezone":"America/New_York","currency":"USD","primary_kpi_goal":"signup"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/payments":{"post":{"tags":["write"],"operationId":"recordPayment","summary":"Record (or refund) revenue server-side (scope: ingest:write).","description":"Two branches in one operation, chosen by the mere PRESENCE of `refunded_amount` — and the check runs before `amount` is read, so a body carrying both takes the REFUND path and `amount` is silently discarded. (An explicit `refunded_amount: null` falls through to create.) CREATE is idempotent on (site, transaction_id): a duplicate returns `created: false` and updates NOTHING. REFUND sets a CUMULATIVE total, then clamps it — `least(amount, greatest(stored, requested))` — so the echoed `refunded_amount` can differ from what you sent, and 404s when no payment carries that `transaction_id`. CURRENCY TRAP: `currency` is upper-cased and must match /^[A-Z]{3}$/, but a valid currency that is not the SITE's currency still returns 200 `created: true` while being silently excluded from every revenue rollup and from LTV. There is no `?site`: writes always target the token's own site.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordPaymentRequest"},"examples":{"create":{"summary":"Create — a Stripe charge","value":{"transaction_id":"ch_3QxYZ1AbCdEf2GhI","amount":49.99,"currency":"USD","visitor_id":"b1e8f0a2-3c4d-4e5f-8a9b-0c1d2e3f4a5b","session_id":"s_9f2c1a7b4d3e5f60","customer_email":"jo@example.com","provider":"stripe","occurred_at":"2026-07-02T14:31:00Z","raw":{"event_id":"evt_1QxYZ1AbCdEf2GhI","object":"charge","livemode":true}}},"refund":{"summary":"Refund — cumulative amount against an existing sale","value":{"transaction_id":"ch_3QxYZ1AbCdEf2GhI","refunded_amount":12.34,"status":"partially_refunded"}}}}}},"responses":{"200":{"description":"Recorded. The create branch returns { ok, created }; the refund branch returns the recomputed amounts.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentWriteResponse"},"examples":{"created":{"summary":"Create branch","value":{"ok":true,"created":true}},"refunded":{"summary":"Refund branch — note the clamped refunded_amount and the derived status","value":{"ok":true,"matched":true,"transaction_id":"ch_3QxYZ1AbCdEf2GhI","amount":49.99,"refunded_amount":12.34,"net_amount":37.65,"status":"partially_refunded"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["write"],"operationId":"deletePayment","summary":"Delete one recorded payment by transaction_id (scope: ingest:write).","description":"This DELETE CARRIES A JSON BODY — generated clients routinely drop it, and a bodyless DELETE is a 400 (not a 405). Hard-deletes the payment and recomputes the owning visitor's AND profile's lifetime value, so unlike a refund the sale also drops out of paying-visitor and conversion counts. Not idempotent in the observable sense: the first call returns 200, a repeat returns 404.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletePaymentRequest"},"example":{"transaction_id":"ch_3QxYZ1AbCdEf2GhI"}}}},"responses":{"200":{"description":"Deleted. `deleted` is a constant true — branch on the HTTP status, not on the field.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentDeleted"},"example":{"ok":true,"deleted":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/identify":{"post":{"tags":["write"],"operationId":"identifyVisitor","summary":"Attach identity (user_id / user_email / user_name) to a visitor (scope: ingest:write).","description":"PATCH semantics with a sharp edge: presence is tested with `in`, so an OMITTED key is left untouched but a key sent explicitly as `null`, `\"\"` or any non-string CLEARS the stored value. `traits` is REPLACED, not merged. `user_email` is lower-cased and otherwise unvalidated. An unrecognised `visitor_id` is silently created rather than 404'd — a typo returns 200 and creates a ghost visitor. The resolved profile id, the merge outcome and the recomputed LTV are all computed server-side but NOT returned. Each call also appends an `identify` event that shows up in GET /realtime. There is no `?site`: writes always target the token's own site.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdentifyRequest"},"example":{"visitor_id":"b1e8f0a2-4c77-4b3e-9a51-2f0d6c8ea310","user_id":"user_123","user_email":"jo@example.com","user_name":"Jo Rivera","user_image":"https://example.com/avatars/jo.png","traits":{"plan":"pro","mrr":49,"trial":false},"session_id":"3f7a1c94-2b6e-4a58-9d21-8c05e7bd4419"}}}},"responses":{"200":{"description":"Recorded. Acknowledgement only.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WriteAck"},"example":{"ok":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A Statlark API token (slk_…) from Settings → API keys."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"SiteId":{"type":"string","description":"Your public site id (sl_…), the same value on the tracker snippet. Read responses echo it back exactly as supplied in ?site (whitespace-trimmed).","examples":["sl_xxxx"]},"Range":{"type":"object","description":"The normalized half-open window [from, to) actually applied. Both values are re-serialized by the API with JavaScript `toISOString()`, so they always carry milliseconds and a `Z` suffix however you supplied them (a bare YYYY-MM-DD is pinned to UTC midnight). This is deliberately a different rendering from `bucket_start`, which Postgres emits with a numeric `+00:00` offset.","required":["from","to"],"properties":{"from":{"type":"string","format":"date-time","description":"Window start, inclusive (occurred_at >= from).","examples":["2026-06-01T00:00:00.000Z"]},"to":{"type":"string","format":"date-time","description":"Window end, exclusive (occurred_at < to).","examples":["2026-07-01T00:00:00.000Z"]}}},"Bucket":{"type":"string","enum":["hour","day","week","month"],"default":"day","description":"Bucket granularity, defaulting to `day`. Buckets are truncated in the SITE's timezone (GET /site), not UTC; week buckets start on Monday."},"BucketStart":{"type":"string","format":"date-time","description":"Start of the bucket as an absolute instant, rendered by Postgres with a numeric offset (`2026-06-01T00:00:00+00:00`) — NOT the `…000Z` millisecond form used by `range.from`/`range.to`. Buckets are truncated in the SITE's timezone and then converted to UTC, so on a non-UTC site the instant carries the site's offset (a Europe/Bucharest day for 2026-06-01 is `2026-05-31T21:00:00+00:00`), the first bucket can PRECEDE `range.from`, and a 28-day UTC window can yield 29 daily buckets.","examples":["2026-06-01T00:00:00+00:00"]},"Revenue":{"type":"number","description":"Revenue in MAJOR units of the site's configured currency (GET /site) — 4820.5, not 482050. Always NET of refunds (`payments.net_amount`). Payments recorded in any other currency are excluded entirely, never FX-converted."},"Ratio":{"type":"number","minimum":0,"maximum":1,"description":"A fraction in [0,1] at full float precision — 0.02144 is 2.144%, not 2.144. Never rounded server-side."},"BreakdownRow":{"type":"object","required":["key","visitors","revenue","revenue_per_visitor"],"properties":{"key":{"type":"string","description":"The dimension value, or the literal `Unknown` (no value recorded) / `Other` (everything outside the top 8). Never null."},"visitors":{"type":"integer","minimum":0,"description":"Distinct visitors in the window with this value."},"revenue":{"$ref":"#/components/schemas/Revenue","description":"Net revenue attributed to those visitors."},"revenue_per_visitor":{"type":"number","description":"revenue / visitors, computed by the API. 0 — never null — when visitors is 0. (Unlike /pages, this field is never null and its denominator really is the row's own `visitors`.)"}}},"BreakdownDimension":{"type":"array","maxItems":10,"description":"At most 8 named rows ranked by (visitors desc, revenue desc, key asc), then a single `Unknown` row aggregating visitors with no value, then a single `Other` row aggregating rank 9+ — 10 rows maximum. `Unknown` and `Other` always sort last, in that order, even when they carry more visitors than the ranked rows. `Other` appears only when more than 8 distinct values exist, so whenever it is present exactly 8 named rows precede it; either extra row is omitted when empty.","items":{"$ref":"#/components/schemas/BreakdownRow"}},"Props":{"type":"object","description":"Free-form metadata, SANITIZED SILENTLY AND LOSSILY: at most 20 UNIQUE keys, keys truncated to 64 chars, string values truncated to 500 chars, and only string / finite-number / boolean values survive — null, arrays, nested objects, NaN and Infinity are DROPPED without an error, and a non-object value becomes `{}`. Losing data this way still returns 200.","additionalProperties":{"type":["string","number","boolean"]}},"WriteAck":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true,"description":"Always true. Failures surface as a non-2xx status with an `{ error }` body instead."}}},"OverviewResponse":{"type":"object","required":["site","range","metrics"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"range":{"$ref":"#/components/schemas/Range"},"metrics":{"type":["object","null"],"description":"Headline metrics for the window, each paired with its value over the immediately preceding equal-length period. `null` is defensive only — the underlying RPC always returns exactly one row, so a token that can read the site will not observe it.","required":["revenue","previous_revenue","visitors","previous_visitors","paying_conversion","previous_paying_conversion","revenue_per_visitor","previous_revenue_per_visitor"],"properties":{"revenue":{"$ref":"#/components/schemas/Revenue","description":"Net revenue in the window."},"previous_revenue":{"$ref":"#/components/schemas/Revenue","description":"Same measure over the preceding equal-length period."},"visitors":{"type":"integer","minimum":0,"description":"Distinct visitors with at least one event in the window."},"previous_visitors":{"type":"integer","minimum":0,"description":"Distinct visitors over the preceding equal-length period."},"paying_conversion":{"$ref":"#/components/schemas/Ratio","description":"Paying visitors / visitors. Emitted as 0 when visitors is 0 (the RPC's SQL NULL is coerced to 0), so 0 is ambiguous between a true 0% and an undefined ratio — check `visitors` to disambiguate."},"previous_paying_conversion":{"$ref":"#/components/schemas/Ratio","description":"Same ratio over the preceding equal-length period; 0 when previous_visitors is 0."},"revenue_per_visitor":{"type":"number","description":"revenue / visitors, in major units. Emitted as 0 (never null) when visitors is 0."},"previous_revenue_per_visitor":{"type":"number","description":"Same measure over the preceding equal-length period; 0 when previous_visitors is 0."}}}}},"TimeseriesResponse":{"type":"object","required":["site","range","bucket","points"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"range":{"$ref":"#/components/schemas/Range"},"bucket":{"$ref":"#/components/schemas/Bucket","description":"The granularity actually used, echoed from ?bucket."},"points":{"type":"array","description":"One point per bucket, ascending by bucket_start, gap-filled — a bucket with no revenue is present with revenue 0, never omitted. Always at least one element for a valid window.","items":{"type":"object","required":["bucket_start","revenue"],"properties":{"bucket_start":{"$ref":"#/components/schemas/BucketStart"},"revenue":{"$ref":"#/components/schemas/Revenue","description":"Net revenue attributed to this bucket. 0 for an empty bucket; never null."}}}}}},"SourcesResponse":{"type":"object","required":["site","range","grouping","sources"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"range":{"$ref":"#/components/schemas/Range"},"grouping":{"type":"string","enum":["channel","utm_source","referrer","adclick"],"description":"The grouping applied. `channel` when ?grouping was omitted."},"sources":{"type":"array","description":"One row per distinct source key, ordered by revenue desc, then visitors desc, then source asc. Not paginated and not capped. Empty when no visitor in the window has a first-touch record.","items":{"type":"object","required":["source","visitors","revenue","revenue_per_visitor","paying_conversion"],"properties":{"source":{"type":"string","description":"The source key, never null. Two of the four groupings are closed sets: `channel` is one of Ads, Email, Social, Organic, Referral, Direct; `adclick` is one of 'Google Ads', 'Meta Ads', 'Microsoft Ads', '(none)'. `utm_source` and `referrer` are open-ended raw first-touch values with '(direct)' as the fallback."},"visitors":{"type":"integer","minimum":0,"description":"Distinct visitors with an event in the window whose first touch maps to this source."},"revenue":{"$ref":"#/components/schemas/Revenue","description":"Revenue attributed to those visitors."},"revenue_per_visitor":{"type":"number","description":"revenue / visitors. 0 (never null) when the source produced no revenue."},"paying_conversion":{"$ref":"#/components/schemas/Ratio","description":"Distinct paying visitors / distinct visitors for this source."}}}}}},"BreakdownsResponse":{"type":"object","required":["site","range","totals","countries","cities","devices","browsers","hostnames"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"range":{"$ref":"#/components/schemas/Range"},"totals":{"type":"object","description":"Window totals for the whole audience. Every dimension's rows sum exactly to totals.visitors and totals.revenue.","required":["visitors","revenue","identified","customers"],"properties":{"visitors":{"type":"integer","minimum":0,"description":"Distinct visitors with at least one event in the window. Counted with a LEFT JOIN to the visitors table, so a visitor with no first-touch row is still included here (and lands in `Unknown`) — unlike /sources, which drops them."},"revenue":{"$ref":"#/components/schemas/Revenue","description":"Revenue from those visitors for payments inside the window."},"identified":{"type":"integer","minimum":0,"description":"Of those visitors, how many are identified (a user id or email is attached)."},"customers":{"type":"integer","minimum":0,"description":"Of those visitors, how many have ever paid."}}},"countries":{"$ref":"#/components/schemas/BreakdownDimension","description":"First-touch country as an ISO 3166-1 alpha-2 code (e.g. 'US'), from edge geo."},"cities":{"$ref":"#/components/schemas/BreakdownDimension","description":"First-touch city display name (e.g. 'New York')."},"devices":{"$ref":"#/components/schemas/BreakdownDimension","description":"First-touch device class, always lower-case: desktop, mobile, tablet, bot, unknown. Note the lower-case `unknown` (empty or unparseable user agent) is a DIFFERENT row from the capitalised `Unknown` no-value bucket, and both can appear in the same array."},"browsers":{"$ref":"#/components/schemas/BreakdownDimension","description":"First-touch browser family, capitalised brand names — Chrome, Safari, Firefox, Edge, Opera, Samsung Internet, plus in-app browsers such as Instagram, Facebook, TikTok, X."},"hostnames":{"$ref":"#/components/schemas/BreakdownDimension","description":"First-touch hostname the visitor landed on, i.e. the subdomain breakdown (e.g. 'app.example.com')."}}},"PagesResponse":{"type":"object","required":["site","range","entry_only","pages"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"range":{"$ref":"#/components/schemas/Range"},"entry_only":{"type":"boolean","description":"Whether the result was restricted to entry (landing) pages. True only when ?entry_only was the exact string `true`."},"pages":{"type":"array","description":"One row per distinct pageview path in the window, ordered by pageviews desc, then revenue desc, then path asc. Not paginated — every qualifying path is returned. Empty when the site has no pageviews in the window.","items":{"type":"object","required":["path","pageviews","visitors","entries","revenue","revenue_per_visitor"],"properties":{"path":{"type":"string","description":"URL path of the page. Never null — events with a null path are excluded.","examples":["/pricing"]},"pageviews":{"type":"integer","minimum":1,"description":"Total pageview events on this path in the window."},"visitors":{"type":"integer","minimum":1,"description":"Distinct visitors who viewed this path in the window."},"entries":{"type":"integer","minimum":0,"description":"Sessions whose first pageview in the window was this path. 0 is possible unless entry_only=true."},"revenue":{"$ref":"#/components/schemas/Revenue","description":"LANDING-attributed: the net revenue of visitors whose frozen first-touch landing path is this page — NOT revenue earned on this page — for payments inside the window. So a page can carry revenue with `entries: 0`. 0, never null, when nothing is attributed."},"revenue_per_visitor":{"type":["number","null"],"description":"`revenue` divided by the number of range-active visitors whose first-touch landing path is this page — a DIFFERENT denominator from this row's `visitors`, so do not reverse-engineer it as revenue/visitors. `null` (not 0) when this page is no range-active visitor's landing path. Full division precision, never rounded."}}}}}},"GoalListResponse":{"type":"object","description":"Returned when ?goal is absent, empty or whitespace-only. Range-free: every distinct goal name ever recorded for the site, not limited by ?from/?to.","required":["site","goals"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"goals":{"type":"array","description":"Distinct goal names on this site, ascending. Empty array when the site has never recorded a goal. Uncapped.","items":{"type":"string"},"examples":[["signup","video_cta_click"]]}}},"GoalAnalysisResponse":{"type":"object","description":"Returned when ?goal is a non-empty string. This branch requires ?from and ?to.","required":["site","goal","range","bucket","timeseries","conversions_by_landing"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"goal":{"type":"string","description":"The ?goal value, trimmed, echoed back. Never validated against the goal list — an unknown name is not an error.","examples":["signup"]},"range":{"$ref":"#/components/schemas/Range"},"bucket":{"$ref":"#/components/schemas/Bucket","description":"The granularity actually used, echoed from ?bucket."},"timeseries":{"type":"array","description":"Gap-filled conversion counts per bucket, ascending by bucket_start.","items":{"type":"object","required":["bucket_start","conversions"],"properties":{"bucket_start":{"$ref":"#/components/schemas/BucketStart"},"conversions":{"type":"integer","minimum":0,"description":"DISTINCT visitors who fired this goal in the bucket — not the raw event count, so a visitor converting twice counts once. Zero-filled: every bucket in the window is present."}}}},"conversions_by_landing":{"type":"array","description":"One row per distinct frozen first-touch landing path among visitors active in the window, ordered by conversions desc, then landing_visitors desc, then landing_path asc. NOT scoped to the goal — the row set is every landing page in the window and the goal only affects the conversion columns, so an unknown goal name returns the same rows with all zeros. UNCAPPED: a low-traffic site can return hundreds of rows, most with conversions 0.","items":{"type":"object","required":["landing_path","landing_visitors","conversions","conversion_rate"],"properties":{"landing_path":{"type":"string","description":"The visitor's frozen first-touch landing path, verbatim as captured — QUERY STRINGS INCLUDED (e.g. '/?utm_source=toolify', or a path carrying a full gclid). Not normalised or grouped, so one logical page appears many times. Never null: visitors with no landing path are excluded from this breakdown.","examples":["/pricing"]},"landing_visitors":{"type":"integer","minimum":1,"description":"Distinct visitors active in the window whose frozen first-touch landing path is this path. Always at least 1 — a path with no landing visitors produces no row."},"conversions":{"type":"integer","minimum":0,"description":"Distinct visitors among those landing_visitors who fired this goal within the window."},"conversion_rate":{"$ref":"#/components/schemas/Ratio","description":"conversions / landing_visitors. Never null."}}}}}},"GoalsResponse":{"description":"One of two shapes, selected by whether a non-empty ?goal was supplied. The branches are disjoint on their required keys: the list branch has `goals` and no `goal`/`range`/`bucket`.","oneOf":[{"$ref":"#/components/schemas/GoalListResponse"},{"$ref":"#/components/schemas/GoalAnalysisResponse"}]},"RealtimeEvent":{"type":"object","required":["occurred_at","type","name","path","country","region","city","channel","visitor_id"],"properties":{"occurred_at":{"type":"string","format":"date-time","description":"When the event happened, always UTC. Serialized by Postgres inside the jsonb payload, so it uses the `+00:00` offset form with up to microsecond precision and trailing zeros trimmed (`2026-07-01T12:04:11.812431+00:00`, or `2026-07-01T12:04:11+00:00` on a whole second) — NOT the `…Z` millisecond form used elsewhere in this API.","examples":["2026-07-01T12:04:11.812431+00:00"]},"type":{"type":"string","enum":["pageview","goal","identify","event"],"description":"Event kind. Enforced by a CHECK constraint, so no other value can appear."},"name":{"type":["string","null"],"description":"Goal or custom-event name. In practice set for `goal` and `event` and null for `pageview` and `identify`, but nothing enforces that — treat it as nullable for every type."},"path":{"type":["string","null"],"description":"URL path the event fired on. Null whenever the hit carried no URL — that covers BOTH server-side writes (POST /identify, POST /goals) AND client-collected hits with no page context, notably identify() from the React Native SDK, which is the common case. A null path does NOT imply a server-side write: use the geo fields to tell them apart, since edge-collected hits carry country/region/city and write-API rows never do."},"country":{"type":["string","null"],"description":"ISO 3166-1 alpha-2 country code (e.g. 'US'), stamped from edge geo at collection time. Null for write-API events and when edge geo is unavailable."},"region":{"type":["string","null"],"description":"ISO 3166-2 subdivision CODE, not a display name — 'CA' for California, 'ENG' for England, 'B' for Bucharest. Null when unavailable."},"city":{"type":["string","null"],"description":"City display name, percent-decoded at collection time (e.g. 'San Francisco'). Null when unavailable."},"channel":{"type":"string","enum":["Ads","Email","Social","Organic","Referral","Direct"],"description":"Marketing channel derived at read time from that event's own source/medium/gclid/fbclid/msclkid/referrer. Never null — the classifier falls back to 'Direct'."},"visitor_id":{"type":"string","description":"Pseudonymous first-party visitor id. The web tracker mints a UUID (what `statlark.getVisitorId()` returns), but the column is free-form text — a server-side POST /identify can create a visitor under any non-empty string."}}},"RealtimeResponse":{"type":"object","description":"The right-now snapshot for one site. No range — always computed against now().","required":["site","active_visitors","recent_events"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"active_visitors":{"type":"integer","minimum":0,"description":"Distinct visitors with at least one event in the last 5 minutes. 0 when the site is idle."},"recent_events":{"type":"array","maxItems":50,"description":"The site's events from the last 30 minutes, newest first, HARD-CAPPED AT 50. Empty when nothing happened in that window. There is no pagination, cursor, filter or truncation flag — this is not 'all' recent events.","items":{"$ref":"#/components/schemas/RealtimeEvent"}}}},"SiteResponse":{"type":"object","required":["site","domain","timezone","currency","primary_kpi_goal"],"properties":{"site":{"$ref":"#/components/schemas/SiteId"},"domain":{"type":"string","description":"The site's registered domain.","examples":["example.com"]},"timezone":{"type":"string","description":"IANA timezone the site reports in — what day/week/month buckets are truncated in everywhere else in the API. Defaults to 'UTC'.","examples":["America/New_York","UTC"]},"currency":{"type":"string","description":"ISO 4217 code every revenue figure in this API is reported in, as major units. Payments recorded in any other currency are excluded from revenue, never converted. Defaults to 'USD'.","examples":["USD"]},"primary_kpi_goal":{"type":["string","null"],"description":"Name of the goal treated as the site's #1 KPI. Null means revenue is the #1 KPI. The only nullable field in this response.","examples":["signup"]}}},"RecordPaymentRequest":{"description":"Create body: transaction_id + amount (+ optional metadata). Refund body: transaction_id + refunded_amount (+ optional status). Sending `refunded_amount` alongside `amount` takes the refund branch and discards `amount`.","anyOf":[{"$ref":"#/components/schemas/CreatePaymentRequest"},{"$ref":"#/components/schemas/RefundPaymentRequest"}]},"RefundPaymentRequest":{"type":"object","description":"Refund branch — selected by the mere PRESENCE of `refunded_amount`. Any create-only field sent alongside it is ignored.","required":["transaction_id","refunded_amount"],"properties":{"transaction_id":{"$ref":"#/components/schemas/TransactionId","description":"The sale to refund. 404 if no payment on this site carries it."},"refunded_amount":{"type":"number","description":"CUMULATIVE (the total ever refunded on this transaction, like Stripe's amount_refunded), not an increment, and clamped to least(amount, greatest(stored, requested)) — so the echoed value can differ from what you sent."},"status":{"type":"string","enum":["refunded","partially_refunded","disputed"],"description":"Optional refund status; derived from the amounts when omitted. Note `succeeded` is rejected here (400) but CAN appear in the response."}}},"CreatePaymentRequest":{"type":"object","description":"Create branch — used when `refunded_amount` is absent (or explicitly null). Idempotent on (site, transaction_id).","required":["transaction_id","amount"],"properties":{"transaction_id":{"$ref":"#/components/schemas/TransactionId","description":"Your provider's id for the sale, and the idempotency key: a duplicate returns created: false and updates nothing."},"amount":{"type":"number","description":"Gross amount in MAJOR units (49.99, not 4999)."},"currency":{"type":"string","description":"ISO 4217, upper-cased server-side; must match /^[A-Z]{3}$/ or 400. Defaults to USD. A valid code that is not the SITE's currency is accepted with 200 but excluded from every revenue figure and from LTV.","default":"USD"},"visitor_id":{"type":"string","description":"Optional. Omit and the revenue is recorded but attributed to nobody; an unknown id records the payment and silently attributes nothing. Capture it client-side with `statlark.getVisitorId()`."},"session_id":{"type":"string","description":"Optional session id to correlate with the visit."},"customer_email":{"type":"string","description":"Optional. Lower-cased before storage; the key that stitches this sale to a person across devices."},"provider":{"type":"string","description":"Optional source label, e.g. 'stripe'. Defaults to `api` over this endpoint.","default":"api"},"occurred_at":{"type":"string","description":"Optional ISO date (pinned to UTC midnight) or timestamp; unparseable is a 400. Omitted means now().","examples":["2026-07-02T14:31:00Z"]},"raw":{"type":"object","description":"Optional provider payload, stored as jsonb. Must be a JSON OBJECT — an array or scalar is a 400 — and at most 16384 bytes once serialized."}}},"TransactionId":{"type":"string","description":"Your payment provider's id for the sale, unique within your site.","examples":["ch_3QxYZ1AbCdEf2GhI"]},"DeletePaymentRequest":{"type":"object","description":"Sent as the body of a DELETE — a bodyless request is a 400, not a 405. Extra fields are ignored.","required":["transaction_id"],"properties":{"transaction_id":{"$ref":"#/components/schemas/TransactionId","description":"The payment to hard-delete, scoped to the token's site."}}},"RecordGoalRequest":{"type":"object","required":["visitor_id","name"],"properties":{"visitor_id":{"type":"string","description":"The visitor who converted. Trimmed; blank is a 400. An unknown id is silently CREATED with no first-touch data — only send an id captured client-side with `statlark.getVisitorId()`."},"name":{"type":"string","description":"Goal name. Trimmed; blank is a 400.","examples":["signup"]},"props":{"$ref":"#/components/schemas/Props","description":"Optional metadata attached to the conversion event."},"session_id":{"type":"string","description":"Optional session id to correlate with the visit."}}},"IdentifyRequest":{"type":"object","description":"PATCH semantics keyed on key presence: an OMITTED key is left untouched, a key sent as null / \"\" / a non-string CLEARS the stored value.","required":["visitor_id"],"properties":{"visitor_id":{"type":"string","description":"The visitor to identify. Trimmed; blank is a 400. An unknown id is silently created."},"user_id":{"type":"string","description":"Your own user id."},"user_email":{"type":"string","description":"Lower-cased before storage. Not validated — any string is stored."},"user_name":{"type":"string","description":"Display name."},"user_image":{"type":"string","description":"Avatar URL."},"traits":{"$ref":"#/components/schemas/Props","description":"REPLACES the stored traits — this is not a merge."},"session_id":{"type":"string","description":"Optional session id, attached to the identify event."}}},"PaymentCreated":{"type":"object","description":"Create branch — a body without `refunded_amount`.","required":["ok","created"],"properties":{"ok":{"type":"boolean","const":true,"description":"Always true on a 200."},"created":{"type":"boolean","description":"true when this call inserted a new payment; false when (site, transaction_id) already existed — an idempotent duplicate, in which case NOTHING was updated (amount, currency, status and raw keep their original values)."}}},"RefundRecorded":{"type":"object","description":"Refund branch — a body with `refunded_amount`.","required":["ok","matched","transaction_id","amount","refunded_amount","net_amount","status"],"properties":{"ok":{"type":"boolean","const":true,"description":"Always true on a 200."},"matched":{"type":"boolean","const":true,"description":"Always true on a 200 — a transaction_id matching no payment is a 404, never `matched: false`."},"transaction_id":{"type":"string","description":"The transaction that was matched (your trimmed value)."},"amount":{"type":"number","description":"The original gross amount of the sale, in major units. Unchanged by the refund."},"refunded_amount":{"type":"number","description":"The cumulative amount now refunded, AFTER clamping to least(amount, greatest(previously_stored, requested)) — so it can be lower than you sent (over-refund capped) or higher (a larger refund already on file)."},"net_amount":{"type":"number","description":"amount − refunded_amount, in major units. This is what every revenue rollup and LTV now counts for this sale."},"status":{"type":"string","enum":["succeeded","refunded","partially_refunded","disputed"],"description":"The payment's status after the refund: the value you supplied, or derived — `refunded` when amount > 0 and refunded_amount >= amount, `partially_refunded` when refunded_amount > 0, otherwise the row's existing status. That last case is why `succeeded` can appear here even though the request rejects it."}}},"PaymentWriteResponse":{"description":"One of two shapes, chosen by the request body. The branches are disjoint on their required keys: only the create branch has `created`, only the refund branch has `matched`.","oneOf":[{"$ref":"#/components/schemas/PaymentCreated"},{"$ref":"#/components/schemas/RefundRecorded"}]},"PaymentDeleted":{"type":"object","required":["ok","deleted"],"properties":{"ok":{"type":"boolean","const":true,"description":"Always true on a 200."},"deleted":{"type":"boolean","const":true,"description":"Always true on a 200. A missing payment is a 404, so `deleted: false` is never emitted — branch on the HTTP status, not on this field."}}}},"responses":{"BadRequest":{"description":"Invalid request. Query-param causes: a missing ?site, a missing or unparseable ?from/?to, from >= to, a window wider than 366 days, or an invalid ?bucket / ?grouping. Body causes on the write endpoints: `Request body must be valid JSON.`, `Request body must be a JSON object.`, a missing required field, or a bad amount / currency / occurred_at / raw.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing, malformed or revoked token. The same 401 is returned for an absent or malformed `Authorization: Bearer` header and for an unknown or revoked token — deliberately not distinguished.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"The token may not do this. On reads: it lacks the `analytics:read` scope, or it cannot read the requested ?site — an unknown site id and another tenant's site id return the SAME 403 by design, so this status never discloses whether a site exists. On writes: it lacks `ingest:write`, or it is account-scoped and writing requires a website-scoped token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"The addressed record does not exist. On POST /payments (refund branch) and DELETE /payments, no payment on this site carries that transaction_id — so a refund or delete is not idempotent in the observable sense: the first call returns 200, a repeat returns 404. On GET /site, the site row was deleted between authorization and the read (a race). This status never discloses whether a site id exists: an unknown or forbidden ?site returns 403, not 404.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Rate limit exceeded — either the pre-auth per-IP bucket (600 burst / 10 rps) or the post-auth per-token bucket (300 burst / 5 rps). The X-RateLimit-* values describe whichever bucket was charged, with nothing to tell them apart.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Burst capacity of the bucket that was charged: 300 for the per-token bucket. A pre-auth per-IP 429 reports the IP bucket instead (600), not your token quota."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Whole tokens left in that bucket after this request."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Seconds until that bucket is fully refilled."},"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"InternalError":{"description":"An unexpected server-side failure — the token lookup or the underlying query failed. The body is `{ error }` with a short, non-diagnostic message; retry is safe for reads. Reachable on every operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}