Shoal Analytics

Documentation

How Shoal works

One script tag on your site, one key for your agent, one plain-text endpoint. No SDK, no client library, nothing to install on the machine that reads the numbers.

Quick start

  1. 1. Open the dashboard and add your site. You get a site key that looks like 623a8f6842dadf1b02.
  2. 2. Paste the snippet into your site's HTML — see Install.
  3. 3. Load a page. The first hit appears in the dashboard within a second or two; the badge flips to VERIFIED once a hit arrives from the domain you registered.
  4. 4. Issue an agent key in the Agent desk on the dashboard. Copy it then — it is never shown again.
  5. 5. Call /api/rundown with that key.

Install the snippet

Put it in the <head> of every page you want counted. The script is deferred, so it will not block rendering.

<script defer src="https://shoalanalytics.dev/shoal.js" data-site="YOUR_SITE_KEY"></script>

Next.js (App Router)

// app/layout.tsx
import Script from "next/script";

<Script
  defer
  src="https://shoalanalytics.dev/shoal.js"
  data-site="YOUR_SITE_KEY"
  strategy="afterInteractive"
/>

Next.js (Pages Router)

// pages/_document.jsx
<Head>
  <script defer src="https://shoalanalytics.dev/shoal.js" data-site="YOUR_SITE_KEY" />
</Head>

WordPress

// functions.php
add_action('wp_head', function () { ?>
  <script defer src="https://shoalanalytics.dev/shoal.js" data-site="YOUR_SITE_KEY"></script>
<?php });

The file is called shoal.js rather than track.js or analytics.js on purpose: filter lists block those names, and blocked requests would quietly vanish from your numbers. /track.js still works as an alias for existing installs.

Visitors who block the script

A share of any audience runs an ad blocker, and most blocker lists drop third-party analytics. When the request is blocked it never reaches the server, so it cannot be counted - these numbers are a floor, not a ceiling.

The fix is not to disguise the request; it is to serve it from your own domain. A rewrite makes the script and its API calls first-party, which most lists leave alone, and nothing about the data changes: same salt, same daily hash, same rows.

This is also why a site can show fewer visits here than in Google Analytics, and neither number is lying: a blocked request never reaches either server. GA does not say so. The gap is small on mainstream audiences and wide on developer, privacy or ad-blocker-heavy ones.

Netlify - netlify.toml

[[redirects]]
  from = "/s/js"
  to = "https://shoalanalytics.dev/shoal.js"
  status = 200
  force = true

[[redirects]]
  from = "/s/api/*"
  to = "https://shoalanalytics.dev/api/:splat"
  status = 200
  force = true

Vercel - vercel.json

{
  "rewrites": [
    { "source": "/s/js", "destination": "https://shoalanalytics.dev/shoal.js" },
    { "source": "/s/api/:path*", "destination": "https://shoalanalytics.dev/api/:path*" }
  ]
}

nginx

