# Tinylytics > Tinylytics is a privacy-first analytics platform for small websites and personal projects. ## Homepage - [Homepage](https://tinylytics.app/) - [Sign Up](https://tinylytics.app/auth/sign_up) - [Sign In](https://tinylytics.app/auth/sign_in) ## Updates - [Changelog](https://tinylytics.app/changelog) - [SaaS changelog](https://tinylytics.app/changelog/saas) ## Documentation Hub - [Docs](https://tinylytics.app/docs) ## Documentation (Full Text) ### Sites and account Source: https://tinylytics.app/docs/account # Sites and account This section covers the settings around your data rather than the data itself: how a site is configured, who can see it, how several sites are grouped together, and how billing works. A useful distinction to hold on to as you read: settings that belong to a **site** live under that site's Settings, and settings that belong to **you** — your timezone, your billing, your team — live under Account in the top navigation. When something is not where you expected it, that is usually why. <%= render "docs/section_landing", section_path: "/docs/account" %> ### Billing Source: https://tinylytics.app/docs/account/billing # Billing When your 14-day trial ends you choose a plan under **Account → Billing**. There are three, and the thing to understand about them is what they do *not* change: the analytics are identical on all of them. | Plan | Sites | Monthly | Yearly | | --- | --- | --- | --- | | Zen | 2 | $7 | $70 | | Pro | 20 | $14 | $140 | | Ultra | 50 | $29 | $290 | Analytics, uptime monitoring, insights, the API, webhooks, and unlimited data retention are included on every plan. You are paying for how many sites you track, not for permission to see your own numbers. Usage is fair-use rather than metered, and we only get in touch [above a million hits a month](https://tinylytics.app/docs/terms#fair-usage-policy). If you need more sites than Ultra allows, email hello@tinylytics.app and we will sort something out. Ultra adds the features that only make sense once other people are involved or once you are running a lot of sites: teams, groups, traffic spike and collection dropoff alerts, extra report recipients, custom uptime check intervals, spell checking, Ask on the dashboard, and a token for private public-stats pages. Some long-standing accounts are still on classic prices, or on a free plan we no longer sell. Those arrangements stand — see [why we dropped the free plan](https://tinylytics.app/docs/why_free_plan) for the reasoning. <%= render "docs/next_steps", pages: [ { label: "Create an account", path: "/docs/getting-started/account" }, { label: "Reports and alerts", path: "/docs/monitoring/reports" } ] %> ### Groups Source: https://tinylytics.app/docs/account/groups # Groups A group is a collection of whole sites with a shared dashboard. It is the answer to "how are all my client blogs doing?" — one chart, one set of tables, every site rolled together. Groups are an Ultra feature. It is worth separating groups from [segments](https://tinylytics.app/docs/analytics/segments), because they solve mirror-image problems. A group combines several sites into one view. A segment divides one site into several views. If the things you want to compare live on different domains, you want a group; if they live under different paths on the same domain, you want a segment. ## Creating one From the sites list, open **Groups** and press **New Group**. You give it a name, an optional description, an icon or an image, an accent colour, and the sites to include. You also pick the default view: **All sites (separate)** keeps each site as its own line, which is what you want when you are comparing them, while **All sites (combined)** adds them into a single total. You can pin a group to your dashboard so it is the first thing you see. The group dashboard has the same shape as a site's — chart, paths, journeys, and the rest — just rolled up across the members. One permission detail that surprises people: team **Admins** cannot manage groups. The account **Owner** can. See [teams](https://tinylytics.app/docs/account/teams) for the full division of labour. <%= render "docs/next_steps", pages: [ { label: "Segments", path: "/docs/analytics/segments" }, { label: "Teams", path: "/docs/account/teams" } ] %> ### Import Source: https://tinylytics.app/docs/account/import # Import If you are arriving from Plausible, you can bring your history with you rather than starting from an empty chart. Go to **Settings → Data → Import**, choose **Plausible Analytics** as the provider, and drop in the export folder or the individual files. Two files are required: - `imported_visitors.csv` - `imported_pages.csv` And one is optional: `imported_sources.csv`. Stay on the page until it tells you **Import complete**. ## What actually arrives This is the part to read before you import, because a Plausible export is not a copy of your raw traffic — it is a set of daily aggregates, and only so much can be reconstructed from them. Every imported hit is stamped at noon on its day, since the export does not record the hour. Unique hits are rebuilt from the daily totals rather than counted. Sources are distributed statistically when that CSV is present, which gets the proportions right without pretending to know which specific visit came from where. Browser, operating system, and country arrive empty, because they are not in the export at all. The user agent on every imported hit reads `Imported via Plausible`, so you can always tell imported history from data Tinylytics collected itself. Imports do not fire [webhooks](https://tinylytics.app/docs/developer/webhooks). They are history, not events, and your integrations should not wake up because you moved analytics providers. <%= render "docs/next_steps", pages: [ { label: "Export", path: "/docs/analytics/export" }, { label: "Reading Overview", path: "/docs/analytics/overview" } ] %> ### Public stats Source: https://tinylytics.app/docs/account/public-stats # Public stats Publishing your analytics is a small act of good faith, and plenty of people who run personal sites like doing it. A public stats page gives your site's numbers their own URL that anyone can read, with no account and nothing installed. Turn it on under **Settings → Public & Webring** with **Enable public stats page**. The live URL appears on that same screen. One thing to know before you share it widely: the link is disposable by design. Switching public stats off breaks the current URL, and switching it back on mints a **new** one. That is deliberate — it means revoking access genuinely revokes it — but it also means an old bookmark will not start working again later. Paid plans add three options on the same screen: **Remove branding**, **Show uptime on stats page**, and **Allow filtering on stats page**, which lets visitors change the date range rather than only seeing your default view. ## Sharing privately Ultra accounts, along with some grandfathered ones, can set a **Public token**, which turns the page into something you can share with a client or a colleague without publishing it to the world. Append `?token=YOUR_TOKEN` to the URL for anyone who should see it. You can still view the page yourself while signed in, and clearing the field and saving removes the protection. The interface calls this a token rather than a passcode, in case you are looking for that word. Webring membership lives on the same screen — see [webring](https://tinylytics.app/docs/widgets/webring). <%= render "docs/next_steps", pages: [ { label: "Webring", path: "/docs/widgets/webring" }, { label: "Teams", path: "/docs/account/teams" } ] %> ### Site settings Source: https://tinylytics.app/docs/account/site-settings # Site settings **Settings** is the hub for everything about one site. If you are looking for something and it is not on the dashboard, it is here. ## General General holds the site's name, its domain, and **Allow on any domain**. The domain is the important one. Tinylytics serves the embed script only to the hostname you configured, which is why a mismatch between that setting and the address you actually serve is the most common reason [hits do not show up](https://tinylytics.app/docs/troubleshooting/no-hits). Turn on **Allow on any domain** when you genuinely serve one site from several hostnames. Timezone is not here, because it is not a property of a site. It lives on your account, and it decides where the day breaks on every chart, what "this week" means, and when the weekly email arrives. ## Appearance **Settings → Appearance** is where you make the dashboard yours: accent colour, chart type (column or line), **Hide detailed stats**, and **Show chart**. **Site Overview Display**, on paid plans, controls the summary cards above the chart — Lifetime Hits, Traffic Graph, and Uptime Status. **Dashboard Tabs** lets you drag Insights, Uptime, Kudos, Community, Content, and Events into the order you use them in. **Stats Sections** does the same for the blocks under the chart: Goals, Paths, Top pages (all time), User journeys, Sessions, World map, Countries, Browsers, Platforms, Referrers, Sources, AI agents, and Kudos. Journeys and sessions require a paid plan, and Goals only appears on Overview once you have defined at least one [goal](https://tinylytics.app/docs/analytics/goals). This screen is worth ten minutes: a dashboard trimmed to what you actually read is more useful than one showing everything available. ## Pausing and archiving Two different ways to stop, for two different reasons. **Pause** stops collection and keeps every hit you already have. Use it when a site is temporarily off, or when you would rather not record a period at all. **Archive** takes the site out of your daily list without deleting anything. Use it for a site you have finished with but do not want to lose. Both live under Settings, and neither throws data away. The rest of the hub is the embed snippet, [public stats](https://tinylytics.app/docs/account/public-stats), [ignore rules](https://tinylytics.app/docs/analytics/ignore-hits), and Data Hub, which collects segments, goals, spam review, import, and export in one place. <%= render "docs/next_steps", pages: [ { label: "The dashboard", path: "/docs/getting-started/dashboard" }, { label: "Public stats", path: "/docs/account/public-stats" } ] %> ### Teams Source: https://tinylytics.app/docs/account/teams # Teams Teams are an Ultra feature, and there are two separate ways to let somebody in. They exist as two things rather than one because the two situations are genuinely different: bringing a colleague into everything you track is not the same as showing a client their own numbers. ## Account-wide access **Account → Team Access** invites people to the whole account, with one of three roles: - **Viewer** sees analytics and nothing else. - **Admin** can create and manage sites, but not billing, groups, or team settings. - **Owner** can run the team, groups included, but not billing and not deletion of the account. Enter an email address, pick the role, and press **Send invite**. Invitations expire after seven days. If an Ultra subscription lapses, existing members stay listed but become **suspended** rather than being removed — so nothing is lost, and everyone comes back when the subscription does. You can also set a **Team name**, which is what people see in the account switcher and in the invitation email. Worth setting before you invite anyone. ## One site, read-only **Settings → Collaborators** on a site invites someone to that site alone, view-only. They can read the dashboard; every setting stays with you. It is also Ultra, also expires after seven days, and has no role picker, because there is only one thing the role could be. This is the option for clients and for anyone who needs a number rather than a login to your account. Accounts created from an invitation cannot create sites of their own. They work inside the owner's team, which is what makes them safe to hand out. <%= render "docs/next_steps", pages: [ { label: "Groups", path: "/docs/account/groups" }, { label: "Billing", path: "/docs/account/billing" } ] %> ### Instructions for agents Source: https://tinylytics.app/docs/agent_signup # Instructions for agents This page is for you if you are an AI agent setting Tinylytics up on behalf of a person. The division of labour is deliberate and not negotiable: they own the account, you start the signup, and they choose a password in their own browser. Their 14-day trial begins when they activate. Only then do you connect over [MCP](https://tinylytics.app/docs/mcp). So: do not fill in the [sign-up form](https://tinylytics.app/auth/sign_up), do not invent a password, and do not collect a card. The signup API never returns an API key, by design — there is no shortcut here that leaves the human out of the loop. ## 1. Start signup `POST https://tinylytics.app/api/v1/agent_signups` with the owner's email and `tos_accepted: true`. ```bash curl -X POST "https://tinylytics.app/api/v1/agent_signups" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "email": "owner@example.com", "agent_name": "Grok", "agent_client": "grok", "tos_accepted": true }' ``` A `201` response includes `owner_activation_url`, `status_url`, `mcp_url`, and trial details. The 14-day Pro trial starts when the owner activates. `site_url` is optional. It does not create a site. If the email already has an account, you get `existing_account` and no activation URL. Ask the owner to sign in and connect MCP. ## 2. Hand them the activation link Give the owner `owner_activation_url`. They open it in a browser, choose a password, and activate. You wait. Do not fill that form yourself. Tinylytics also emails them the same link. ## 3. Poll until they activate `GET` `status_url` every 15 seconds. Cap that at about 15 minutes, then back off. Send the token as `X-Agent-Signup-Token`. `?token=` also works, but the header stays out of access logs. ```bash curl "https://tinylytics.app/api/v1/agent_signups/ags_…" \ -H "Accept: application/json" \ -H "X-Agent-Signup-Token: …" ``` | Status | What you do | |--------|-------------| | `pending_owner` | Keep waiting. Remind the owner about the link if needed. | | `owner_activated` | Connect MCP. | | `mcp_connected` | You are in. Create or list sites. | | `expired` | Call `POST /api/v1/agent_signups` again. The new `owner_activation_url` is in the JSON, not a new email. | | `existing_account` | Ask them to sign in and connect MCP. | Polling never returns an API key, a password, or `token_digest`. Unknown id or a bad token returns `404`. ## 4. Connect over MCP When `status` is `owner_activated`: ```bash grok mcp add --transport http tinylytics https://tinylytics.app/mcp ``` That writes `~/.grok/config.toml`. It does not sign in. The owner authenticates in Grok. They run `/mcps`, select tinylytics, and press `i`. They sign in and pick Full access if you need to create a site. Read only cannot `create_site`. Then reload this session (`/mcps`, then `r`) or start a new session. Do that before calling `get_me` or `create_site`. Tinylytics can report `mcp_connected` while this turn still has no tools. For other clients, paste `https://tinylytics.app/mcp` into Claude, Cursor, ChatGPT, or Grok connectors. The owner signs in and picks access. That session shows up under Account → Connections. If the client only supports a static header, ask the owner to create a key under Account → API Access and paste `Authorization: Bearer tly-ro-…` (or `tly-fa-…`). ## 5. Prove access, then add the site After the reload, call `GET /api/v1/me` or MCP `get_me`. Send a User-Agent on HTTP API calls. Cloudflare returns 1010 without one. Then create or list sites. Do not assume a site exists because you omitted or sent `site_url`. More after connect: [MCP](https://tinylytics.app/docs/mcp) and [API](https://tinylytics.app/docs/api). ### Analytics Source: https://tinylytics.app/docs/analytics # Analytics Overview is the main view of a site, and most of what follows is about reading it well: what a hit is, why unique hits are an approximation rather than a headcount, and how to carve one busy site into views you can actually reason about. The pages here fall into three groups. Reading Overview, unique hits, and export are about understanding what you already have. Segments, goals, and events are about telling Tinylytics what you care about. Ignoring your hits and spam are about keeping the numbers honest, which matters more on a small site than a large one — a single stuck browser tab can distort a week. <%= render "docs/section_landing", section_path: "/docs/analytics" %> ### AI agent traffic Source: https://tinylytics.app/docs/analytics/ai-agent-traffic # AI agent traffic A growing share of what fetches your pages is not a person. Assistants read your site to answer someone's question, search crawlers index it, and training crawlers collect it. That is worth knowing about, and it is emphatically not worth mixing into your visitor counts — so Tinylytics records it separately. ChatGPT fetching `/pricing` to answer a question shows up as an agent visit, and your traffic chart stays honest. There is a catch built into how this traffic behaves. Most crawlers request the raw HTML and never execute JavaScript, which means the embed script cannot see them at all. To capture them properly the request has to be reported from your side — from your backend, your middleware, or your edge. ## What gets recorded Tinylytics recognises known user agents and sorts them into four kinds: - **AI answers** — someone asked an assistant a question and it fetched your page to answer it (`ChatGPT-User`, `Claude-User`, `Perplexity-User`) - **Indexing** — crawlers building a search index, whether AI-flavoured or classic (`OAI-SearchBot`, `PerplexityBot`) - **Training** — crawlers collecting public pages for model training (`GPTBot`, `ClaudeBot`, `Google-Extended`) - **AI crawler** — other named AI bots that do not fit the three above Anonymous scrapers and bots pretending to be Chrome do not make this list, because there is nothing trustworthy to categorise them by. They are still kept out of your human hits. ## Where to see it The **AI agents** card on a site's Overview breaks the visits down by agent and by page for whichever date range you have selected. It is hidden by default; switch it on under **Settings → Appearance → Stats Sections**. These visits are never folded into views, unique hits, or public stats. This card is not the Referrers **AI assistants** chip, which only groups human hits whose Referer host is a known assistant. ## Capturing crawlers that skip JavaScript If a request never reaches us, we cannot record it — so send it yourself: ```bash curl -X POST "https://tinylytics.app/api/v1/sites/SITE_ID/agent_visits" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "path": "/pricing", "url": "https://example.com/pricing", "user_agent": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0" }' ``` This needs a full-access API key. Unrecognised browser user agents are ignored on this endpoint, so you can safely fire it for every request and let us decide what counts. If you would rather keep one code path, posting the same payload to `POST /sites/:id/hits` also works — known crawlers are classified as agent visits rather than becoming human hits. Here is the same idea as Rails middleware: ```ruby class TrackAiCrawlers def initialize(app) @app = app end def call(env) request = ActionDispatch::Request.new(env) track(request) if request.get? || request.head? @app.call(env) end private def track(request) Thread.new do Faraday.post("#{ENV.fetch("TINYLYTICS_API_BASE_URL")}/api/v1/sites/#{ENV.fetch("TINYLYTICS_SITE_ID")}/agent_visits") do |req| req.headers["Authorization"] = "Bearer #{ENV.fetch("TINYLYTICS_API_KEY")}" req.headers["Content-Type"] = "application/json" req.body = { path: request.path, url: request.original_url, user_agent: request.user_agent, referrer: request.referer }.to_json end end end end ``` Whatever you do, do not make your visitors wait on that call. Fire and forget, or push it into a background job — analytics should never be able to slow down a page. ## API - `GET /api/v1/sites/:id/agent_visits` — list visits; a read-only key is enough - `POST /api/v1/sites/:id/agent_visits` — record one visit; needs a full-access key The [API documentation](https://tinylytics.app/docs/api) has the full parameter list. ### Event tracking Source: https://tinylytics.app/docs/analytics/events # Event tracking Page views tell you what people read. Events tell you what they did. A subscribe button pressed, a PDF downloaded, a form submitted: you name the action in your HTML and Tinylytics counts it. Events appear on the **Events** tab and are kept out of your page-view numbers, so measuring a click never inflates your traffic. Event tracking is in beta. It works, and it may still gain a rough edge or two. ![Events tab](docs/events.png) Add `?events` to your embed URL. Keep `defer` on the tag as usual. ```html ``` You can combine it with the widget parameters, as in `?events&kudos&hits`. If hit collection is switched off for the site, or the script is loaded with `?ignore`, events do not fire either — the two travel together. ## Naming events An event name is a category and an action joined by a dot: `button.click`, `file.download`, `form.submit`. The category is the part that pays off later, when the Events tab groups everything sensibly instead of handing you fifty unrelated words. A bare `click` is rejected for that reason. If arrows are easier to type, `button->click` is stored as `button.click`. ```html ``` You can attach a value when the action has an obvious detail worth keeping, such as which file was downloaded: ```html Download the guide ``` Put the attribute on the element that actually gets clicked. For a form, that means the submit button. ## Clicks that leave the page By default events are sent with `fetch`, which the browser is entitled to cancel if the click navigates away — exactly the situation for a download or an outbound link. Adding `?beacon` switches to `navigator.sendBeacon`, which is designed to survive the page unloading. ```html ``` The trade is that some privacy-focused browsers block beacons outright, so neither method is complete on its own. If these events matter to you, try both on your own audience and keep whichever loses less. One more detail worth knowing: the script debounces each element to one event every 500ms, so an impatient double-click counts once. Once you have an event you care about, you can promote it to a [goal](https://tinylytics.app/docs/analytics/goals) and get a conversion rate alongside it. <%= render "docs/next_steps", pages: [ { label: "Goals", path: "/docs/analytics/goals" }, { label: "Embed the script", path: "/docs/getting-started/embed" } ] %> ### Export Source: https://tinylytics.app/docs/analytics/export # Export Your numbers belong to you, so there are three ways out and they answer three different questions: what am I looking at right now, everything about this one site, and everything I have. ## From the dashboard The **Export data** button on the filter bar is the one you will reach for most. It is included on paid plans and exports exactly what you are looking at — the same date range, the same filters. If you have narrowed the dashboard down to one country and one week, that is what lands in the file. You can export raw hits, paths, top pages (all time), user journeys, sessions, referrers, countries, browsers, platforms, or sources. Goals, the world map, and AI agents are not CSV types here. The Kudos and Events tabs have export buttons of their own. Stay on the page while the file is being prepared. ## A whole site **Settings → Data → Export** ignores your current view and dumps the site: **Export All Hits**, **Export All Kudos**, and **Export All Events**. Hits that matched an ignore rule were never recorded, so they are not in the all-hits file either. ## The whole account **Account → Export Your Data** is the same idea across every site you own. It exists so that leaving, or simply keeping your own archive, is never something you have to ask us for. Two limits worth knowing before you plan around them: journey exports stop at 10,000 rows, and session exports stop at 1,000. If you need more than that, the [API](https://tinylytics.app/docs/api) is the better tool. <%= render "docs/next_steps", pages: [ { label: "Reading Overview", path: "/docs/analytics/overview" }, { label: "API", path: "/docs/api" } ] %> ### Goals Source: https://tinylytics.app/docs/analytics/goals # Goals Most of a dashboard tells you what happened. A goal tells you whether the thing you wanted to happen happened. It might be a thank-you page, a completed signup, or a download — anything you would rather count than skim. Once a site has at least one goal, Overview grows a **Goals** table showing each goal with its **Conversions** and **Rate**. ![Goals in Settings](docs/goals.png) Add them under **Settings → Data → Goals**. Give the goal a name you will recognise in a table months from now, then choose what it watches: - **Path** matches by prefix, the same rule [segments](https://tinylytics.app/docs/analytics/segments) use. A goal on `/thanks` therefore includes `/thanks/paid`. - **Event** matches a named event such as `signup.complete`, which you fire from the page. See [events](https://tinylytics.app/docs/analytics/events). Ten goals per site is the limit, and it is a generous one — goals earn their keep by being few. Clicking a goal's name on Overview filters the dashboard to it. ## How the rate is worked out The rate is converted visitors divided by visitors in the date range you are looking at, capped at 100%. It prefers distinct visitor hashes, and falls back to counting hits where those hashes are missing. Because the underlying visitor hash rotates daily, treat the rate as a good comparative signal across ranges rather than a precise measure of individual people. You can hide the Goals table under **Settings → Appearance → Stats Sections** without deleting anything — the goals stay defined and keep counting. <%= render "docs/next_steps", pages: [ { label: "Events", path: "/docs/analytics/events" }, { label: "Reading Overview", path: "/docs/analytics/overview" } ] %> ### Ignoring your hits Source: https://tinylytics.app/docs/analytics/ignore-hits # Ignoring your hits On a small site you are often your own biggest visitor. Every draft preview, every check that the footer still looks right, every reload after a deploy — none of it is traffic, and all of it is loud enough to bend a chart. Tinylytics gives you two ways to deal with that: a flag you set in your own browser, and rules that drop traffic before it is ever recorded. ![Ignore rules in Settings](docs/ignore-rules.png) ![Advanced ignore rule builder](docs/ignore-rules-more.png) ## Ignore this browser Visit your live site once with the flag on the end of the URL: ```text https://your-site.com?tiny_ignore=true ``` If underscores are awkward wherever you are typing, `?ti=true` does exactly the same thing. Include the `https://` so your browser navigates rather than searching. Tinylytics writes a flag into that browser's **localStorage** and confirms it did. From then on, that browser is invisible to your own analytics and nobody else is affected. Because the flag lives in local storage, it is per browser and per device — so do it again in the browser you use on your phone, and again in whichever one you keep open for testing. To start counting yourself again: ```text https://your-site.com?tiny_ignore=false ``` Clearing site data for that origin has the same effect. There is also `?ignore` on the embed URL itself, which stops that particular page load from ever sending a hit. It is handy in a staging template or a layout you know you will be reloading all afternoon. ## Ignore rules Ignore rules are for noise that will keep arriving whether or not you are looking: an `/admin` area, a preview host, your own site appearing as its own referrer, a country whose bot traffic you have stopped finding interesting. Set them under **Settings → Ignore Rules**, which is also linked from **Settings → Data Hub**. Ignored hits are not hidden, they are never recorded. They stay out of Overview and out of the CSV export of all hits. That is worth knowing before you write a broad rule, because there is nothing to recover afterwards. Note that plenty of junk never reaches this stage at all — Cloudflare, the collector's rate limits, and spam detection all sit in front of it. ## Ignore paths or segments? The two look similar and mean opposite things. An ignored path throws the traffic away. A [segment](https://tinylytics.app/docs/analytics/segments) keeps every hit and simply gives you a named view of that part of the site. So: ignore what you never want to see again, and segment what you want to look at separately. <%= render "docs/next_steps", pages: [ { label: "Hits not showing", path: "/docs/troubleshooting/no-hits" }, { label: "Segments", path: "/docs/analytics/segments" } ] %> ### Insights Source: https://tinylytics.app/docs/analytics/insights # Insights Charts are good at showing you a number and bad at telling you it is unusual. Insights closes that gap: once a day Tinylytics looks over the past week of traffic, picks out what actually moved — a spike, a page climbing, a referrer that appeared out of nowhere — and writes it up in a few paragraphs you can read in under a minute. It is available on paid plans, and it works entirely from the anonymised traffic Tinylytics already holds. Turning it on adds no tracking of any kind. ![Insights tab](docs/insights.png) Ultra accounts also get **Ask**, a short-lived chat about your own sites that you can toggle under **Account → Overview → Ask**. It shares the underlying data with Insights but is a different thing: Insights comes to you, Ask waits for a question. ## Turning it on Open the **Insights** tab and press **Enable Insights**. Two conditions have to be met: an active plan or trial, and at least ten hits in the last seven days — below that there is nothing honest to say about a trend. Insights pause if a trial expires. The first report is queued straight away so you are not left waiting a day to see what you signed up for. After that, generation runs at 1:00 AM in your timezone. **Insights Settings** holds two things worth visiting. Custom instructions let you steer the report in plain language — "focus on the blog posts, skip the generic traffic advice" is a perfectly good instruction. And **Send daily insights via email** delivers each report from `insights@tinylytics.app` as it is generated. If you run several sites, your account settings can collapse them into one grouped digest at 9:00 AM instead of a morning of separate emails. You can have the digest, the per-site emails, or both; the per-site toggle still decides which sites are included at all. ## What is in a report - **Summary** — the one thing that changed most this week - **Signals** — traffic jumps, rising pages, new referrers, shifts in audience - **Traffic patterns** — the days and hours you are actually being read - **Best performing content** - **Recommendations** — two or three specific things to try next Previous reports are stacked underneath the latest one, which is where Insights becomes genuinely useful: a single report is a snapshot, and a month of them is a story. **Disable Insights** sits at the bottom of Insights Settings whenever you have had enough. <%= render "docs/next_steps", pages: [ { label: "Reading Overview", path: "/docs/analytics/overview" }, { label: "Reports and alerts", path: "/docs/monitoring/reports" } ] %> ### Reading Overview Source: https://tinylytics.app/docs/analytics/overview # Reading Overview Overview is the main view of a site. Almost everything on it — the hit counts, the chart, and every table below it — answers the same question for whichever date range you have selected. Change the range and the whole page moves with it. The single exception is **Top pages (all time)**, which ignores the date filter on purpose. ## The chart Pick a range from the filter bar: Last 24 Hours, 7 Days, 30 Days, All Time, and the rest. Multi-day ranges can be grouped by day, week, month, or quarter, so a year of traffic does not turn into a wall of noise. A single day is grouped by hour instead. Where the week begins is your choice, not ours. **Week starts on** in your account settings decides whether Sunday or Monday opens the week, and **This week**, **Last week**, and the weekly charts all respect it. Two numbers sit above the chart: - **Hits** are page loads. Every view counts, including the reader who refreshes three times. - **Unique hits**, if you have enabled them, estimate how many different browsers those loads came from. The word estimate is doing real work in that sentence — see [unique hits](https://tinylytics.app/docs/analytics/unique-hits) for what we do and do not know. On a paid plan you can also decide which summary cards appear above the chart — **Lifetime Hits**, **Traffic Graph**, and **Uptime Status** — under **Settings → Appearance → Site Overview Display**. ## The tables underneath Every block below the chart can be hidden or reordered under **Settings → Appearance → Stats Sections**. A dashboard you have pruned to the four things you care about is worth more than one showing everything. **Goals** appears once you have defined at least one, and shows conversions and conversion rate alongside it. See [goals](https://tinylytics.app/docs/analytics/goals). **Top pages** ranks the most-viewed pages within the range you picked. **Top pages (all time)** is a separate block that answers a different question — what has done well ever — and so it deliberately ignores the date filter. New sites start with it hidden; existing sites keep whatever they had. **User journeys** and **Sessions** are both paid features and are easy to mix up. A journey is a visitor who saw more than one page in the range, which is what you want when you are asking how people move through the site. A session is every visitor we saw, single-page visits included. Neither is a durable person: the underlying hash rotates with the day, so the same reader tomorrow is a new row. [Unique hits](https://tinylytics.app/docs/analytics/unique-hits) explains why. **Referrers** shows where traffic came from — Direct, external sites, and Internal, which is usually hidden by default. An **AI assistants** chip on that table groups human click-throughs whose Referer host is a known assistant; it is not a complete picture of AI traffic, because a stripped Referer still shows up as Direct. We record exactly what the browser tells us, and browsers have become much less forthcoming: LinkedIn, Google, and many others now send only the origin (`linkedin.com`, `google.com`) rather than the page or the search terms. That is their referrer policy, not a limitation we can code around. For search terms, use [Google Search Console](https://search.google.com/search-console). On links you control, UTM parameters still work perfectly. **Countries** comes from the visitor's IP address, which is used for the lookup and then discarded. Country is as specific as it gets — there is no city and no map pin. **Browsers** and **Platforms** are two separate tables: Chrome, Safari, and Vivaldi in one; iOS, Android, macOS, and Windows in the other. **AI agents** is hidden until you ask for it. It counts visits from known assistants and crawlers, and it is kept out of your hit totals so that a curious language model cannot flatter your traffic. See [AI agent traffic](https://tinylytics.app/docs/analytics/ai-agent-traffic). ## Filters The date picker is the main filter, and for most questions it is the only one you need. You can also filter to a single path, but note that the path filter is an exact match: filtering to `/docs` will not include `/docs/api`. That is precise, and occasionally not what you meant — when you want a whole section treated as one thing, use [segments](https://tinylytics.app/docs/analytics/segments) instead. ## Insights The **Insights** tab is the same data read back to you in sentences: what moved this week, which pages rose, which referrers are new. [Insights](https://tinylytics.app/docs/analytics/insights) covers how it works and how to turn it on. <%= render "docs/next_steps", pages: [ { label: "Unique hits", path: "/docs/analytics/unique-hits" }, { label: "Segments", path: "/docs/analytics/segments" }, { label: "Ignoring your hits", path: "/docs/analytics/ignore-hits" } ] %> ### Segments Source: https://tinylytics.app/docs/analytics/segments # Segments A segment is a named view of one site, defined by a path prefix. Give one site a blog, a docs tree, and a marketing homepage and the totals stop meaning much; a segment lets you look at `/docs` on its own, including everything beneath it, without splitting the site in two or touching your embed. Prefix matching is what makes this useful: a segment on `/docs` covers `/docs/api` and `/docs/getting-started`, but not `/docs-old`, which is a different path rather than a deeper one. ![Segments in Settings](docs/segments.png) Create them under **Settings → Data → Segments**, also reachable from **Data Hub**. Once a site has segments, a switcher appears under its name on Overview to move between **All** and each segment. Four segments or fewer show as pills on a wide screen; past that, and on a narrow one, you get a dropdown instead. Segments work within one site. If what you want to compare is several different hosts, that is a [group](https://tinylytics.app/docs/account/groups), not a segment. ## Adding a segment Press **Add segment**, name it, and pick one of two kinds. **Paths** takes one prefix per line. When prefixes overlap, the order of the list decides which segment claims a hit, and the earlier one wins — so drag them into the priority you want rather than trying to write mutually exclusive prefixes. **Everything else** collects the traffic no other segment claimed. You get one remainder per site, and you can list extra paths to keep out of it if there is traffic you would rather leave uncategorised. You can have up to ten segments per site. Removing one only removes the view; the hits stay exactly where they were. ## What a segment is not It is not the dashboard path filter. That filter is an exact match, so clicking `/docs` there shows you that one page and nothing below it. Segments exist precisely because that is often not the question you were asking. It is also not an ignore rule. Ignore rules hide traffic; segments name traffic you want to keep looking at. <%= render "docs/next_steps", pages: [ { label: "Reading Overview", path: "/docs/analytics/overview" }, { label: "Groups", path: "/docs/account/groups" } ] %> ### Spam Source: https://tinylytics.app/docs/analytics/spam # Spam Some traffic is obviously not a reader: the same path hit forty times in a minute, a burst of identical user agents, a visitor pattern no human would produce. Spam Analysis, under **Settings → Data → Spam Analysis**, looks over the last fourteen days and shows you what it found. It is deliberately a review queue rather than a blocklist. Automated deletion of your own data is not a decision we want to make on your behalf, so Tinylytics flags and scores, and you decide. Plenty is already stopped before it gets this far — Cloudflare, the collector's rate limits, and your own [ignore rules](https://tinylytics.app/docs/analytics/ignore-hits) all sit in front of this page. ![Spam analysis](docs/spam.png) Each suspicious group carries a score: - **Review** (50–79) - **High risk** (80–119) - **Very high risk** (120+) The score is built from signals like user-agent bursts, clustering in time, visitor patterns, how little path variety there is, and bursts arriving at the collector. If the page is empty, the last two weeks looked normal. ## What you can do Filter by score, then delete a single row, everything currently visible, or every flagged hit at once. Deletion is permanent and there is no undo, which is the main reason to look at the hashes before you use the widest option. **Hide very high risk hits from the dashboard** is the gentler alternative. Those hits stop affecting Overview immediately but stay in the database for two weeks before Tinylytics removes them, which gives you time to change your mind. Review and high risk hits are left entirely alone until you act on them. As a rule of thumb: use an ignore rule when the same junk will keep arriving, and delete when you have looked at what you have and you are sure. MCP clients can work the same queue if you would rather do this from an agent — see [MCP](https://tinylytics.app/docs/mcp). <%= render "docs/next_steps", pages: [ { label: "Ignoring your hits", path: "/docs/analytics/ignore-hits" }, { label: "Export", path: "/docs/analytics/export" } ] %> ### Unique hits Source: https://tinylytics.app/docs/analytics/unique-hits # Unique hits Total hits count every page load, refreshes included. Unique hits try to count browsers instead of loads, so a reader who opens four of your posts registers as one visitor for the day rather than four. It is a genuinely useful number and a deliberately imperfect one, and it is worth understanding which parts of it you can trust. Unique hits are available on paid plans. Legacy free accounts ignore the unique-hits widget parameter. ## How we count without identifying anyone We do not store a visitor ID, because we do not create one. Instead, every incoming request is turned into a one-way SHA-256 hash of the visitor's IP address, your site's identifier, a truncated user agent, today's date in UTC, and a salt that rotates every twelve hours. Nothing in that hash can be reversed into the ingredients that made it, and the ingredients themselves stop lining up quickly: the date changes at midnight UTC, and the salt changes twice a day, which means even a same-day attempt to guess your way back to a visitor has a very short window and no payoff. Two hashes come out of this. The **visitor hash** leaves the path out, so one person reading several pages stays one visitor — that is the hash behind sessions and journeys. The **unique hash** includes the path, which is what produces the per-page unique count on a path row. Country is looked up from the IP address and then the address is thrown away. It is never written onto the hit. ## What the number is not It is not a cookie, a fingerprint, or a person you can follow. The same human returning tomorrow produces a different hash and counts again. Someone who reads you in Safari and then in Firefox counts twice. Several colleagues behind one office connection, on similar machines, can collapse into one. That is the trade we have chosen: approximate visitor counts and no durable record of a reader. Bots and obvious automation are filtered out before any of this happens, so they do not inflate the estimate. ## Turning it on Unique hits appear on Overview as soon as unique tracking is enabled for a site. If you would like the number on your own pages too, the [hit counter widget](https://tinylytics.app/docs/widgets/hit-counter) accepts `?hits=unique`. If you want the full walk from page load to stored row, [how we handle visitor data](https://tinylytics.app/docs/trust/privacy) has it. <%= render "docs/next_steps", pages: [ { label: "Reading Overview", path: "/docs/analytics/overview" }, { label: "Ignoring your hits", path: "/docs/analytics/ignore-hits" } ] %> ### API Source: https://tinylytics.app/docs/api # Tinylytics API The API gives you everything the dashboard has, plus the ability to send data in. Read your analytics into a report of your own, record hits and events from a backend where JavaScript never runs, create kudos, or automate the parts of your workflow that currently involve you opening a browser. Keys come in two kinds and the distinction is worth getting right up front: read-only keys (`tly-ro-…`) can call every `GET` endpoint, and full-access keys (`tly-fa-…`) are required for anything that writes. Reach for read-only unless you genuinely need to write. If you are connecting an AI client, the [MCP server](https://tinylytics.app/docs/mcp) at `https://tinylytics.app/mcp` wraps these same endpoints and uses these same keys. If an agent needs to start a trial for a human owner, that flow is in [instructions for agents](https://tinylytics.app/docs/agent_signup). ## 1. Quick Start **Base URL** `https://tinylytics.app/api/v1` **Header format** ```bash Authorization: Bearer tly-ro-your-api-key Accept: application/json User-Agent: your-agent ``` Use `tly-fa-...` (Full Access) for write endpoints. HTTP API calls need a User-Agent. Cloudflare returns 1010 without one. ### Test your key in 30 seconds ```bash curl "https://tinylytics.app/api/v1/me" \ -H "Authorization: Bearer tly-ro-your-api-key" \ -H "Accept: application/json" \ -H "User-Agent: your-agent" ``` If valid, you get your account payload with HTTP `200`. ### Discovery and schema You can bootstrap clients without reading prose docs first: ```bash curl "https://tinylytics.app/api/v1" curl "https://tinylytics.app/api/v1/openapi.json" ``` ## 2. Authentication and Access - Auth scheme: `Bearer` token. - Key location: Account Settings → API Access. - Read-only keys (`tly-ro-...`) can call all `GET` endpoints. - Full-access keys (`tly-fa-...`) are required for: - `POST /sites/:id/hits` - `POST /sites/:id/hits/batch` - `POST /sites/:id/agent_visits` - `POST /sites/:id/events` - `POST /sites/:id/events/batch` - `POST /sites/:id/kudos` - `DELETE /sites/:id/kudos/:kudo_uid` - `POST /sites/:id/signals` - `POST /groups` - `PATCH /groups/:id` - `PUT /groups/:id` - `DELETE /groups/:id` ### Access rules - Any account with a valid API key can use core API endpoints. - API keys stay personal. Omit `account_id` to act on the key owner's personal account. - Pass `account_id` (the team owner's user id) to act on an active team account you belong to. List available accounts with `GET /accounts`. - Team viewers can read the owner's sites and groups. Team admins can create, update, and delete the owner's sites. Team owners can also create, update, and delete the owner's groups. - Premium endpoints require an active subscription on the selected account owner: - `GET /sites/:id/insights` - `GET /sites/:id/signals` - `POST /sites/:id/signals` - `GET /sites/:id/uptime` - `GET /sites/:id/content` - Group endpoints require an Ultra subscription: - `GET /groups` - `GET /groups/:id` - `POST /groups` - `PATCH /groups/:id` - `PUT /groups/:id` - `DELETE /groups/:id` - Signals endpoints also require insights to be enabled on the site. - Revoked or invalid keys return `401`. - Write endpoint with read-only key returns `403`. ## 3. Request Conventions - Dates use `YYYY-MM-DD`. - Optional `account_id` selects a team account. Send it as a query parameter or in the JSON body. Omit it for the personal account. - Date range limit for analytics endpoints: max `730` days. - Date boundaries for analytics endpoints default to `UTC`. - Optional timezone mode: - `time_zone=utc` (default) uses UTC day boundaries. - `time_zone=user` uses your account timezone day boundaries. - Pagination: - `page` default varies by endpoint - `per_page` max `1000` (`hits`, `kudos`, `leaderboard`), `50` (`user_journeys`, `insights`), `100` (`signals`, `uptime`) - Hits filtering: - `country` exact match - `path` exact match - `referrer` partial match - Kudos filtering: - `path` exact match - Grouped hits: - `grouped=true` - `group_by` one of `path`, `country`, `referrer`, `browser_name`, `platform_name`, `source`, `date` - `interval` one of `day`, `week`, `month` when `group_by=date` (defaults to `day`) ## 4. Endpoint Directory | Method | Endpoint | Purpose | |--------|----------|---------| | GET | `/` | Public API discovery metadata | | GET | `/openapi.json` | OpenAPI 3.1 schema for API v1 | | GET | `/me` | Validate API key and return account info | | GET | `/accounts` | List personal and accessible team accounts | | POST | `/agent_signups` | Start signup for a human owner | | GET | `/agent_signups/:id` | Poll agent signup status | | GET | `/sites` | List accessible sites | | GET | `/sites/:id` | Get one site | | POST | `/sites` | Create one site Full Access | | PATCH | `/sites/:id` | Update one site Full Access | | PUT | `/sites/:id` | Update one site Full Access | | DELETE | `/sites/:id` | Delete one site Full Access | | PATCH | `/sites/:id/features` | Update site monitoring features Full Access | | PUT | `/sites/:id/features` | Update site monitoring features Full Access | | GET | `/sites/:id/hits` | Raw or grouped analytics hits | | POST | `/sites/:id/hits` | Create one hit Full Access | | POST | `/sites/:id/hits/batch` | Create many hits in one request Full Access | | GET | `/sites/:id/agent_visits` | AI assistant and crawler visits | | POST | `/sites/:id/agent_visits` | Record one AI crawler visit Full Access | | POST | `/sites/:id/events` | Create one event Full Access | | POST | `/sites/:id/events/batch` | Create many events in one request Full Access | | GET | `/sites/:id/kudos` | Read kudos records | | POST | `/sites/:id/kudos` | Create one kudo Full Access | | DELETE | `/sites/:id/kudos/:kudo_uid` | Delete one kudo by UID Full Access | | GET | `/sites/:id/leaderboard` | All-time path leaderboard | | GET | `/sites/:id/user_journeys` | Visitor journey analysis | | GET | `/sites/:id/insights` | AI insights for the site Subscription | | GET | `/sites/:id/signals` | Owner-submitted context signals Subscription | | POST | `/sites/:id/signals` | Submit one context signal Full Access Subscription | | GET | `/sites/:id/uptime` | Uptime + SSL/domain status Subscription | | GET | `/sites/:id/content` | Content monitoring status and issues Subscription | | GET | `/groups` | List site groups Ultra | | GET | `/groups/:id` | Get one site group Ultra | | POST | `/groups` | Create one site group Full Access Ultra | | PATCH | `/groups/:id` | Update one site group Full Access Ultra | | PUT | `/groups/:id` | Update one site group Full Access Ultra | | DELETE | `/groups/:id` | Delete one site group Full Access Ultra | ## 5. Endpoint Reference ### Agent signup Unauthenticated. Starts signup for a human owner. The 14-day trial starts when they activate. The response never includes API keys or passwords. After the owner activates, connect over [MCP](https://tinylytics.app/docs/mcp). Full walkthrough: [instructions for agents](https://tinylytics.app/docs/agent_signup). #### POST `/agent_signups` | Property | Required | Description | |----------|----------|-------------| | email | Yes | Owner email. | | tos_accepted | Yes | Must be `true`. | | agent_name | No | Display name of the agent. | | agent_client | No | Client identifier, such as `cursor`. | | site_url | No | Suggested first site. Not created automatically. | | intended_plan | No | Hint only: `zen`, `pro`, or `ultra`. | Returns `201` with `pending_owner`, or `200` with `existing_account` if the email already has an account. #### GET `/agent_signups/:id` Authorize with `X-Agent-Signup-Token`. `token` as a query parameter also works. Poll until `owner_activated`, then connect to `/mcp`. ### Account and Sites #### GET `/me` Returns current user + current API key metadata. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | None | Yes | This endpoint does not accept query or body properties. | ```bash curl "https://tinylytics.app/api/v1/me" \ -H "Authorization: Bearer tly-ro-your-api-key" \ -H "User-Agent: your-agent" ``` ```json { "id": 123, "email": "user@example.com", "is_subscribed": true, "created_at": "2025-06-01T12:00:00Z", "api_key": { "name": "CLI integration", "access_type": "read_only", "last_used_at": "2026-02-12T10:00:00Z" }, "accounts": [ { "id": 123, "name": "Personal", "kind": "personal", "role": "owner", "is_subscribed": true, "is_plus": false }, { "id": 456, "name": "Tiny Team", "kind": "team", "role": "viewer", "is_subscribed": true, "is_plus": true } ] } ``` --- #### GET `/accounts` Lists the personal account and every active team account available to this API key. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | None | Yes | This endpoint does not accept query or body properties. | ```bash curl "https://tinylytics.app/api/v1/accounts" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "accounts": [ { "id": 123, "name": "Personal", "kind": "personal", "role": "owner", "is_subscribed": true, "is_plus": false }, { "id": 456, "name": "Tiny Team", "kind": "team", "role": "admin", "is_subscribed": true, "is_plus": true } ] } ``` Use a team `id` as `account_id` on later requests. --- #### GET `/sites` Lists your sites with lifetime counters. Pass `start_date` or `end_date` to add `period_hits` and `period_unique_hits` for that window. Sites with no hits in the window still appear, with `0`. `period_unique_hits` is `null` when unique hit tracking is off for that site. Lifetime counters stay as they are. Pass `time_zone=user` if you also group hits by week in the account timezone. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `account_id` | No | Team account owner id. Omit for the personal account. | | `start_date` | No | Range start (`YYYY-MM-DD`). When set, each site includes period counters. | | `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. | | `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). | ```bash curl "https://tinylytics.app/api/v1/sites" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```bash curl "https://tinylytics.app/api/v1/sites?account_id=456" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "sites": [ { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog", "lifetime_hits": 12340, "lifetime_unique_hits": 8920, "lifetime_kudos": 87, "active": true, "public": false, "created_at": "2025-06-01T12:00:00Z", "updated_at": "2026-02-14T09:30:00Z" } ] } ``` With a date range: ```bash curl "https://tinylytics.app/api/v1/sites?start_date=2026-03-01&end_date=2026-03-07" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "sites": [ { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog", "lifetime_hits": 12340, "lifetime_unique_hits": 8920, "lifetime_kudos": 87, "active": true, "public": false, "period_hits": 84, "period_unique_hits": 61, "created_at": "2025-06-01T12:00:00Z", "updated_at": "2026-02-14T09:30:00Z" } ], "filters": { "start_date": "2026-03-01", "end_date": "2026-03-07", "time_zone": "utc" } } ``` --- #### GET `/sites/:id` Returns one site by numeric ID. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. Use the `id` returned from `GET /sites`. | ```bash curl "https://tinylytics.app/api/v1/sites/456" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog", "lifetime_hits": 12340, "lifetime_unique_hits": 8920, "lifetime_kudos": 87, "active": true, "public": false, "created_at": "2025-06-01T12:00:00Z", "updated_at": "2026-02-14T09:30:00Z" } ``` --- #### POST `/sites` Creates a site owned by the selected account. Omit `account_id` to create on the key owner's personal account. Team admins and owners can pass `account_id` to create a site owned by the team owner. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `url` | Yes | Site URL. Must start with `http://` or `https://`. | | `label` | No | Display label. | | `display_root_path` | No | Optional public path prefix for display URLs. | | `public` or `is_public` | No | Whether the site stats page is public. | | `active` | No | Whether the site accepts analytics ingestion. Defaults to `true`. | | `allow_from_any_domain` | No | When `true`, the embed accepts hits from any hostname (not only the configured site URL). Defaults to `false`. | | `account_id` | No | Team account owner id. The new site belongs to that owner. | Requires a full-access API key (`tly-fa-*`) and owner or team-admin access on the selected account. Team owners can also write groups. ```bash curl -X POST "https://tinylytics.app/api/v1/sites" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "url": "https://example.com", "label": "My Blog", "public": false }' ``` ```json { "site": { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog", "display_root_path": null, "lifetime_hits": 0, "lifetime_unique_hits": 0, "lifetime_kudos": 0, "active": true, "public": false, "created_at": "2026-02-14T09:30:00Z", "updated_at": "2026-02-14T09:30:00Z" } } ``` --- #### PATCH `/sites/:id` Updates one owned site. Accepts the same body fields as `POST /sites`, except `url` is optional on update. Requires a full-access API key (`tly-fa-*`). ```bash curl -X PATCH "https://tinylytics.app/api/v1/sites/456" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "label": "Renamed Blog", "display_root_path": "/blog", "public": true, "active": true }' ``` ```json { "site": { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "Renamed Blog", "display_root_path": "/blog", "lifetime_hits": 12340, "lifetime_unique_hits": 8920, "lifetime_kudos": 87, "active": true, "public": true, "created_at": "2025-06-01T12:00:00Z", "updated_at": "2026-02-14T10:15:00Z" } } ``` --- #### DELETE `/sites/:id` Soft-deletes one owned site immediately, hides it from future `GET /sites` responses, and queues background cleanup. This action is irreversible from the API client's perspective. Requires a full-access API key (`tly-fa-*`). **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | ```bash curl -X DELETE "https://tinylytics.app/api/v1/sites/456" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Accept: application/json" ``` Returns `204 No Content` on success. ### Groups Group endpoints let Ultra subscribers bundle sites for combined analytics dashboards. Mutations require a full-access API key. #### GET `/groups` Ultra Lists your site groups with member site summaries. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | None | Yes | This endpoint does not accept query or body properties. | ```bash curl "https://tinylytics.app/api/v1/groups" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "groups": [ { "id": 12, "name": "Blog Portfolio", "description": "All personal blogs", "icon": "rocket", "accent_color": "#4F46E5", "pinned": true, "default_chart_view": "separate", "position": 1, "site_ids": [456, 789], "sites": [ { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog" }, { "id": 789, "uid": "def456", "url": "https://notes.example.com", "label": "Notes" } ], "created_at": "2025-11-30T18:58:32Z", "updated_at": "2026-02-14T09:30:00Z" } ] } ``` --- #### GET `/groups/:id` Ultra Returns one group by numeric ID. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Group numeric ID. | ```bash curl "https://tinylytics.app/api/v1/groups/12" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` --- #### POST `/groups` Full Access Ultra Creates a group on the selected account. Omit `account_id` to create on the key owner's personal account. Team owners can pass `account_id` to create a group on the team account. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `name` | Yes | Group name. | | `description` | No | Optional description. | | `icon` | No | Icon key used by the dashboard UI. | | `accent_color` | No | Hex color such as `#4F46E5`. | | `pinned` | No | Whether the group is pinned in the dashboard. | | `default_chart_view` | No | One of `separate` or `combined`. Defaults to `separate`. | | `site_ids` | No | Array of site numeric IDs owned by the selected account. Unknown or foreign site IDs are ignored. | | `account_id` | No | Team account owner id. The new group belongs to that owner. | Requires a full-access API key (`tly-fa-*`) and personal or team-owner access on the selected account. ```bash curl -X POST "https://tinylytics.app/api/v1/groups" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "name": "Blog Portfolio", "description": "All personal blogs", "accent_color": "#4F46E5", "default_chart_view": "separate", "site_ids": [456, 789] }' ``` ```json { "group": { "id": 12, "name": "Blog Portfolio", "description": "All personal blogs", "icon": null, "accent_color": "#4F46E5", "pinned": false, "default_chart_view": "separate", "position": 1, "site_ids": [456, 789], "sites": [ { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog" }, { "id": 789, "uid": "def456", "url": "https://notes.example.com", "label": "Notes" } ], "created_at": "2026-02-14T09:30:00Z", "updated_at": "2026-02-14T09:30:00Z" } } ``` --- #### PATCH `/groups/:id` Full Access Ultra Updates one owned group. Send only the fields you want to change. Include `site_ids` to replace group membership with the provided owned site IDs. Requires a full-access API key (`tly-fa-*`). ```bash curl -X PATCH "https://tinylytics.app/api/v1/groups/12" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "name": "Renamed Portfolio", "pinned": true, "site_ids": [456] }' ``` --- #### DELETE `/groups/:id` Full Access Ultra Deletes one owned group. Member sites are not deleted. Requires a full-access API key (`tly-fa-*`). ```bash curl -X DELETE "https://tinylytics.app/api/v1/groups/12" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Accept: application/json" ``` Returns `204 No Content` on success. #### PATCH `/sites/:id/features` Full Access Enable or disable monitoring features for one owned site. Send only the features you want to change. Requires a full-access API key (`tly-fa-*`). **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `content_monitoring` | No | Enable or disable content monitoring. Requires a paid plan. Enabling also turns on content monitoring emails. | | `content_monitoring_emails` | No | Enable or disable email alerts for content monitoring issues. Requires content monitoring to be enabled before turning emails on. | | `uptime` | No | Enable or resume uptime monitoring, or pause it when set to `false`. Pausing does not delete the monitor. | At least one supported feature property must be present in the request body. ```bash curl -X PATCH "https://tinylytics.app/api/v1/sites/456/features" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "content_monitoring": true, "uptime": true }' ``` ```json { "site": { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "Example Blog", "display_root_path": "/", "lifetime_hits": 12340, "lifetime_unique_hits": 8920, "lifetime_kudos": 87, "active": true, "public": true, "created_at": "2025-06-01T12:00:00Z", "updated_at": "2026-02-14T10:15:00Z" }, "features": { "content_monitoring": true, "content_monitoring_emails": true, "uptime": true } } ``` To keep content monitoring on while turning off email alerts: ```bash curl -X PATCH "https://tinylytics.app/api/v1/sites/456/features" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "content_monitoring_emails": false }' ``` To pause uptime monitoring without deleting the monitor: ```bash curl -X PATCH "https://tinylytics.app/api/v1/sites/456/features" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "uptime": false }' ``` ### Analytics Endpoints #### GET `/sites/:id/hits` Read detailed hits or grouped analytics. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `start_date` | No | Range start (`YYYY-MM-DD`). Defaults to 30 days ago in the selected timezone mode. | | `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. | | `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). | | `country` | No | Filter by exact 2-letter country code. | | `path` | No | Filter by exact path (for example `/pricing`). | | `referrer` | No | Case-insensitive partial match on referrer. | | `grouped` | No | Set to `true` to return grouped/aggregated results. | | `group_by` | No | One of `path`, `country`, `referrer`, `browser_name`, `platform_name`, `source`, `date`. | | `interval` | No | Date bucket size when `group_by=date`. One of `day` (default), `week`, `month`. Week start follows your account start-of-week setting. | | `page` | No | Page number. | | `per_page` | No | Page size, max `1000`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/hits?grouped=true&group_by=path" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```bash curl "https://tinylytics.app/api/v1/sites/456/hits?grouped=true&group_by=date&interval=week&start_date=2026-03-01&end_date=2026-03-29" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` Grouped by `path` returns `views` (+ `unique_views` when enabled). Other groupings return `hit_count`. `group_by=date` returns a series of `{ date, hit_count }` rows, including empty buckets. Every successful response includes `summary` over the filtered range, not the current page. To evaluate `start_date` and `end_date` in your account timezone, add `time_zone=user`: ```bash curl "https://tinylytics.app/api/v1/sites/456/hits?start_date=2026-02-13&end_date=2026-02-13&time_zone=user" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` **Response (ungrouped)** ```json { "hits": [ { "id": 789, "url": "https://example.com/pricing", "path": "/pricing", "referrer": "https://google.com", "country": "US", "browser_name": "Safari", "platform_name": "macOS", "is_mobile": false, "source": "google", "created_at": "2026-02-13T14:22:00Z" } ], "pagination": { "current_page": 1, "per_page": 100, "total_count": 1, "total_pages": 1 }, "filters": { "start_date": "2026-02-13", "end_date": "2026-02-13", "time_zone": "user", "country": null, "path": null, "referrer": null, "grouped": false }, "summary": { "total_hits": 1, "unique_visitors": 1, "distinct_paths": 1 } } ``` **Response (grouped by `path`)** ```json { "grouped_hits": [ { "path": "/pricing", "views": 142, "unique_views": 98 } ], "pagination": { "current_page": 1, "per_page": 100, "total_count": 1, "total_pages": 1 }, "filters": { "start_date": "2026-02-13", "end_date": "2026-02-13", "time_zone": "user", "country": null, "path": null, "referrer": null, "grouped": true, "group_by": "path" }, "summary": { "total_hits": 142, "unique_visitors": 98, "distinct_paths": 1 } } ``` `unique_views` is only included when unique hit tracking is enabled for the site. Other `group_by` values (`country`, `referrer`, `browser_name`, `platform_name`, `source`, `date`) return `hit_count` instead of `views`/`unique_views`. **Response (grouped by `date`)** ```json { "grouped_hits": [ { "date": "2026-03-02", "hit_count": 40 }, { "date": "2026-03-09", "hit_count": 0 }, { "date": "2026-03-16", "hit_count": 22 } ], "pagination": { "current_page": 1, "per_page": 100, "total_count": 3, "total_pages": 1 }, "filters": { "start_date": "2026-03-01", "end_date": "2026-03-22", "time_zone": "utc", "country": null, "path": null, "referrer": null, "grouped": true, "group_by": "date", "interval": "week" }, "summary": { "total_hits": 62, "unique_visitors": 41, "distinct_paths": 8 } } ``` `summary.total_hits` is the filtered hit count. For grouped results, `pagination.total_count` is the number of groups. `group_by=date` returns the full series in one page (empty buckets included), so `per_page` does not slice days or weeks. --- #### POST `/sites/:id/hits` Full Access Create one hit. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `path` | Yes | Path to track. Leading slash is auto-added if missing. | | `country` | No | 2-letter uppercase country code (for example `US`, `PL`, `XX`). If provided, this value takes precedence. | | `ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `country` is not provided. Raw IP is not stored in hits. | | `url` | No | Full page URL. Defaults to `site.url + path`. | | `referrer` | No | Referrer URL. | | `user_agent` | No | User agent string. | | `visitor_id` | No | Stable visitor identifier used for dedupe/journey grouping. | | `source` | No | Source override. If missing, Tinylytics may infer from URL parameters. | **Payload rules** - Body must be a single JSON object - Required fields: `path` - `country` must be 2-letter uppercase when provided (example: `US`, `PL`, `XX`) - Country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX` - `path` is normalized to begin with `/` ```bash curl -X POST "https://tinylytics.app/api/v1/sites/456/hits" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "path": "/pricing", "ip_address": "8.8.8.8", "visitor_id": "user-123" }' ``` **Response (`201` created)** ```json { "status": "created", "hit": { "id": 789, "url": "https://example.com/pricing", "path": "/pricing", "referrer": null, "country": "US", "browser_name": null, "platform_name": null, "is_mobile": false, "source": null, "unique_hash": "a1b2c3", "visitor_hash": "d4e5f6", "created_at": "2026-02-14T10:00:00Z" } } ``` **Response (`202` ignored)** ```json { "status": "ignored", "reason": "Path matches ignore rule" } ``` **Response (`422` error)** ```json { "status": "error", "errors": ["Path can't be blank"] } ``` --- #### POST `/sites/:id/hits/batch` Full Access Create many hits in one request. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `[]` | Yes | Top-level array of hit objects. | | `[].path` | Yes | Path to track. Leading slash is auto-added if missing. | | `[].country` | No | 2-letter uppercase country code. If provided, this value takes precedence. | | `[].ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `[].country` is not provided. Raw IP is not stored in hits. | | `[].url` | No | Full page URL. | | `[].referrer` | No | Referrer URL. | | `[].user_agent` | No | User agent string. | | `[].visitor_id` | No | Stable visitor identifier used for dedupe/journey grouping. | | `[].source` | No | Source override. | **Payload rules** - Body must be a top-level JSON array - Each row follows the same field rules as single hit creation. - Per row country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX` - Batch is partial-success: one bad row does not fail the whole request. ```bash curl -X POST "https://tinylytics.app/api/v1/sites/456/hits/batch" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -d '[ { "path": "/valid", "country": "PL" }, { "path": "/from-ip", "ip_address": "8.8.8.8" }, { "path": "/fallback-xx", "ip_address": "999.999.999.999" } ]' ``` ```json { "created_count": 3, "ignored_count": 0, "error_count": 0, "results": [ { "index": 0, "status": "created" }, { "index": 1, "status": "created" }, { "index": 2, "status": "created" } ] } ``` --- #### POST `/sites/:id/events` Full Access Create one event. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `event` | Yes | Event name in `category.action` format. | | `value` | No | Optional event value stored as `event_properties["value"]`. | | `path` | No | Optional path context. Leading slash is auto-added if missing. | | `country` | No | 2-letter uppercase country code (for example `US`, `PL`, `XX`). If provided, this value takes precedence. | | `ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `country` is not provided. Raw IP is not stored in events. | | `url` | No | Full page URL. Defaults to `site.url + path` when `path` is provided. | | `referrer` | No | Referrer URL. | | `user_agent` | No | User agent string. | | `visitor_id` | No | Stable visitor identifier used for event identity/grouping hashes. | | `source` | No | Source override. If missing, Tinylytics may infer from URL parameters. | **Payload rules** - Body must be a single JSON object - Required fields: `event` - `event` must use `category.action` format - `country` must be 2-letter uppercase when provided (example: `US`, `PL`, `XX`) - Country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX` - `path` is optional and normalized to begin with `/` when present ```bash curl -X POST "https://tinylytics.app/api/v1/sites/456/events" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "event": "signup.started", "value": "pricing", "ip_address": "8.8.8.8", "visitor_id": "user-123" }' ``` **Response (`201` created)** ```json { "status": "created", "event": { "id": 790, "event": "signup.started", "value": "pricing", "url": null, "path": null, "referrer": null, "country": "US", "source": null, "unique_hash": "a1b2c3", "visitor_hash": "d4e5f6", "created_at": "2026-02-14T10:00:00Z" } } ``` **Response (`202` ignored)** ```json { "status": "ignored", "reason": "Event matched ignore rules" } ``` **Response (`422` error)** ```json { "status": "error", "errors": ["Event must be 'category.action' format (2+ dot-separated segments)"] } ``` --- #### POST `/sites/:id/events/batch` Full Access Create many events in one request. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `[]` | Yes | Top-level array of event objects. | | `[].event` | Yes | Event name in `category.action` format. | | `[].value` | No | Optional event value stored as `event_properties["value"]`. | | `[].path` | No | Optional path context. Leading slash is auto-added if missing. | | `[].country` | No | 2-letter uppercase country code. If provided, this value takes precedence. | | `[].ip_address` | No | IPv4/IPv6 address used to resolve country via local lookup first, then IPinfo Lite API as fallback when `[].country` is not provided. Raw IP is not stored in events. | | `[].url` | No | Full page URL. | | `[].referrer` | No | Referrer URL. | | `[].user_agent` | No | User agent string. | | `[].visitor_id` | No | Stable visitor identifier used for event identity/grouping hashes. | | `[].source` | No | Source override. | **Payload rules** - Body must be a top-level JSON array - Each row follows the same field rules as single event creation - Per row country resolution order: provided `country` → local lookup from `ip_address` → IPinfo `country_code` API fallback → `XX` - Batch is partial-success: one bad row does not fail the whole request ```bash curl -X POST "https://tinylytics.app/api/v1/sites/456/events/batch" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -d '[ { "event": "signup.started", "value": "pricing", "country": "PL" }, { "event": "signup.completed", "ip_address": "8.8.8.8" }, { "event": "signup.cancelled", "ip_address": "999.999.999.999" } ]' ``` ```json { "created_count": 3, "ignored_count": 0, "error_count": 0, "results": [ { "index": 0, "status": "created" }, { "index": 1, "status": "created" }, { "index": 2, "status": "created" } ] } ``` --- #### GET `/sites/:id/kudos` Read detailed Kudos activity. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `start_date` | No | Range start (`YYYY-MM-DD`). Defaults to 30 days ago in the selected timezone mode. | | `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. | | `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). | | `path` | No | Filter by exact path (for example `/pricing`). | | `uid` | No | Filter by exact kudo UID. | | `page` | No | Page number. | | `per_page` | No | Page size, max `1000`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/kudos?start_date=2026-02-01&end_date=2026-02-14" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "kudos": [ { "id": 321, "uid": "pricing-kudo-1", "path": "/pricing", "created_at": "2026-02-10T08:15:00Z" } ], "pagination": { "current_page": 1, "per_page": 100, "total_count": 1, "total_pages": 1 }, "filters": { "start_date": "2026-02-01", "end_date": "2026-02-14", "time_zone": "utc", "path": null, "uid": null } } ``` --- #### POST `/sites/:id/kudos` Full Access Create one kudo. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `path` | Yes | Path to track. Leading slash is auto-added if missing. | | `custom_uid` | No | Custom identifier for the kudo. If omitted, Tinylytics generates one. | **Payload rules** - Body must be a single JSON object - Required fields: `path` - `path` is normalized to begin with `/` ```bash curl -X POST "https://tinylytics.app/api/v1/sites/456/kudos" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "path": "/pricing", "custom_uid": "pricing-kudo-1" }' ``` **Response (`201` created)** ```json { "status": "created", "kudo": { "id": 321, "uid": "pricing-kudo-1", "path": "/pricing", "created_at": "2026-02-14T10:00:00Z" } } ``` **Response (`202` ignored)** ```json { "status": "ignored", "reason": "Path matches ignore rule" } ``` **Response (`422` error)** ```json { "status": "error", "errors": ["Path can't be blank"] } ``` --- #### DELETE `/sites/:id/kudos/:kudo_uid` Full Access Delete one kudo by UID. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `kudo_uid` (URL path) | Yes | Kudo UID to delete. | ```bash curl -X DELETE "https://tinylytics.app/api/v1/sites/456/kudos/pricing-kudo-1" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Accept: application/json" ``` **Response (`200` deleted)** ```json { "status": "deleted", "uid": "pricing-kudo-1" } ``` **Response (`404` not found)** ```json { "error": "Kudo not found" } ``` --- #### GET `/sites/:id/leaderboard` All-time path ranking with caching. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `path` | No | Case-insensitive partial filter for path text. | | `page` | No | Page number. | | `per_page` | No | Page size, max `1000`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/leaderboard?path=blog" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "leaderboard": [ { "path": "/blog/hello-world", "total_hits": 540, "unique_hits": 320, "percentage": 12.5 } ], "site": { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog" }, "pagination": { "current_page": 1, "per_page": 100, "total_count": 1, "total_pages": 1 }, "cache_info": { "cached_at": "2026-02-14T09:00:00Z", "expires_at": "2026-02-14T10:00:00Z" }, "filters": { "path": "blog" } } ``` --- #### GET `/sites/:id/user_journeys` Session-style visitor path analysis with summary metrics. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `start_date` | No | Range start (`YYYY-MM-DD`). Defaults to 30 days ago in the selected timezone mode. | | `end_date` | No | Range end (`YYYY-MM-DD`). Defaults to today in the selected timezone mode. | | `time_zone` | No | Date-boundary mode: `utc` (default) or `user` (use account timezone). | | `page` | No | Page number. | | `per_page` | No | Page size, max `50`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/user_journeys?start_date=2026-01-01&end_date=2026-01-31&time_zone=user" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "user_journeys": [ { "visitor_hash": "v1a2b3", "page_count": 4, "first_hit": "2026-01-15T10:00:00Z", "last_hit": "2026-01-15T10:12:00Z", "duration_minutes": 12, "pages": [ { "path": "/" }, { "path": "/blog" }, { "path": "/blog/hello-world" }, { "path": "/pricing" } ], "entry_page": "/", "exit_page": "/pricing", "session_duration": 720, "referrer": "https://google.com", "country": "DE", "browser": "Firefox" } ], "summary": { "total_visitors": 230, "multi_page_visitors": 95, "single_page_visitors": 135, "bounce_rate": 58.7 }, "insights": { "top_entry_pages": [ { "path": "/", "visitors": 120 }, { "path": "/blog", "visitors": 45 } ], "top_exit_pages": [ { "path": "/pricing", "visitors": 60 }, { "path": "/blog/hello-world", "visitors": 30 } ] }, "pagination": { "current_page": 1, "per_page": 50, "total_count": 230, "total_pages": 5 }, "filters": { "start_date": "2026-01-01", "end_date": "2026-01-31", "time_zone": "user" } } ``` --- #### GET `/sites/:id/insights` Subscription Returns generated insights, signal snapshots, and site insight settings. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `page` | No | Page number. | | `per_page` | No | Page size, max `50`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/insights" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "insights": [ { "id": 42, "insights_for_date": "2026-02-13", "formatted_insights_date": "February 13, 2026", "generated_at": "2026-02-14T06:00:00Z", "summary": "Traffic was steadier than usual overall, with one blog post and a new referrer doing most of the lifting.", "signals": [ { "type": "traffic_change", "headline": "Traffic is up 28% this week", "summary": "The site picked up 378 hits in the last 7 days, up from 296 the week before.", "importance_score": 64, "detected_at": "2026-02-14T06:00:00Z", "window": { "started_at": "2026-02-07T00:00:00Z", "ended_at": "2026-02-14T06:00:00Z" }, "payload_excerpt": { "direction": "increase", "current_hits": 378, "previous_hits": 296, "absolute_change": 82, "change_percentage": 27.7 } } ], "traffic_patterns": "Wednesday and Thursday were the busiest days, with evenings remaining your strongest hour.", "best_content": "Your recent Rails post is getting more attention than usual and is now one of the site's top pages.", "recommendations": "Keep an eye on the post that is breaking out, and consider sharing similar content while the momentum is still fresh.", "context_signals_count": 1, "context_signals": [ { "id": 12, "occurred_at": "2026-02-12T09:00:00Z", "title": "Newsletter #42 sent", "description": "Featured the Rails post that is now breaking out.", "category": "newsletter", "metadata": { "path": "/blog/rails-post" } } ] } ], "pagination": { "current_page": 1, "per_page": 50, "total_count": 1, "total_pages": 1 }, "site": { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog", "insights_enabled": true, "daily_insight_reports_active": true, "next_insight_job_scheduled_at": "2026-02-15T06:00:00Z" } } ``` Each insight returns: - `summary`: the short AI overview of what changed most. - `signals`: stored auto-detected signal snapshots for that report, including headline, summary, score, detection time, window, and a small payload excerpt. - `context_signals_count`: how many owner-submitted signals were included when the insight was generated. - `context_signals`: the owner-submitted signals included in that insight. - `traffic_patterns`, `best_content`, and `recommendations`: the fuller AI explanation for the week. #### GET `/sites/:id/signals` Subscription Returns owner-submitted context signals for a site. Requires insights to be enabled. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `start_date` | No | Start date (`YYYY-MM-DD`). Defaults to 30 days ago. | | `end_date` | No | End date (`YYYY-MM-DD`). Defaults to today. | | `category` | No | Filter by category (`newsletter`, `social`, `launch`, `maintenance`, `content`, `podcast`, `other`). | | `page` | No | Page number. | | `per_page` | No | Page size, max `100`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/signals?start_date=2026-02-01&end_date=2026-02-14" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` #### POST `/sites/:id/signals` Full Access Subscription Submit one owner context signal. Use this to tell Tinylytics about newsletters, launches, social posts, maintenance windows, and other events that help explain traffic changes in Insights and traffic spike analysis. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `signal.occurred_at` | Yes | When the event happened (ISO8601 datetime). | | `signal.title` | Yes | Short headline, max 200 characters. | | `signal.description` | No | Longer context, max 5000 characters. | | `signal.category` | No | One of `newsletter`, `social`, `launch`, `maintenance`, `content`, `podcast`, `other`. | | `signal.metadata` | No | Optional JSON object (for example `url`, `path`, `campaign`, `tags`). | | `signal.external_id` | No | Idempotency key unique per site. Reusing it updates the existing signal. | ```bash curl -X POST "https://tinylytics.app/api/v1/sites/456/signals" \ -H "Authorization: Bearer tly-fa-your-api-key" \ -H "Content-Type: application/json" \ -d '{ "signal": { "occurred_at": "2026-02-12T09:00:00Z", "title": "Newsletter #42 sent", "description": "Featured the Rails post that is now breaking out.", "category": "newsletter", "metadata": { "path": "/blog/rails-post" }, "external_id": "newsletter-42" } }' ``` ### Monitoring Endpoints #### GET `/sites/:id/uptime` Subscription Returns uptime monitor status, SSL/domain details, and downtime history. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | | `page` | No | Page number for downtime records. | | `per_page` | No | Page size for downtime records, max `100`. | ```bash curl "https://tinylytics.app/api/v1/sites/456/uptime" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` If uptime is not enabled for the site, response is `404`. ```json { "monitor": { "id": 101, "url": "https://example.com", "enabled": true, "is_down": false, "uptime": 99.95, "last_check_at": "2026-02-14T09:55:00Z", "next_check_at": "2026-02-14T10:00:00Z", "last_status_code": 200, "last_error_message": null, "status_description": "OK (200)", "current_check_interval": 300, "period": "30d", "ssl": { "expires_at": "2026-08-01T00:00:00Z", "valid": true, "expiring_soon": false, "expired": false, "days_until_expiry": 168 }, "domain": { "tested_at": "2026-02-14T00:00:00Z", "expires_at": "2027-06-01T00:00:00Z", "remaining_days": 472, "source": "whois", "expired": false, "expiring_soon": false, "days_until_expiry": 472 }, "auto_paused": false, "created_at": "2025-06-01T12:00:00Z", "updated_at": "2026-02-14T09:55:00Z" }, "downtimes": [ { "id": 55, "error": "Connection timed out", "started_at": "2026-02-10T03:00:00Z", "ended_at": "2026-02-10T03:15:00Z", "duration": 900, "duration_in_words": "15 minutes", "partial": false, "ongoing": false } ], "pagination": { "current_page": 1, "per_page": 100, "total_count": 1, "total_pages": 1 }, "summary": { "total_downtimes": 3, "ongoing_downtimes": 0, "recent_downtimes_30_days": 1 } } ``` --- #### GET `/sites/:id/content` Subscription Returns content monitoring status, issues, ignored issues, and stats. **Accepted properties** | Property | Required | Description | |----------|----------|-------------| | `id` (URL path) | Yes | Site numeric ID. | ```bash curl "https://tinylytics.app/api/v1/sites/456/content" \ -H "Authorization: Bearer tly-ro-your-api-key" ``` ```json { "site": { "id": 456, "uid": "abc123", "url": "https://example.com", "label": "My Blog" }, "monitoring_status": { "enabled": true, "root_path": "/blog", "last_check_at": "2026-02-14T08:00:00Z", "is_initial_check": false, "is_rechecking": false, "has_issues": true, "emails_enabled": true, "emails_paused": false, "emails_paused_until": null }, "issues": { "broken_links": [ { "id": 201, "url": "https://example.com/old-page", "status_code": 404, "error_message": "Not Found", "issue_type": "broken_link", "checked_at": "2026-02-14T08:00:00Z", "ignored": false } ], "mixed_content": [] }, "ignored_issues": [], "ok_links": [], "stats": { "total_checked": 48, "broken_links_count": 1, "mixed_content_count": 0, "ignored_count": 0, "ok_count": 47 } } ``` If content monitoring is disabled for the site, response is `403` with: ```json { "error": "Content monitoring is not enabled for this site", "content_monitoring_enabled": false } ``` ## 6. Common Flows ### Build a dashboard 1. `GET /sites` 2. `GET /sites/:id/hits?grouped=true&group_by=path` 3. `GET /sites/:id/leaderboard` ### Add server-side tracking 1. Create full-access key 2. `POST /sites/:id/hits` from your backend 3. `POST /sites/:id/events` for backend interaction tracking 4. `POST /sites/:id/kudos` when users react 5. Verify ingestion with `GET /sites/:id/hits` and your site’s Events/Kudos dashboard views ### Monitor health in one poll cycle 1. `GET /sites/:id/uptime` 2. `GET /sites/:id/content` 3. Alert from `summary`/`stats` fields ## 7. Errors and Status Codes | Status | Meaning | |--------|---------| | `200` | Success | | `201` | Resource created | | `202` | Accepted but skipped (for ignored hits, events, or kudos) | | `400` | Invalid parameter(s) | | `401` | Missing/invalid/revoked API key | | `403` | Premium endpoint requires subscription, write access required, or feature disabled | | `404` | Resource not found | | `422` | Validation or payload format error | | `500` | Unexpected server error | Typical error payload: ```json { "error": "Invalid API key" } ``` ## 8. Rate Limits and Support Authenticated API requests are rate limited to `1000 requests per hour per API key`. For implementation help: `hello@tinylytics.app`. ### Developer Source: https://tinylytics.app/docs/developer # Developer Everything in this section exists so that your analytics are not trapped behind our interface. The API reads and writes the same data the dashboard does, webhooks push events to you instead of making you poll, MCP lets an AI client work your account directly, and the tracking pixel covers the places JavaScript cannot reach. If you are wiring something up for the first time, the [API](https://tinylytics.app/docs/api) is the place to start — MCP, webhooks, and the n8n recipes are all built on the same endpoints and the same keys. <%= render "docs/section_landing", section_path: "/docs/developer" %> ### Integrations Source: https://tinylytics.app/docs/developer/integrations # Integrations Tinylytics is one script tag, so it works anywhere you can edit a template. But on hosted platforms that is not always convenient, and people in the community have built plugins and themes that do the work for you. These are theirs, not ours — we are simply glad they exist.
Micro.blog Plugin

