# 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.  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**.  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 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.  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.  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.  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.
## 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=` 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.  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.   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" } ] %>