location = /s/js  { proxy_pass https://shoalanalytics.dev/shoal.js; }
location /s/api/ { proxy_pass https://shoalanalytics.dev/api/; }

Then point the snippet at the proxy

<script defer src="/s/js" data-site="YOUR_SITE_KEY"></script>

The script derives its API host from wherever it was loaded, so proxying /s/js makes it post to /s/api/track automatically. If you route the two differently, set data-api on the tag to the prefix instead.

A hit does not record how the script was served, so the dashboard cannot show you how much a proxy is recovering. Compare against your server logs if you want that figure.

What gets recorded

One row per page view. That is the whole dataset.

FieldExampleNotes
path/pricing?ref=xPath and query string, trimmed to 500 characters.
referrerhttps://news.ycombinator.com/Where the visitor came from, if the browser sent it.
devicemobiledesktop / mobile / tablet / bot.
countryGBFrom the host's edge header. Never from the IP.
visitor_hash1a0656ee…Salted, rotates daily. Cannot be linked across days.
day2026-10-21UTC calendar day, used for every grouping.

Never recorded

  • • IP addresses — they are used to compute the hash, then discarded.
  • • User-agent strings — only reduced to a device type.
  • • Cookies, localStorage, or session identifiers.
  • • Fingerprints — no canvas, audio, font or WebGL probing.

Bots are recorded but excluded from every headline number, and counted separately as Bot hits. Detection is user-agent based, so a headless browser with an ordinary agent string will be counted as a person — that is the honest trade for never probing the device for a fingerprint.

Your agent key

Keys are issued from the Agent desk on the dashboard: name it, choose a scope, copy it once.

  • • Only a SHA-256 hash is stored. A lost key cannot be recovered — revoke it and make another.
  • • A key can be scoped to one site or to every site you own. A scoped key asking about another site gets 403.
  • • The free plan includes one active key; Pro allows twenty.
  • • Revoked keys stop working immediately. The row stays as a record of what was issued.
curl --fail-with-body --silent \
  -H "Authorization: Bearer YOUR_AGENT_KEY" \
  "https://shoalanalytics.dev/api/rundown?days=7"

Keys are read-only and cannot change anything, but treat them like passwords: put them in your agent's secret store, never in a prompt, a repo, or a screenshot.

GET /api/rundown

The endpoint the product exists for: a digest, not a dashboard.

ParamRequiredNotes
sitenoSite id or site key. Omit to report on every site the key can see.
daysno1–90, defaults to 7.

Returns text/plain, not JSON — an agent can read it straight into a summary. Idle days are collapsed so a quiet week reads as one line.

Shoal | Daily Rundown
Agent: Morning Briefing
Scope: site=example.com
Period: last 7 day(s) (since 2026-09-05)

Views: 312
Visitors: 141
Bot hits: 6 (excluded above)

Top pages:
  1. /pricing (89)
  2. / (64)

Referrers:
  https://news.ycombinator.com/ (120)
  https://www.google.com/ (74)

Daily:
  2026-09-05: 88 views
  2026-09-06 → 2026-09-09: none (4 days)
  2026-09-10: 132 views
  2026-09-11 → 2026-09-12: none (2 days)

Generated: 2026-09-12T07:00:04.212Z

GET /api/stats

The dashboard's own endpoint. JSON, and it requires a signed-in session or an agent key — a site key is public, so knowing one is never enough to read a site's numbers.

ParamRequiredNotes
siteIdone ofSite uuid.
siteKeyone ofThe public site key, if you have not stored the id.
daysno1–90, defaults to 7.
{
  "site": {
    "id": "8f1c…",
    "name": "CareerJot",
    "domain": "careerjot.coralorbit.studio",
    "siteKey": "623a8f6842dadf1b02",
    "plan": "free",
    "verified": true
  },
  "period": { "days": 7, "since": "2026-10-15" },
  "totals": { "views": 42, "visitors": 31, "bots": 4 },
  "byDay": [{ "day": "2026-10-21", "views": 9, "visitors": 8 }],
  "topPages": [{ "path": "/", "views": 22 }],
  "referrers": [{ "referrer": "https://news.ycombinator.com/", "views": 7 }],
  "devices": [{ "device": "mobile", "views": 18 }],
  "countries": [{ "country": "GB", "views": 20 }]
}

byDay always contains one entry per day in the window, zeros included, so a gap in traffic looks like a gap.

POST /api/track

Called by the script, not by you. Documented because a misconfigured script is the most common reason a site looks dead.

POST https://shoalanalytics.dev/api/track
Content-Type: text/plain;charset=UTF-8

{"site":"YOUR_SITE_KEY","type":"pageview","path":"/pricing",
 "referrer":"https://example.com/","device":"desktop"}
  • • Returns 204 with no body.
  • • The body is sent as text/plain so the request stays a CORS-simple POST and needs no preflight.
  • • device from the script wins, because viewport width beats user-agent sniffing; the server falls back to the user agent if it is missing.
  • • An unknown site key returns 404 so a broken install is loud, not silent.

Wiring an agent

Anything that can make an HTTP request can read Shoal: a cron job with curl, a scheduled function, an assistant with a tool definition.

GET https://shoalanalytics.dev/api/rundown?days=7
Authorization: Bearer YOUR_AGENT_KEY

Summarize this traffic report in three sentences.
Call out any day with more than double the usual views,
and suggest one practical next step.

For a daily briefing, run that each morning and deliver the summary to wherever you already read messages. Keep the key in the agent's secret store — not in the prompt, which is usually logged.

Status codes

CodeMeaningFix
204TrackedNothing to do. No body is returned on purpose.
400Bad requestMissing or malformed fields — check the body in this page.
401UnauthorizedNo session and no key, or the key was revoked. Check the Bearer prefix.
403Out of scopeA scoped key asked about a site it cannot see, or a plan limit was hit.
404Not foundUnknown site key, or the site is not yours.
409ConflictThat domain is already being tracked on this account.
429Over allowanceThe site used its monthly page views. Bots are not counted, and the counter resets on the 1st.

Limits

FreePro
Sites120
Agent keys120
Page views a month, all sites10,000100,000
Data retention12 months, rollingEverything, kept
Badge on your siteNoneNone

Pro is £6 a month or £60 a year, and the free plan keeps working exactly as it does now. Bots are recorded on both plans but never counted against the page view allowance, and neither plan puts a badge on your site. If an account ends up with more sites than its plan includes — after a subscription is cancelled, say — the oldest sites keep recording and the newest pause. Nothing is deleted, and resubscribing resumes them.

FAQ

Why does the same person show as one visitor after browsing in a private window?

The visitor hash comes from the address, the user agent and today's date — not from cookies or storage. A private window is the same machine, so it is correctly the same visitor. A different browser or network would count separately.

Why is every visit counted for one day and then forgotten?

Today's date is part of the hash, so yesterday's hashes do not match today's. That is what makes it impossible to follow anyone across days — and it is why there is no per-visitor history, by design.

Why is country 'Unknown' when I test locally?

Country comes from the hosting edge (Netlify's x-country, or x-nf-geo). On localhost there is no edge, so there is no country. Shoal never geolocates an IP itself.

Do I need a cookie banner?

Shoal sets no cookies and stores nothing on the visitor's device. It does compute a daily hash from the request, so a person is counted once rather than on every page view; that hash is replaced at midnight UTC and cannot be linked across days. That combination is usually enough for first-party analytics to sit outside consent — but the decision is yours, and it depends on your jurisdiction.

What happens if I delete a site?

Its recorded page views go with it, and any agent key scoped to that site only is revoked. The confirmation tells you how many views you are about to lose before you commit.

Is there an SDK?

No, and there will not be one for reading data. curl and fetch are the SDK. The tracking side is a single script tag.

Need help?

Something not working, or a number that looks wrong? Email the studio — it is one person, so you get an actual answer rather than a ticket number.

contact@coralorbit.studioDiscord — coming soon

Bug reports and “why is this number wrong” questions are both welcome