Tinylytics for Micro.blog

Adds the Tinylytics script to Micro.blog.

Tiny Theme for Micro.blog

Tiny Theme for Micro.blog

A Micro.blog theme with Tinylytics built in.

WordPress Plugin

Tinylytics for WordPress

Adds the Tinylytics script to WordPress.

## Automation ### n8n You can wire Tinylytics into [n8n](https://n8n.io) using its built-in Webhook and HTTP Request nodes, with no custom community node to install. We publish importable sample workflows covering webhook intake for uptime, hits, and kudos, plus scheduled pulls from the API. The full walkthrough is in [Tinylytics + n8n](https://tinylytics.app/docs/developer/n8n). ## Contributing Built something? We would like to list it. Email hello@tinylytics.app. ### n8n Source: https://tinylytics.app/docs/developer/n8n # Tinylytics + n8n [n8n](https://n8n.io) talks to Tinylytics through the same two building blocks every other client uses: outbound [webhooks](https://tinylytics.app/docs/developer/webhooks) for things that happen to you, and the [API](https://tinylytics.app/docs/api) for things you go and ask for. There is no official Tinylytics community node yet, so you assemble the built-in **Webhook**, **HTTP Request**, and **Code** nodes yourself — which sounds like more work than it is, and we have sample workflows below to save you the first hour. ## What works today | Goal | How | | --- | --- | | React when a site goes down or recovers | Tinylytics webhook → n8n Webhook trigger (`monitor_down`, `monitor_up`) | | React to live hits, kudos, or custom events | Tinylytics webhook → n8n Webhook trigger (`new_hit`, `new_kudo`, `new_event`) | | React when an AI insight is generated | Tinylytics webhook → n8n Webhook trigger (`new_insight`) | | React to content-monitoring issues | Tinylytics webhook → n8n Webhook trigger (`content_issue`) | | Pull analytics on a schedule | n8n Schedule + HTTP Request against `/api/v1` | | Verify webhook authenticity | HMAC over the raw body using `X-Signature` | Webhooks require an **active paid subscription**. API keys are available from Account Settings → API Access. Prefer webhooks over tight polling when you can — API requests are rate limited. ## Sample workflows Import these into n8n (**⋯** menu → **Import from File**), then follow the sticky notes in each workflow. | Workflow | Download | What it does | | --- | --- | --- | | Receive webhooks | [receive-webhooks.json](https://tinylytics.app/examples/n8n/receive-webhooks.json) | Verifies `X-Signature`, routes core webhook events, formats a `message` field you can send onward | | Daily top paths | [daily-top-paths.json](https://tinylytics.app/examples/n8n/daily-top-paths.json) | Calls `GET /sites/:id/hits?grouped=true&group_by=path` for yesterday and builds a short summary | | Poll uptime | [poll-uptime.json](https://tinylytics.app/examples/n8n/poll-uptime.json) | Calls `GET /sites/:id/uptime` on a timer; prefer webhooks for real downtime alerts | The sample workflows stop at a formatted `message` (or equivalent). Connect Slack, email, Discord, Linear, or any other n8n node yourself — Tinylytics does not ship those credentials. ## 1. Receive Tinylytics webhooks in n8n ### Requirements - Paid Tinylytics plan - An n8n instance with a **public** HTTPS URL (Tinylytics blocks localhost and private network URLs) - Webhook platform set to **Generic** (not Discord) - **Wrap content** left off for the sample expressions If you only need Discord downtime embeds, Tinylytics already has a native Discord webhook platform — you do not need n8n for that path. ### Steps 1. Import [receive-webhooks.json](https://tinylytics.app/examples/n8n/receive-webhooks.json) (or recreate the flow below). 2. Open the **Tinylytics Webhook** node, activate the workflow, and copy the **Production URL**. 3. In Tinylytics go to **Account Settings → Webhooks**, create a webhook with that URL, select the event types you want, and save. 4. Copy the signing secret into the **Verify Signature** Code node (`SIGNING_SECRET`). 5. Use **Send Test** on the webhook edit screen. You should see a successful delivery and a workflow execution in n8n. ### Signature verification Tinylytics signs the exact JSON body with your webhook signing secret and sends: ```text X-Signature: sha256= ``` Also sent: `X-Tinylytics-Event`, `X-Tinylytics-Delivery`, `X-Tinylytics-Timestamp`. In n8n: 1. Enable **Raw Body** on the Webhook node (required — the signature is over the raw bytes, not re-serialized JSON). 2. Verify with a Code node using the binary raw body, for example: ```js const crypto = require('crypto'); const SIGNING_SECRET = 'your-signing-secret'; const headers = $input.item.json.headers || {}; const signature = String(headers['x-signature'] || ''); const rawBody = await this.helpers.getBinaryDataBuffer(0, 'data'); const expected = 'sha256=' + crypto.createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex'); const sigBuf = Buffer.from(signature); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { throw new Error('Invalid Tinylytics webhook signature'); } return { json: $input.item.json.body }; ``` Treat deliveries as at-least-once. Store `X-Tinylytics-Delivery`, or `hit.id` / `kudo.uid` / `analytics_event.id` / `insight.id` / `check.id`, if duplicate handling matters. Full payload shapes are documented in [Webhooks](https://tinylytics.app/docs/developer/webhooks#payload-reference). ### Event types (core) | Event | When it fires | | --- | --- | | `monitor_down` | Uptime monitor reports the site down | | `monitor_up` | Uptime monitor reports recovery | | `new_hit` | An accepted live hit (script, pixel, or API create/batch) | | `new_kudo` | An accepted browser kudo, API kudo, or verified Webmention like | | `new_event` | An accepted custom analytics event (script or API create/batch) | | `new_insight` | A new AI insight record is created | | `content_issue` | Content monitoring records a new/changed broken link or mixed content issue | Ignored paths, ignored visitors, spam-suppressed writes, imports, seeds, and similar non-live writes do **not** emit hit/kudo/custom-event webhooks. See [Webhooks](https://tinylytics.app/docs/developer/webhooks). ## 2. Call the Tinylytics API from n8n ### Auth ```text Authorization: Bearer tly-ro-your-api-key Accept: application/json ``` Use a read-only key (`tly-ro-...`) for GET workflows. Use a full-access key (`tly-fa-...`) only when you intentionally write hits, events, kudos, signals, sites, or groups. Create keys under **Account Settings → API Access**. Base URL: `https://tinylytics.app/api/v1`. ### Smoke test HTTP Request node: - Method: `GET` - URL: `https://tinylytics.app/api/v1/me` - Header: `Authorization: Bearer tly-ro-...` A valid key returns HTTP `200` with your account payload. ### Useful read endpoints for automation | Endpoint | Notes | | --- | --- | | `GET /me` | Validate the key | | `GET /sites` | List sites and ids | | `GET /sites/:id/hits` | Raw or grouped hits (`grouped=true`, `group_by=path\|country\|…`) | | `GET /sites/:id/kudos` | Kudos list | | `GET /sites/:id/leaderboard` | All-time path leaderboard | | `GET /sites/:id/uptime` | Subscription + uptime enabled on the site | | `GET /sites/:id/content` | Subscription + content monitoring | | `GET /sites/:id/insights` | Subscription | | `GET /sites/:id/signals` | Subscription + insights enabled on the site | Date query params use `YYYY-MM-DD`. Analytics ranges default to UTC day boundaries; pass `time_zone=user` to use your account timezone. Details: [API docs](https://tinylytics.app/docs/api). ### Example: yesterday’s top paths ```text GET https://tinylytics.app/api/v1/sites/SITE_ID/hits ?grouped=true &group_by=path &start_date=YYYY-MM-DD &end_date=YYYY-MM-DD &time_zone=user &per_page=10 ``` That is what [daily-top-paths.json](https://tinylytics.app/examples/n8n/daily-top-paths.json) calls. Grouped-by-path rows include `views` (and `unique_views` when unique hits are enabled). ## What is not available These gaps are listed plainly so you do not spend an evening building a workflow that can never fire: | Missing today (core product) | Workaround | | --- | --- | | Official Tinylytics n8n node / OAuth app | Use Webhook + HTTP Request as documented here | | GET list endpoint for custom analytics events | Events are write-only via the API (`POST /sites/:id/events`); use the `new_event` webhook for push | | Webhook when a scheduled email report is sent | No equivalent push event | | Native Slack webhook platform | Send Generic webhooks to n8n, then use n8n’s Slack node | ## Troubleshooting | Problem | What to check | | --- | --- | | Tinylytics rejects the webhook URL | URL must be public HTTP(S). Localhost / private IPs are blocked | | No deliveries | Paid plan active? Webhook active? Event type selected? | | Signature always fails | Raw Body enabled? Secret pasted correctly? Verify against raw bytes, not `JSON.stringify(body)` | | Test works, live hits do not | Hit may be ignored, spam-suppressed, imported, or otherwise non-live — see [Webhooks](https://tinylytics.app/docs/developer/webhooks#troubleshooting) | | API `401` | Wrong or revoked key; header must be `Authorization: Bearer …` | | API `403` on uptime/insights/content | Active subscription required; some features must also be enabled on the site | | Uptime poll returns `404` | Uptime is not enabled for that site | | Discord-shaped payload in n8n | Use the **Generic** webhook platform for n8n | ## Related docs - [Webhooks](https://tinylytics.app/docs/developer/webhooks) - [API Reference](https://tinylytics.app/docs/api) - [Uptime Monitoring](https://tinylytics.app/docs/monitoring/uptime) - [Integrations & Plugins](https://tinylytics.app/docs/developer/integrations) ### Pixel tracking Source: https://tinylytics.app/docs/developer/pixel # Pixel tracking for RSS and email Some of your best readers never load your site. They read you in an RSS client or in an email, where JavaScript does not run and the embed script has no way to report anything. A tracking pixel is the old, simple answer: a 1×1 gif that records a hit when the client loads the image. It records a **normal hit**, subject to exactly the same rules as the script — no cookies, nothing kept about the person. This is in beta, and it is worth being honest about what that means here. Image caching and image blocking both cut the other way, so the numbers undercount. There is no RSS subscriber estimate and no email open rate, because neither can be derived honestly from this. ## Where the URLs live Open a site's **Community** tab. The pixel URLs and ready-made snippets are there. You can track the site as a whole, or pass a path so the hits land on the right page. ## The basic pixel ```html ``` ## Per-post pixel ```html ``` The resulting hits appear on Overview alongside everything else — Community is only where you copy the URL from, not where the numbers end up. ### Webhooks Source: https://tinylytics.app/docs/developer/webhooks # Webhooks A webhook is Tinylytics telling your application that something happened, rather than your application asking us over and over. Downtime and recovery, live hits, kudos, custom events, AI insights, and content issues can all be delivered to a URL you control the moment they occur — which is both kinder to our rate limits and considerably faster than polling. Webhooks are available on all paid plans. An outbound event is only created when four things are true: the site owner has an active subscription, the webhook is active, the event type is selected on that webhook, and the underlying write was accepted. That last condition is the one that catches people out, so it is worth remembering — a hit that was ignored or suppressed never happened as far as webhooks are concerned. ## 1. Quick Start 1. Open **Account Settings**. 2. Choose **Webhooks**. 3. Create a webhook with a public HTTP or HTTPS URL. HTTPS is recommended. 4. Select the events you want to receive. 5. Save the webhook and copy the signing secret. 6. Use **Send Test** from the webhook edit screen to confirm your endpoint and signature verification. Your endpoint should return a `2xx` response as soon as it accepts the delivery. Do any slower processing in a background job on your side. Treat webhook delivery as at-least-once. Store `X-Tinylytics-Delivery`, or the nested `hit.id` or `kudo.uid`, if duplicate processing would be a problem. ## 2. Delivery Contract Tinylytics sends each delivery as an HTTP `POST` with a JSON body. | Property | Value | | --- | --- | | Method | `POST` | | Content type | `application/json` | | Timeout | 30 seconds | | Open timeout | 10 seconds | | Success response | Any `2xx` status | | Failed response | Network errors, unsafe URLs, timeouts, `3xx`, `4xx`, or `5xx` responses | | Retry limit | Up to 10 failed attempts | Each delivery includes these headers: | Header | Description | | --- | --- | | `Content-Type` | Always `application/json`. | | `X-Tinylytics-Event` | Event type, such as `new_hit` or `monitor_down`. | | `X-Tinylytics-Delivery` | Unique delivery id. This is the `WebhookEvent` id and can be used for idempotency. | | `X-Tinylytics-Timestamp` | Unix timestamp for the delivery attempt. | | `X-Signature` | HMAC SHA-256 signature in the format `sha256=`. | Retries use this schedule after failed attempts: 30 seconds, 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, and 12 hours. Tinylytics stores recent deliveries under the webhook's **Events** page. You can inspect the payload, response code, response body, error message, attempt count, and retry status there. Failed events can be retried manually from that page. ## 3. Event Directory | Event | When it fires | Payload key | | --- | --- | --- | | `monitor_down` | An uptime monitor reports the site as down. | `monitor`, `downtime` | | `monitor_up` | An uptime monitor reports the site as recovered. | `monitor`, `downtime` | | `new_hit` | Tinylytics accepts a live hit from the collector script, tracking pixel, API create endpoint, or API batch create endpoint. Batch creates emit one webhook event per accepted hit. | `hit` | | `new_kudo` | Tinylytics accepts a browser kudo, API kudo, or verified Webmention like. | `kudo` | | `new_event` | Tinylytics accepts a custom analytics event from the collector script or API create/batch endpoints. Batch creates emit one webhook event per accepted event. | `analytics_event` | | `new_insight` | Tinylytics creates a new AI insight for the site. | `insight` | | `content_issue` | Content monitoring records a new or changed `broken_link` or `mixed_content` issue. | `check` | Existing webhooks only receive the event types you selected. Edit a webhook and select any new types you want. Tinylytics does not emit hit, kudo, or custom-event webhooks for ignored paths, ignored visitors, spam-suppressed writes, rate-limited writes, imports, seeds, cron-generated data, reimports, manual model creates, or kudo deletes. Spelling issues do not emit `content_issue`. ## 4. Payload Reference Every event uses the same top-level wrapper. | Field | Type | Description | | --- | --- | --- | | `event` | string | The event type. Matches `X-Tinylytics-Event`. | | `timestamp` | string | ISO 8601 timestamp for when Tinylytics created the webhook payload. | | `site` | object | Site metadata. | The `site` object has this shape: | Field | Type | Description | | --- | --- | --- | | `id` | integer | Tinylytics site id. | | `name` | string | Display name for the site. | | `url` | string | Site URL. | | `dashboard_url` | string | Tinylytics dashboard URL for the site. | ### `monitor_down` ```json { "event": "monitor_down", "timestamp": "2026-05-14T12:00:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "monitor": { "id": 456, "status": "down" }, "downtime": { "started_at": "2026-05-14T11:58:00Z", "started_at_formatted": "May 14, 2026 at 11:58", "ended_at": null, "ended_at_formatted": null, "duration_seconds": 120, "duration_formatted": "2 minutes", "error": "Connection timeout" } } ``` ### `monitor_up` ```json { "event": "monitor_up", "timestamp": "2026-05-14T12:08:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "monitor": { "id": 456, "status": "up" }, "downtime": { "started_at": "2026-05-14T11:58:00Z", "started_at_formatted": "May 14, 2026 at 11:58", "ended_at": "2026-05-14T12:08:00Z", "ended_at_formatted": "May 14, 2026 at 12:08", "duration_seconds": 600, "duration_formatted": "10 minutes", "error": "Connection timeout" } } ``` ### `new_hit` The `hit` object matches the existing create-hit API response shape. ```json { "event": "new_hit", "timestamp": "2026-05-14T12:15:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "hit": { "id": 789, "url": "https://example.com/articles/webhooks", "path": "/articles/webhooks", "referrer": "https://example.org", "country": "US", "browser_name": "Safari", "platform_name": "macOS", "is_mobile": false, "source": "newsletter", "unique_hash": "4cc1ed6d2f0d", "visitor_hash": "9e89d84f3e47", "created_at": "2026-05-14T12:15:00.000Z" } } ``` `referrer`, `country`, `browser_name`, `platform_name`, `source`, `unique_hash`, and `visitor_hash` can be `null` depending on the request and privacy rules. ### `new_kudo` The `kudo` object matches the existing API kudo response shape. ```json { "event": "new_kudo", "timestamp": "2026-05-14T12:20:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "kudo": { "id": 987, "uid": "kudo_d4f96c", "path": "/articles/webhooks", "created_at": "2026-05-14T12:20:00.000Z" } } ``` ### `new_event` The nested object is named `analytics_event` so it does not collide with the top-level webhook `event` string. `value` comes from the event’s optional value property and may be `null`. ```json { "event": "new_event", "timestamp": "2026-05-14T12:25:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "analytics_event": { "id": 555, "event": "cta.click", "value": "hero", "url": "https://example.com/pricing", "path": "/pricing", "referrer": "https://example.org", "country": "US", "source": "newsletter", "unique_hash": "4cc1ed6d2f0d", "visitor_hash": "9e89d84f3e47", "created_at": "2026-05-14T12:25:00.000Z" } } ``` ### `new_insight` ```json { "event": "new_insight", "timestamp": "2026-05-14T13:00:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "insight": { "id": 42, "insights_for_date": "2026-05-14", "formatted_insights_date": "May 14, 2026", "generated_at": "2026-05-14T13:00:00.000Z", "summary": "Traffic stayed steady with a small mid-week lift.", "signals": [], "context_signals_count": 0, "traffic_patterns": "Visits were evenly spread through the week.", "best_content": "/articles/webhooks led the week.", "recommendations": "Keep publishing on the topics that drew attention." } } ``` ### `content_issue` Fired for new or changed `broken_link` and `mixed_content` checks only. ```json { "event": "content_issue", "timestamp": "2026-05-14T13:05:00Z", "site": { "id": 123, "name": "Example Blog", "url": "https://example.com", "dashboard_url": "https://tinylytics.app/sites/123" }, "check": { "id": 77, "url": "https://example.com/broken-link", "status_code": 404, "error_message": "HTTP 404: Not Found", "issue_type": "broken_link", "checked_at": "2026-05-14T13:05:00.000Z", "ignored": false } } ``` Test deliveries from the webhook edit screen include a top-level `"test": true` flag and sample data. They use the same delivery job, headers, signature format, and retry behaviour as live deliveries. ## 5. Signature Verification Tinylytics signs the exact JSON request body with the webhook signing secret: ```text X-Signature: sha256= ``` Verify the signature before parsing or trusting the JSON. Always compute the HMAC from the raw request body you received. If you enable the content wrapper or use Discord formatting, the signed body is the transformed body that Tinylytics sent. ### Rails Example ```ruby class TinylyticsWebhooksController < ApplicationController skip_before_action :verify_authenticity_token def create return head :unauthorized unless valid_signature? payload = JSON.parse(request.raw_post) case request.headers["X-Tinylytics-Event"] when "monitor_down" # Notify your incident system. when "monitor_up" # Resolve your incident. when "new_hit" # Enqueue analytics processing. when "new_kudo" # Notify the author or update local counters. when "new_event" # Handle a custom analytics event. when "new_insight" # Store or forward the AI insight summary. when "content_issue" # Open a content-fix ticket. end head :ok end private def valid_signature? signature = request.headers["X-Signature"].to_s body = request.raw_post expected = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", signing_secret, body)}" signature.bytesize == expected.bytesize && ActiveSupport::SecurityUtils.secure_compare(signature, expected) end def signing_secret Rails.application.credentials.dig(:tinylytics, :webhook_signing_secret) end end ``` ### Node.js Example Mount this route before any JSON middleware that would consume the raw body. ```js import crypto from "node:crypto"; import express from "express"; const app = express(); app.post("/webhooks/tinylytics", express.raw({ type: "application/json" }), (req, res) => { const secret = process.env.TINYLYTICS_WEBHOOK_SIGNING_SECRET; const signature = req.get("X-Signature") || ""; const expected = `sha256=${crypto.createHmac("sha256", secret).update(req.body).digest("hex")}`; const verified = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); if (!verified) { return res.sendStatus(401); } const eventType = req.get("X-Tinylytics-Event"); const payload = JSON.parse(req.body.toString("utf8")); switch (eventType) { case "new_hit": // Enqueue analytics processing. break; case "new_kudo": // Notify the author or update local counters. break; case "new_event": // Handle a custom analytics event. break; case "new_insight": // Store or forward the AI insight summary. break; case "content_issue": // Open a content-fix ticket. break; case "monitor_down": case "monitor_up": // Route monitor alerts. break; } res.sendStatus(200); }); ``` ## 6. Platform And Body Options The default platform is **Generic**. It receives the JSON payloads shown above. If **Wrap content** is enabled, Tinylytics wraps the event payload before delivery: ```json { "content": { "event": "new_hit", "timestamp": "2026-05-14T12:15:00Z", "site": {}, "hit": {} } } ``` Discord webhooks use custom embeds for `monitor_down` and `monitor_up`. Other event types use the generic Discord fallback unless Tinylytics adds a custom Discord format later. ## 7. Common Flows ### n8n To pipe webhook events into n8n (signature verification, routing, and sample workflow JSON), see [Tinylytics + n8n](https://tinylytics.app/docs/developer/n8n). ### Downtime Alerts Select `monitor_down` and `monitor_up`. Verify the signature, route `monitor_down` to your alerting or incident tool, and mark the incident resolved when `monitor_up` arrives. ### Live Hit Processing Select `new_hit`. Tinylytics creates one webhook event after each accepted live hit from the collector script, tracking pixel, API create endpoint, or API batch endpoint. Use `X-Tinylytics-Delivery` for delivery idempotency and `hit.id` for hit idempotency. ### Kudos Notifications Select `new_kudo`. Tinylytics creates one webhook event after each accepted browser kudo, API kudo, or verified Webmention like. Use `kudo.uid` if you need a stable public identifier. ### Custom Events Select `new_event`. Tinylytics creates one webhook event after each accepted custom analytics event from the collector script or API. Use `analytics_event.id` for idempotency. ### AI Insights Select `new_insight`. Tinylytics creates one webhook event after each newly generated insight record. ### Content Issues Select `content_issue`. Tinylytics creates one webhook event when content monitoring first records a broken link or mixed content issue, or when that issue type changes. Unchanged re-checks do not re-fire. ### API Writes And Webhooks Successful API hit, kudo, and custom-event creates can emit webhook events. Ignored or rejected writes do not emit webhooks, even if the API request returns a handled response. ## 8. Troubleshooting | Problem | What to check | | --- | --- | | No deliveries appear | Confirm the site owner has an active subscription, the webhook is active, and the event type is selected. | | Test works but live hits do not | Confirm the hit is not ignored, spam-suppressed, imported, seeded, or manually created. | | Signature mismatch | Verify against the raw request body, not a parsed and re-serialized JSON object. | | Repeated retries | Return a `2xx` status after accepting the event. Redirects, `4xx`, and `5xx` responses are treated as failures. | | Endpoint rejected | Use a public HTTP or HTTPS endpoint. Localhost, private network addresses, and unsafe URLs are blocked. | | Discord output is generic | Only `monitor_down` and `monitor_up` use custom Discord embeds today. | ### Webmentions Source: https://tinylytics.app/docs/developer/webmentions # Webmentions A webmention is how one site tells another that it has linked to it. It is the IndieWeb's version of a notification, and it means appreciation from elsewhere on the web can land on your own post rather than staying on a platform. Tinylytics accepts them, shows them on the **Community** tab, and can count likes as kudos for that path. This is in beta. Add this to your ``. The same tag appears in Kudos setup and on the Community tab, so you only need to copy it from one of them: ```html ``` When someone likes your post from their own site, or through a bridge like [Bridgy](https://brid.gy/), we fetch the source page and check that it genuinely links to yours before doing anything with it. That verification step is what keeps webmentions from becoming a spam channel. Verified likes become kudos; mentions and reposts appear in Community. In your kudos stats you can filter by source **External** to see which sites have been sending them, which is often more interesting than the count itself. <%= render "docs/next_steps", pages: [ { label: "Kudos", path: "/docs/widgets/kudos" } ] %> ### Getting started Source: https://tinylytics.app/docs/getting-started # Getting started Tinylytics is analytics for people who want to know how their site is doing without spying on the people reading it. You add one small script, we count page views, and you read them on your dashboard. There are no cookies, no fingerprinting, and no consent banner to design. Getting to your first hit takes four steps: 1. **[Create an account](https://tinylytics.app/docs/getting-started/account)** and confirm your email address. Confirming is what starts your 14-day trial and switches collection on. 2. **[Add a site](https://tinylytics.app/docs/getting-started/add-a-site)** using the domain you actually serve to visitors. 3. **[Paste the embed script](https://tinylytics.app/docs/getting-started/embed)** into your pages, just before the closing `` tag. 4. **[Open the dashboard](https://tinylytics.app/docs/getting-started/dashboard)** and load your own site once to see the first hit land. If an AI agent is setting Tinylytics up on your behalf, send it to the [instructions for agents](https://tinylytics.app/docs/agent_signup) rather than the signup form. It follows a slightly different path, and you stay the owner of the account. <%= render "docs/section_landing", section_path: "/docs/getting-started" %> ### Create an account Source: https://tinylytics.app/docs/getting-started/account # Create an account Sign up at [tinylytics.app](https://tinylytics.app) with an email address and a password, then click the link we send you. Confirming your address is what begins the **14-day trial**, and it is also what switches collection on: until the address is confirmed, Tinylytics will not serve the embed script or record a hit. If your numbers stay at zero on day one, this is the first thing to check. The trial gives you everything, including the features that later belong to the higher plans, so you can find out which one you actually need. We do not ask for a card up front. When the trial ends you choose between **Zen** (2 sites), **Pro** (20 sites), and **Ultra** (50 sites). The analytics are identical on all three — the plans differ in how many sites you can track and, on Ultra, in the collaboration and alerting extras. [Billing](https://tinylytics.app/docs/account/billing) has the full comparison. ## If an agent is signing you up An AI agent can start the process for you, but it cannot finish it. It calls the signup API, and you get an activation link by email; you choose the password and accept the terms yourself. Your trial starts when you activate, and only then can the agent connect over [MCP](https://tinylytics.app/docs/mcp). The full sequence is in [instructions for agents](https://tinylytics.app/docs/agent_signup). ## Managing the account afterwards **Account** in the top navigation holds everything that belongs to you rather than to a particular site: your email address and password, passkeys, billing, team access, and your timezone. Your timezone matters more than it sounds — it decides where the day breaks on your charts, what "this week" means, and when the weekly email arrives. The same screen is where you export or delete your data. Both are yours to run whenever you like, without asking us first. <%= render "docs/next_steps", pages: [ { label: "Add a site", path: "/docs/getting-started/add-a-site" }, { label: "Embed the script", path: "/docs/getting-started/embed" } ] %> ### Add a site Source: https://tinylytics.app/docs/getting-started/add-a-site # Add a site A site in Tinylytics is one domain and its dashboard. Adding one is deliberately dull: press **Add +** on the sites list, give us the URL, and you are done. A friendly name is optional and only changes what you see in the list. On Ultra you can also choose between a **Full Analytics Site** and an **Uptime Monitor Only**, which is useful when you want to watch something you have no interest in measuring. ![Sites list with Add in the top right](docs/sites-list.png) ![Add a new site form](docs/add-site.png) ![Create Site and Omakase setup](docs/add-site-create.png) The URL is the one decision worth slowing down for. Tinylytics only serves the embed script to the hostname you enter here, so use the address visitors actually land on. If your site is served from `www.example.com` and that is what you type, requests from a bare `example.com` will not be counted unless you turn on **Allow on any domain** under **Settings → General**. The `www` and apex pair is the one exception we handle for you. If you configured `example.com` and a visitor reaches `www.example.com` without being redirected, those hits still count. ## After you save You land on the new site's dashboard, which will be empty until the script is in place. **Settings** is where you copy the [embed snippet](https://tinylytics.app/docs/getting-started/embed), and it is also the door to General, Appearance, Public & Webring, Data Hub, and Ignore Rules. A development or preview hostname that does not match the domain you configured records nothing at all — that is the point of the restriction, not a bug to work around. If you genuinely want numbers from staging, add it as a second site and keep the two apart. <%= render "docs/next_steps", pages: [ { label: "Embed the script", path: "/docs/getting-started/embed" }, { label: "Hits not showing", path: "/docs/troubleshooting/no-hits" } ] %> ### The dashboard Source: https://tinylytics.app/docs/getting-started/dashboard # The dashboard Open a site and you get three things: **Overview**, any feature tabs you have switched on, and **Settings**. That is the whole shape of it. If you are used to older versions of Tinylytics, note that the embed snippet now lives inside Settings rather than on a tab of its own. ![Tinylytics Overview on the public demo, with a hits chart and stats.](docs/overview-demo.png) The screenshot above is our public demo site, so you can poke at a busy dashboard without waiting for your own traffic. ## Overview Overview is the page you will actually live on. Hits, unique hits if you have enabled them, the chart, and every table beneath it all follow whichever date range you pick. The one exception is **Top pages (all time)**, which deliberately ignores the date filter. [Reading Overview](https://tinylytics.app/docs/analytics/overview) goes through each block and what it does and does not tell you. Above the chart sits a row of optional summary cards — lifetime hits, current traffic, uptime, and so on. They are there to be turned off as much as on; pick the two or three you actually glance at and hide the rest under **Settings → Appearance**. ## Feature tabs Tabs appear as you enable the features behind them: Insights, Uptime, Kudos, Community, Content, and Events. Nothing shows up uninvited. Once you have a few, drag them into the order you want under **Settings → Appearance → Dashboard Tabs**, so the tab you check daily is the one nearest to hand. ## Settings Everything you can change about a site is behind **Settings**: - **General** — name, domain, and whether to allow hits from any hostname - **Appearance** — accent colour, chart type, and which stats sections appear - the **embed snippet** for this site - **Public & Webring** — the public stats page and webring membership - **Collaborators** (Ultra) — read-only access for one person on one site - **Email Reports** and **Traffic Spikes** — the weekly email and the alerts - **Data Hub** — segments, goals, spam review, import, and export - **Ignore Rules** — traffic you never want recorded ## Account **Account**, in the top navigation, is deliberately outside any site. Your email address, password, billing, team access, timezone, data export, and account deletion all live there, because they apply to everything you track rather than to one domain. <%= render "docs/next_steps", pages: [ { label: "Reading Overview", path: "/docs/analytics/overview" }, { label: "Site settings", path: "/docs/account/site-settings" } ] %> ### Embed the script Source: https://tinylytics.app/docs/getting-started/embed # Embed the script Every site has its own embed script, tied to the domain you configured when you added it. Installing Tinylytics means copying that one tag from **Settings** and pasting it into your pages, ideally just before the closing `` tag. It works in `` too, as long as you keep `defer` on it so the browser does not wait for us before painting the page. ![Tracking code in Settings](docs/embed-code.png) Click the snippet in Settings and it copies itself. ```html ``` That is the entire installation. Load one of your own pages and the hit should appear on Overview within a moment. ## A smaller file If you would rather ship fewer bytes, add `/min` to get a minified build. It behaves identically. ```html ``` ## Single-page apps A normal page load is easy to count. An app that swaps the URL without loading a new document is not, because the script never runs again. Adding `?spa` loads a version that can count more than once per document. ```html ``` With `?spa`, Tinylytics counts the first view, counts again on every `turbo:load` event, and exposes `window.tinylytics.triggerUpdate()` so you can count a view yourself. If you use Turbo, that is all you need. If you use React Router, Vue Router, or anything else with its own history handling, call `triggerUpdate()` after each navigation: ```javascript window.tinylytics?.triggerUpdate() ``` Repeat calls for the same URL are ignored, so an over-eager router will not double-count. You can combine `?spa` with `/min`. ## Hosts that will not accept a script tag Some publishing platforms strip ` ``` ## Parameters | Parameter | What it does | | --- | --- | | `?hits` | Hit counter (`tinylytics_hits`) | | `?hits=unique` | Unique hits instead of total (paid) | | `?kudos` | Kudos button, default 👋 | | `?kudos=❤️` | Custom emoji | | `?kudos=custom` | Count only, for your own styling | | `?uptime` | Uptime percentage | | `?countries` | Visitor country flags | | `?webring` | Random webring member | | `?webring=avatars` | Webring member with avatar | | `?events` | Event tracking | | `?beacon` | `sendBeacon` instead of `fetch` | | `?spa` | Client-side navigation | | `?ignore` | Do not record this page load | Join them with `&`. If you have not installed the [script](https://tinylytics.app/docs/getting-started/embed) yet, start there — widgets are additions to it, not alternatives. <%= render "docs/section_landing", section_path: "/docs/widgets" %> ### Countries Source: https://tinylytics.app/docs/widgets/countries # Countries A row of flags is a quietly lovely thing to have in a footer: proof that a small site written in one room has been read in forty places. This widget lists every country that appears in your hits, as emoji flags, in alphabetical order. ```html ``` The script replaces whatever is inside the element, so leave it empty. You can combine it with the other parameters on the same URL, as in `?hits&countries`. Flags sit tightly next to each other by default. A little letter spacing makes a long row much easier to read: ```css .tinylytics_countries { letter-spacing: 0.15em; } ``` This is the same data as the Countries table on Overview: resolved from the visitor's IP address, which is discarded immediately after the lookup. The widget is simply the friendly version of it. <%= render "docs/next_steps", pages: [ { label: "Hit counter", path: "/docs/widgets/hit-counter" }, { label: "Reading Overview", path: "/docs/analytics/overview" } ] %> ### Hit counter Source: https://tinylytics.app/docs/widgets/hit-counter # Hit counter A hit counter is the oldest thing on the web and still one of the nicest. This one shows your site's lifetime hit count, and it is available on every plan. Add `?hits` to the embed, then put an element wherever the number belongs: ```html ``` The tag itself does not matter — a `span`, a `strong`, a `div` all work. The class is what the script looks for. Put several on one page and they all show the same lifetime total, since it is a site figure rather than a page one. ## Unique hits instead On a paid plan, `?hits=unique` fills the element with unique hits rather than total hits, which is the more modest and arguably more interesting number. Legacy free accounts fall back to the total. ```html ``` ## Styling The number inherits whatever type surrounds it. Tabular figures stop it jittering as the count grows, which is worth two lines of CSS: ```css .tinylytics_hits { font-variant-numeric: tabular-nums; letter-spacing: 0.06em; } ``` <%= render "docs/next_steps", pages: [ { label: "Kudos", path: "/docs/widgets/kudos" }, { label: "Embed the script", path: "/docs/getting-started/embed" } ] %> ### Kudos Source: https://tinylytics.app/docs/widgets/kudos # Kudos Kudos is a like button for your own site. Someone enjoys a post, they tap it, and the appreciation stays on your page rather than being routed through a social network that wants something in return. No account, no login, no tracking of the person who pressed it. Confirm your email address before you expect kudos to record — like everything else that writes to your account, this waits for a confirmed owner. ```html ``` Leave the button empty. The script fills it with 👋 and the current count. On an index page that lists several posts, tell each button which post it belongs to: ```html ``` Paths start with `/`. A wrong path records happily against the wrong page, so it is worth checking one before you template it across a hundred. After a click the button gains a `.did_select` class and is disabled, and a kudos id goes into local storage so the same browser cannot count twice. ## Emoji and custom markup A wave is the default because it is friendly and means very little. Swap it for something that means more on your site: ```html ``` `?kudos=custom` returns only the count, which is what you want when you would rather draw the control yourself. `data-private="true"` hides the public count while still recording it — useful on a new site where a visible zero does more harm than good. If you go either route, style `.did_select` yourself so people can still see that their tap registered. Try it here: Paid plans add kudos tables to the dashboard, so you can see which posts people actually appreciated rather than only which ones they opened. ![Kudos chart on the dashboard](docs/kudos.png) ![Kudos table by path](docs/kudos-table.png) Likes from the IndieWeb can land here too. See [webmentions](https://tinylytics.app/docs/developer/webmentions). <%= render "docs/next_steps", pages: [ { label: "Hit counter", path: "/docs/widgets/hit-counter" }, { label: "Webmentions", path: "/docs/developer/webmentions" } ] %> ### Uptime status Source: https://tinylytics.app/docs/widgets/uptime # Uptime status Once [uptime monitoring](https://tinylytics.app/docs/monitoring/uptime) is running, you can publish the percentage on your own pages. It is a small, quietly confident thing to put in a footer, and it comes from real multi-region checks rather than a badge you drew yourself. The widget reads from an active monitor, and monitors are a paid feature — without one the element simply shows zero. Use one element per page: ```html ``` You can combine it with the other parameters, as in `?hits&uptime`. If the dashboard reports your site as down while it loads perfectly for you, a firewall is usually eating the checks rather than the site being unwell. Allow this user agent through: ``` Tinylytics Uptime Monitor/1.0 ``` <%= render "docs/next_steps", pages: [ { label: "Uptime monitoring", path: "/docs/monitoring/uptime" }, { label: "Widgets", path: "/docs/widgets" } ] %> ### Webring Source: https://tinylytics.app/docs/widgets/webring # Webring The Tinylytics webring is a hand-curated ring of personal sites. Every click on the widget sends a reader to a random other member, which is a very old way of finding things and still a much better one than most. Join from **Settings → Public & Webring**. ```html 🕸️💍 ``` Leave `href` empty and the script fills it in. The link text is entirely yours — the spider and ring above are just a convention, not a requirement. ## With avatars If you would rather show who you are sending people to, the avatar variant adds their picture to the link: ```html 🕸️💍 ``` The image starts hidden deliberately, so a broken placeholder never flashes before the real one arrives. The script sets `src` and `srcset` — 100×100, with a 200×200 for retina screens — and then reveals it. The ring is for personal sites, and we review every membership by hand. That review is the whole point: a ring nobody curates stops being worth clicking within a month. <%= render "docs/next_steps", pages: [ { label: "Public stats", path: "/docs/account/public-stats" }, { label: "Widgets", path: "/docs/widgets" } ] %>