Stack0 Analytics
Analytics is a hosted app at analytics.stack0.dev. It connects pageviews to signups and payments without a tracking cookie, so every source, page, and campaign gets a revenue number. There is no SDK package to install: a script tag collects from the browser, and one HTTP endpoint takes events from anywhere else.
Get a site key
Sign in at analytics.stack0.dev and add a site. It gives you a site key, which is public and belongs in the snippet. The dashboard also generates a full setup prompt for a coding agent, including a short-lived token that agent uses to verify the install and register goals and funnels. If you are wiring this up with Claude Code or Cursor, paste that prompt rather than this page.
The bare install
<script defer src="https://analytics.stack0.dev/track.js"data-site="YOUR_SITE_KEY"></script>
That tag is the whole install on a site with no server of your own: Webflow, Framer, Squarespace, WordPress, Shopify. Error capture and Core Web Vitals are on by default; turn them off per site with data-errors="off" and data-vitals="off". Both count toward the event allowance.
Anywhere you control the server, relay through your own origin instead. Beacons POST to the Analytics host, whose CORS allowlist names its own origin, so a browser on your domain has its beacon refused by the preflight. The relay is what makes events arrive at all. It also survives ad blockers and keeps visitor geography. A refused preflight looks exactly like "no traffic yet", so confirm a beacon returns 200 in the network tab before you call the install done.
In a Pylon app
This is not the Next.js recipe. /api/fn/* is Pylon's own server-function namespace, so app/api/fn/ingestEvent/route.ts is never matched, and there is no next.config.js to put a rewrite in. The relay is a function.
// functions/ingestEvent.ts — the relay. Name it ingestEvent and track.js// finds it on its own; any other name needs data-endpoint on the tag.import { action } from "@pylonsync/functions";const UPSTREAM = "https://analytics.stack0.dev/api/fn/ingestEvent";// The headers that decide where a visitor is on the map. Forward whichever// set your edge writes — a country alone puts them at its centre, the city// and coordinates put them in their city.const GEO = ["user-agent","x-forwarded-for",// Cloudflare"cf-ipcountry","cf-region-code","cf-ipcity","cf-iplatitude","cf-iplongitude",// Vercel"x-vercel-ip-country","x-vercel-ip-country-region","x-vercel-ip-city","x-vercel-ip-latitude","x-vercel-ip-longitude",];export default action({auth: "public",args: {},async handler(ctx) {const req = ctx.request;if (!req) return {};const headers: Record<string, string> = { "content-type": "application/json" };for (const name of GEO) {const value = req.headers[name];if (value) headers[name] = value;}// rawBody, not args: the beacon carries fields this arg list does not// declare, and they must survive the hop.const res = await fetch(UPSTREAM, { method: "POST", headers, body: req.rawBody });// Verbatim — track.js reads visitorId off this response.return await res.json();},});
Only actions get ctx.request, which is why this is an action and not a mutation. Forward rawBody rather than args: the beacon carries fields your arg list does not declare, and declaring them all would mean chasing every future addition.
Then serve the tracker from your own origin:
// app/rt/track.js/route.ts — serve the tracker first-party, so an ad// blocker that knows the Analytics host does not take it out.import type { RawRouteHandler } from "@pylonsync/react";const UPSTREAM = "https://analytics.stack0.dev/track.js";let cached: string | null = null;export const GET: RawRouteHandler = async () => {if (!cached) cached = await fetch(UPSTREAM).then((r) => r.text());return {body: cached,contentType: "application/javascript; charset=utf-8",headers: { "cache-control": "public, max-age=3600" },};};
// app/layout.tsx<script defer src="/rt/track.js" data-site="YOUR_SITE_KEY" />
Naming the relay ingestEvent lets track.js derive <script origin>/api/fn/ingestEvent by itself. If you name it something else, point at it with data-endpoint, and make sure the value contains /api/fn/ or it is read as an origin rather than a path.
stack0Analytics() is a browser global and Pylon renders on the server, so call it from an event handler or an effect, never during a render. A no-JS <Form> that completes in a route handler has no client code left to run — POST the event from the handler instead.
Building a multi-tenant app? Add the snippet to your own pages only, never to customer-facing tenant pages.
Conversions
Event names take letters, numbers, and _ : -.
// A browser event handler or effect — never during a render.window.stack0Analytics?.("signup");// From a server function, a CLI, or a mobile app.await fetch("https://analytics.stack0.dev/api/fn/ingestEvent", {method: "POST",headers: { "content-type": "application/json" },body: JSON.stringify({site: "YOUR_SITE_KEY",path: "/signup",name: "signup",visitorId: "<stable-anonymous-id>",clientType: "server", // or "cli", "mobile_app"}),});
Never send revenueCents, currency, or customerId through ingestEvent. The site key is public and cannot authenticate money.
Revenue
Point a Stripe webhook for checkout.session.completed and invoice.payment_succeeded at analytics.stack0.dev/api/fn/stripeRevenue, then save its whsec_ signing secret in the site's settings. Payments are verified server-side and land on the same rows as the pageviews.
For first-touch attribution, pass the visitor id as the Checkout session's client_reference_id. Use await stack0Analytics.visitorIdAsync(): the synchronous visitorId() is null until the first beacon answers, which races a deep-linked buy page. Treat it as best-effort and omit it when null. Never block or fail a checkout on analytics.
Native in-app purchases go through RevenueCat, pointed at analytics.stack0.dev/api/fn/revenueCatRevenue. Set RevenueCat's appUserID to the exact id the app reports as its visitor id, or the purchase lands on a different visitor and the funnel never completes.
Goals and funnels
Pageviews alone are not the point. Map the real path from first visit to payment, instrument each step as a named event, then register a goal per conversion and one funnel over the ordered path. Instrument first: a funnel over events that never fire stays empty.
A typical SaaS path is pageview → signup → payment; an ecommerce one is pageview → add_to_cart → checkout_started → payment. Register them from the dashboard, or with the setup token through agentCreateGoal and agentCreateFunnel. Rollups refresh hourly, so counts lag a fresh install.
For agents
analytics.stack0.dev/llms.txt names the host and points at the two machine-readable specs: /openapi.json for the Website API and /mcp.json for the MCP server. The MCP tools cover querying stats, listing the available metrics and dimensions before composing a query, defining funnels, and sending agent traces.
If your product has AI features, send spans for model and tool calls with the visitor id the snippet reports. They land in the same database as the traffic and the revenue, which is what lets you ask whether the people who used the AI feature converted, and what their inference cost was against what they paid. That needs a website API key with the traces:write scope, not the short-lived setup token.
Identity
The default is cookieless: nothing is stored in the browser and visitor hashes rotate daily, so there is no consent banner. Multi-day retention and new-versus-returning need persistent anonymous identity, which you opt into per site. That stores a random site-scoped id in localStorage and adds data-identity="persistent" to the tag, so disclose it in your privacy notice. See the product page for what the dashboard holds.