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. Open the dashboard and add your site. You get a site key that looks like
623a8f6842dadf1b02. - 2. Paste the snippet into your site's HTML — see Install.
- 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. Issue an agent key in the Agent desk on the dashboard. Copy it then — it is never shown again.
- 5. Call
/api/rundownwith 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.
| Field | Example | Notes |
|---|---|---|
| path | /pricing?ref=x | Path and query string, trimmed to 500 characters. |
| referrer | https://news.ycombinator.com/ | Where the visitor came from, if the browser sent it. |
| device | mobile | desktop / mobile / tablet / bot. |
| country | GB | From the host's edge header. Never from the IP. |
| visitor_hash | 1a0656ee… | Salted, rotates daily. Cannot be linked across days. |
| day | 2026-10-21 | UTC 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.
| Param | Required | Notes |
|---|---|---|
| site | no | Site id or site key. Omit to report on every site the key can see. |
| days | no | 1–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.
| Param | Required | Notes |
|---|---|---|
| siteId | one of | Site uuid. |
| siteKey | one of | The public site key, if you have not stored the id. |
| days | no | 1–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
204with no body. - • The body is sent as
text/plainso the request stays a CORS-simple POST and needs no preflight. - •
devicefrom 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
404so 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
| Code | Meaning | Fix |
|---|---|---|
| 204 | Tracked | Nothing to do. No body is returned on purpose. |
| 400 | Bad request | Missing or malformed fields — check the body in this page. |
| 401 | Unauthorized | No session and no key, or the key was revoked. Check the Bearer prefix. |
| 403 | Out of scope | A scoped key asked about a site it cannot see, or a plan limit was hit. |
| 404 | Not found | Unknown site key, or the site is not yours. |
| 409 | Conflict | That domain is already being tracked on this account. |
| 429 | Over allowance | The site used its monthly page views. Bots are not counted, and the counter resets on the 1st. |
Limits
| Free | Pro | |
|---|---|---|
| Sites | 1 | 20 |
| Agent keys | 1 | 20 |
| Page views a month, all sites | 10,000 | 100,000 |
| Data retention | 12 months, rolling | Everything, kept |
| Badge on your site | None | None |
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.
Bug reports and “why is this number wrong” questions are both welcome