Add it to anything. One snippet, or one endpoint.
Every stack and every level of complexity, with exact copy-paste code: a website, React, Vue, Next.js, a multi-tenant SaaS, iOS and Android, any backend language, and no-code builders. Then connect your AI, let it instrument the events that matter, and let the investigation run.
There is one endpoint. Everything else is a convenience on top.
Getting data in is deliberately small. In a browser, a script gives you autocapture and helpers. Everywhere else, you send events with one HTTP call, no dependency to add or keep updated:
POST https://YOUR_HOST/v1/events
Authorization: Bearer YOUR_WRITE_KEY
Content-Type: application/json
{ "name": "checkout", "distinct_id": "user_123", "properties": { "amount": 29, "plan": "pro" } }That is the entire API for sending data. A single event or an array of up to 10,000. The write key is write-only, so it is safe in client code. Keep the distinct_id stable and identical across web and server, and a person's events join into one funnel on their own.
One command
It works out what your project is, edits the one file that needs editing, and tells you which file before it touches it.
npx smolanalytics init --host https://YOUR_HOST --key YOUR_WRITE_KEY
detected Next.js (App Router) file app/layout.tsx edited app/layout.tsx edited .env.local
It edits for you on Next.js (both routers), SvelteKit, Vite, Create React App and plain HTML. On Nuxt and Astro it prints the install and changes nothing, because neither installs as a script tag in an HTML file and a generic snippet there gives you a page that looks instrumented and sends nothing.
Running it twice is safe: the second run sees the tracker already there and leaves everything alone. If it can't find a safe place to insert, it prints the snippet rather than guessing.
A website or web app
Paste this into your <head>. It captures pageviews and clicks immediately; add track() for the moments you want funnels on.
<script src="https://YOUR_HOST/sdk.js"></script>
<script>
smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });
// the moments that matter (optional, but this is what powers funnels)
smolanalytics.identify("user_123"); // ties a person's events together
smolanalytics.track("signup", { plan: "pro" });
smolanalytics.track("activate"); // your core aha moment
</script>React or Vue
Same script, loaded once from your entry file so it is bundled with the app. SPA route changes (React Router, TanStack, Vue Router) are captured automatically, no per-route wiring.
export function initAnalytics() {
if (document.getElementById("smol")) return;
const s = document.createElement("script");
s.id = "smol"; s.src = "https://YOUR_HOST/sdk.js";
s.onload = () => (window as any).smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });
document.head.appendChild(s);
}
// call initAnalytics() once in src/main.tsx, before renderexport default defineNuxtConfig({
app: { head: { script: [
{ src: "https://YOUR_HOST/sdk.js" },
{ children: `smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });` },
] } },
});Next.js (App Router or Pages)
Load it with next/script in your root layout. Pageviews and clicks are captured on every route.
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<Script src="https://YOUR_HOST/sdk.js" strategy="afterInteractive" />
<Script id="smol-init" strategy="afterInteractive">
{`smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });`}
</Script>
</html>
);
}Server-side events (a Route Handler, a Server Action, a Stripe webhook) post straight to the endpoint with the same distinct_id you pass to identify():
await fetch(`${process.env.SMOLANALYTICS_HOST}/v1/events`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.SMOLANALYTICS_KEY}` },
body: JSON.stringify({ name: "checkout", distinct_id: userId, properties: { amount: 29 } }),
});A multi-tenant SaaS
A real SaaS emits events from two places, and smolanalytics is built for exactly that split. The browser sends product usage; your server sends the things the browser never sees (payments, provisioning, webhooks, cron). Three rules make it clean:
- 1One identity everywhere. Call identify(userId) in the browser and send that same distinct_id from the server. Client and server events fuse into one funnel per user.
- 2Sites are never the meter. Every event is stamped with its site by the SDK, so all your surfaces (marketing site, app, docs) live on one instance and one bill. You are billed on events, not on how many sites or how many users you have.
- 3Hard isolation when you need it. Each project is its own isolated instance (its own server and data). Keep a big customer's data fully separate by giving them their own instance; Pro includes 2, Scale 10.
// Stripe webhook, billing cron, or provisioning job, any backend language.
await fetch(`${SMOL_HOST}/v1/events`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${SMOL_KEY}` },
body: JSON.stringify({
name: "subscription_started",
distinct_id: user.id, // the SAME id you identify() in the app
properties: { plan: "pro", mrr: 29, seats: 3 },
}),
});Now the funnel from a marketing pageview, to signup in the app, to a payment on your server is one connected path, and you ask it directly: "what's the signup to paid conversion, and how long does it take?"
Mobile apps (iOS, Android, React Native, Flutter)
Native SDKs, one line to initialize. Each handles an offline-safe event queue, sessions, device context, and screen() tracking (screens power funnels and paths). Call identify() on login to tie a person's events together; the write key is public (send-only), safe to ship.
import SmolAnalytics
// once, in your App / AppDelegate
SmolAnalytics.initialize(writeKey: "WRITE_KEY", host: "https://YOUR-INSTANCE")
SmolAnalytics.track("signup", ["plan": "pro"])
SmolAnalytics.screen("Checkout") // screens power funnels + paths
SmolAnalytics.identify("user-123") // on login; reset() on logoutimport com.smolanalytics.SmolAnalytics
// once, in Application.onCreate()
SmolAnalytics.initialize(this, "WRITE_KEY", "https://YOUR-INSTANCE")
SmolAnalytics.track("signup", mapOf("plan" to "pro"))
SmolAnalytics.screen("Checkout")
SmolAnalytics.identify("user-123")// npm install smolanalytics-react-native
import smol from "smolanalytics-react-native";
smol.init("WRITE_KEY", { host: "https://YOUR-INSTANCE" });
smol.track("signup", { plan: "pro" });
smol.screen("Checkout");
smol.identify("user-123");// pubspec.yaml: smolanalytics: ^0.1.0
import 'package:smolanalytics/smolanalytics.dart';
Smolanalytics.init("WRITE_KEY", host: "https://YOUR-INSTANCE");
Smolanalytics.track("signup", {"plan": "pro"});
Smolanalytics.screen("Checkout");
Smolanalytics.identify("user-123");No dependency? Every SDK is a thin wrapper over one call, so you can also POST JSON to /v1/events with the toolkit's own HTTP client (URLSession, OkHttp, fetch, http) and batch up to 10,000 events per array to save battery.
Any backend language
The events the browser never sees (payments, webhooks, cron jobs, API usage) post from any language. Same distinct_id as the client so they join up.
await fetch(`${process.env.SMOL_HOST}/v1/events`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.SMOL_KEY}` },
body: JSON.stringify({ name: "checkout", distinct_id: userId, properties: { amount: 29 } }),
});import requests
requests.post(f"{HOST}/v1/events",
headers={"Authorization": f"Bearer {KEY}"},
json={"name": "signup", "distinct_id": user_id, "properties": {"plan": "pro"}})body, _ := json.Marshal(map[string]any{"name": "checkout", "distinct_id": userID, "properties": map[string]any{"amount": 29}})
req, _ := http.NewRequest("POST", host+"/v1/events", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)require "net/http"; require "json"
uri = URI("#{HOST}/v1/events")
Net::HTTP.post(uri, { name: "signup", distinct_id: user_id }.to_json,
"Authorization" => "Bearer #{KEY}", "Content-Type" => "application/json")$ch = curl_init("$host/v1/events");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $key", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["name" => "checkout", "distinct_id" => $userId]),
]);
curl_exec($ch);Built with an AI builder
If you built your app with Lovable, Bolt, v0, Replit, or Base44 and you do not write code, you never open an editor. Sign up, and we hand you one prompt with your write key already in it. Paste it into your builder's chat and its AI drops the snippet in the right place for that platform.
Your data stays yours
Every project runs as its own isolated instance: its own server and its own data, never a shard of a shared cluster. Nothing phones home, and everything you collect exports in one file any time, so leaving is a download, not a migration project. That portability is the deal: you pay for the product, and the data is always yours to walk away with.
Prefer to kick the tyres with real data first? The live demo is a populated instance, no install, and every account starts with a 14-day trial at Pro limits, no card.
Your agent instruments it, then you just ask
On the hosted cloud it is one connection for your whole org: paste your organization's MCP token (from Settings, pointed at smolanalytics.com/api/mcp) into your editor once. That single connection provisions new analytics with create_project and reaches any project by passing project="<name>" to any tool, with the read key kept server-side. Prefer the CLI? One command wires smolanalytics into every coding assistant, pointed at your instance. Then let your agent do the setup: run the instrument-my-app prompt and it drops the snippet, reads your code, wires your real signup / checkout events at the right call-sites, and proves they fire, you never hand-write tracking. (Prefer to do it yourself? The script tag and POST endpoint above are the manual path.)
npx smolanalytics connect # wires up Cursor, Claude Code, VS Code, Windsurf, …
After that, your editor's own model answers from your real data, your model, so the AI part costs nothing. Ask, in the same window you write code:
Your AI assistant admits it can hallucinate your numbers. This one can't. Answers come from the same deterministic reports the dashboard renders (97 MCP tools, 15 prompts). The three worth knowing by name: investigate (the whole desk in one call), backtest (replay your history, each finding dated by when it would first have surfaced), and mark_finding_acted (tell it you acted, and it reports back when the metric recovers). There is also a dashboard ask bar for plain-English questions about your data. Which surface is which is explained on how it works.
The desk, and the loop that closes
The dashboard does not open on a wall of charts. It opens on the desk: the single most expensive finding up top, a queue of everything else the investigation found (each with a status: needs you, watch, fix first, recovered, verified, acted, auto-reverted), the quarter's metric movements with multiple-comparison correction so a noisy quarter can't fake a story, and a note when your product is still below the detection floor rather than a pretend finding. The classic reports (funnels, retention, paths, cohorts and the rest) are all there, behind a menu, when you want to dig.
The queue is not just a list, it closes. When you fix something, mark the finding acted: the button on the desk, the mark_finding_acted tool from your editor, or one call from anywhere:
POST https://YOUR_HOST/v1/findings/acted
Authorization: Bearer YOUR_READ_KEY
{ "fingerprint": "<from the finding>", "note": "shipped fix in a1b2c3d" }When the metric then recovers, the finding upgrades to verified: you acted on a date, and the metric recovered within N days. If you acted and it is still down, it says that instead. A regression that recovers on its own retires itself as recovered (never "fixed", no causality claimed), and a guardrailed flag that gets auto-reverted shows the receipt on the desk. The daily brief lands in Slack or Discord with the same tags: [verified], [acted], [recovered], [needs you].
Revenue webhooks and deploy markers
Both optional, both self-serve, both change what a finding says. Point your payment provider's webhook at your instance and a finding whose metric carries an amount is priced in dollars instead of people:
POST https://YOUR_HOST/v1/revenue/stripe # or lemonsqueezy · polar · dodo
Pass your distinct_id as the checkout reference so payments join the same person as their product events. Only findings on a revenue-bearing metric get a dollar figure; nothing else is dressed up in money.
Deploy markers tie a metric change to the ship that correlates with it (correlation, not proof, and the copy says so). One line in CI, a GitHub Actions step, the GitHub App on the cloud, or nothing at all: flipping a feature flag is recorded as a ship automatically.
curl -X POST https://YOUR_HOST/v1/deploys \
-H "Authorization: Bearer YOUR_WRITE_KEY" \
-d '{"sha": "'$(git rev-parse HEAD)'", "message": "'"$(git log -1 --pretty=%s)"'"}'Every integration that exists (payment providers, deploy markers, Slack and Discord delivery, importers from PostHog/Mixpanel/Amplitude/Umami, Search Console) is on the integrations page.
Environments
Every event is stamped with an env, and every report hides anything that isn't production by default. You don't configure this: localhost, private network addresses, dev tunnels (ngrok, cloudflared), Netlify deploy previews and staging./preview./qa. subdomains are detected and kept out of your real numbers.
Detection is deliberately cautious, because hiding real traffic is far worse than showing a little preview traffic. There is no blanket *.vercel.app rule, since plenty of production sites live there with no custom domain. On Vercel, pass the environment through and it wins over any guess:
smolanalytics.init("YOUR_WRITE_KEY", {
host: "https://YOUR_HOST",
env: process.env.NEXT_PUBLIC_VERCEL_ENV, // "production" | "preview" | "development"
});To look at hidden traffic, add ?env=preview (or development) to the dashboard, or filter on env anywhere. Nothing is discarded at ingest; it is all stored, just scoped out of the default view.
Set anything you like with env. The values hidden by default are development, preview, staging, test and ci; an unrecognised value stays visible, so a typo can never make your production numbers disappear.
The API, and embedding smolanalytics in your own product
Every core operation is available over plain HTTP, authenticated with your org API token from Settings. No SDK, no meeting, no partnership form. If you ship a boilerplate, a template, or an app builder, this is everything you need to put analytics in it.
GET /api/v1/projects list your projects, plan and trial state POST /api/v1/projects create one (instant, instances are pre-warmed) GET /api/v1/projects/:id DELETE /api/v1/projects/:id tears down the instance, then the record
Responses never include the secret read key. Only the public write key, which is ingest-only and cannot read your data.
Set someone up before they have an account
This is the one worth knowing about. You can provision a working instance for a user who has never heard of us, hand them a live write key immediately, and let them claim ownership later. Signup stops being step one of using your product.
curl -X POST https://smolanalytics.com/api/v1/claimable \
-H "Authorization: Bearer YOUR_ORG_TOKEN" \
-d '{"name": "their-app"}'
{
"project_id": "prj_...",
"instance_url": "https://....fly.dev",
"write_key": "sa_...", // works immediately, put it in their app
"claim_url": "https://smolanalytics.com/claim?p=...&t=...",
"expires_at": "2026-07-29T..."
}Send them the claim_url whenever you like. Events flow from the moment the write key is wired in, and whoever opens that link takes ownership of the project and everything already collected. Links are single-use and expire (24 hours by default, up to 7 days via hours). An agent that already has a token can skip the browser entirely with POST /api/v1/claimable/accept.
The claim link is shown once. We store only a hash of it, so if you lose one, mint another rather than looking the old one up.
The event contract
| field | required | what it is |
|---|---|---|
| name | yes | The event, e.g. signup, checkout. A $ prefix marks internal web events (pageviews); yours have no prefix. |
| distinct_id | no | Who did it. Use one stable value across web and server so a person's events join. Omit for anonymous counts. |
| properties | no | Any JSON object: plan, amount, source. What you break funnels and cohorts down by. |
- Auth: Authorization: Bearer YOUR_WRITE_KEY. Write-only, safe in client code.
- Batch: POST an array of up to 10,000 events in one request (max 4MB). Over the cap returns a clean 413.
- Endpoint: POST /v1/events on your instance host. That is the entire write API.
- Full API + all 97 tools: see every feature and the API doc.