smolanalytics
menu
log inStart trial
docs · setup

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.

You add smolanalytics to any stack in one of two ways, and both are tiny. For anything that runs in a browser (a plain website, React, Vue, Next.js, or an app built with Lovable/Bolt/v0/Replit) you drop one script tag, smolanalytics.init(key, { host }), and it autocaptures pageviews (including SPA route changes) and clicks; you add track() for the funnel moments and identify(userId) to tie a person's events together. For mobile there are native SDKs, Swift, Kotlin, React Native/Expo, and Flutter, that you initialize in one line and that handle an offline-safe event queue, sessions, and screen() tracking (screens are what power funnels and paths). For everything the browser never sees on the server (webhooks, cron jobs, payments) there is no SDK to install: you POST JSON to one endpoint, POST /v1/events, with an Authorization: Bearer WRITE_KEY header (YOUR_HOST and YOUR_WRITE_KEY are placeholders, your project page shows every snippet with your real host and key already filled in, so you copy, not type) and a body of { name, distinct_id, properties }, or an array of up to 10,000 of them. Because client and server both key off the same distinct_id, a user's mobile events and backend events join into one funnel automatically. Node, Python, Go, Ruby, and PHP are all just that one HTTP call from the client they already use. Then you run npx smolanalytics connect once and your editor's own model gets 97 tools and 15 prompts over MCP: investigate returns the whole desk in one call (findings, causes, costs, quarter movements), backtest replays your history with each finding dated by when it would first have surfaced, and mark_finding_acted records that you acted on a finding so a recovery upgrades it to verified. Two optional feeds make the findings sharper: point your payment provider's webhook at POST /v1/revenue/stripe (or lemonsqueezy, polar, dodo) and findings that involve a revenue metric are priced in dollars, and record deploy markers (one curl in CI, a GitHub Actions step, or the GitHub App) so a metric change is tied to the ship that correlates with it. Every account starts with a 14-day trial at Pro limits, no card, then plans start at $19/mo; every project runs as its own isolated instance and your data exports in one file any time.
the whole idea

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:

the universal ingestion contract
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.

fastest path

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.

terminal
npx smolanalytics init --host https://YOUR_HOST --key YOUR_WRITE_KEY
output
  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.

level 1 · zero build step

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.

index.html
<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>
level 1 · spa

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.

src/analytics.ts (React, Vite / CRA / any SPA)
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 render
Vue: same script in index.html, or Nuxt via nuxt.config.ts
export default defineNuxtConfig({
  app: { head: { script: [
    { src: "https://YOUR_HOST/sdk.js" },
    { children: `smolanalytics.init("YOUR_WRITE_KEY", { host: "https://YOUR_HOST" });` },
  ] } },
});
level 2 · ssr framework

Next.js (App Router or Pages)

Load it with next/script in your root layout. Pageviews and clicks are captured on every route.

app/layout.tsx
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():

app/api/checkout/route.ts
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 } }),
});
level 3 · production saas

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.
server: the revenue events the browser can't see
// 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?"

level 3 · native

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.

iOS (Swift · Swift Package Manager)
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 logout
Android (Kotlin · JitPack)
import 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")
React Native / Expo (npm)
// 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");
Flutter (pub.dev)
// 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.

level 2 · server-side

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.

Node
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 } }),
});
Python
import requests
requests.post(f"{HOST}/v1/events",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"name": "signup", "distinct_id": user_id, "properties": {"plan": "pro"}})
Go
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)
Ruby
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")
PHP
$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);
level 0 · no code at all

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.

no lock-in

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.

the point of all this

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.)

the CLI: one command, then restart your editor
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:

what's my activation rate on iOS vs Android?
what's the signup → checkout conversion, and how long does it take?
did activation improve since we shipped the new onboarding?

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.

what all those events buy you

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:

the outcome ledger
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].

two feeds that sharpen findings

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:

revenue: paste one URL into your processor's dashboard
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.

deploy marker: one curl in your build (write key, same as events)
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.

previews don't pollute production

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:

app/layout.tsx
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.

build on top of it

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.

control plane
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.

provision on someone's behalf
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.

reference

The event contract

fieldrequiredwhat it is
nameyesThe event, e.g. signup, checkout. A $ prefix marks internal web events (pageviews); yours have no prefix.
distinct_idnoWho did it. Use one stable value across web and server so a person's events join. Omit for anonymous counts.
propertiesnoAny 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.

Common questions

Do I need to install an SDK or a package?
On the web, no, it is one script tag, no npm install and no build step. On mobile there are native SDKs (Swift, Kotlin, React Native/Expo, Flutter): one line to initialize, and they handle an offline-safe event queue, sessions, and screen() tracking for you (funnels and paths need screens). For servers, webhooks, and cron there is no SDK to install, you POST JSON to /v1/events with the HTTP client your language already has, which is why a Go service and a Stripe webhook look nearly identical. Every path is the same one ingestion endpoint underneath.
How do browser events and server events end up in the same funnel?
The same distinct_id. Call identify(userId) in the browser and send the same value as distinct_id from your backend, and a user's pageview, their signup on the client, and their payment webhook on the server all join into one person's timeline and one funnel. Pick a stable id (your user id, or a generated id you store in Keychain / SharedPreferences on mobile).
Which key do I use, and is it safe in client code?
There are two, on purpose. The WRITE key is public: it can send events and nothing else, so shipping it in a web page, a mobile binary, or a public repo is safe, it exposes no data and grants no read access. The READ key is secret: it reads your reports and connects your AI over MCP, and it only appears inside your password-protected dashboard, under “connect your coding agent”. Never put the read key in client code. The write key cannot read your data, so a scraped write key leaks nothing.
What are the limits on an event or a batch?
One request is up to 4MB and a batch array is up to 10,000 events; go over and you get a clean 413, nothing is silently dropped. An event needs a name; distinct_id and properties are optional. Batching is the recommended way to save battery and network on mobile: queue events and flush on background or foreground.
I built my app with AI and do not write code. Can I still use this?
Yes. You never open an editor or a terminal. Sign up, copy the one prompt we hand you (your write key already in it), paste it into your builder's chat, and its AI drops the snippet in the right place. There is a page with the exact prompt for Lovable, Bolt, v0, Replit, and Base44.
Is mobile a real integration or a hack?
It is the intended path: ingestion is deliberately one HTTP endpoint so a native app needs no dependency. Track screens as events (track("screen", { name: "Checkout" })) to get screen-flow paths, the mobile equivalent of pageviews, then ask "what's my activation rate on iOS vs Android?" from your editor.
Start the 14-day trial
no credit card · then from $19/mo