Docs
Set it up in ten minutes.
Install the script, serve it from your domain, add the edge collector and read your numbers over the API. Wholetally is new, so details can still change.
Install the script
Paste this into the <head> of every page. The Install page in the app shows it with your site id filled in. It sets no cookies and stores nothing on the device. Single-page apps work out of the box: History API navigation counts as a pageview.
<script defer src="https://api.wholetally.com/script.js" data-site="your-site-id"></script>
Options
| Attribute | What it does |
|---|---|
data-site | Required. Your site id, shown on the Install page in the app. |
data-outbound | Counts clicks on links to other sites as an outbound event. |
data-auto="false" | Turns off the automatic pageview. Call wt.pageview() yourself. |
data-api | Sends hits to another endpoint, for example your own proxy. |
data-allow-localhost | Counts hits on localhost. Off by default, so development stays out of your stats. |
JavaScript API
| Call | What it does |
|---|---|
wt.track(name, props?) | Sends a custom event. Property values are strings, numbers or booleans. |
wt.pageview() | Sends a pageview. Only needed with data-auto="false"; history navigation is counted automatically. |
wt.getAttribution() | Returns this visit’s source, medium, campaign, landing page and referrer. Kept in memory only. |
wt.checkoutMetadata() | The same, as wt_ keys for Stripe metadata. |
Serve it from your domain
Blockers block known analytics hosts. From your own domain the script is a first-party request, and with a proxy your visitors’ IP addresses never reach us: it forwards only the country header and the user agent. Pick one for your host, then change the script tag to src="/_wt/script.js".
<script defer src="/_wt/script.js" data-site="your-site-id"></script>
Cloudflare Worker
// Route: example.com/_wt/*
export default {
async fetch(req) {
const url = new URL(req.url);
const upstream = 'https://api.wholetally.com' + url.pathname.slice('/_wt'.length) + url.search;
const headers = new Headers({
'content-type': req.headers.get('content-type') || 'text/plain',
'user-agent': req.headers.get('user-agent') || '',
'x-wt-country': req.cf?.country || '', // no IP is forwarded
});
return fetch(upstream, { method: req.method, headers, body: req.method === 'POST' ? req.body : undefined });
},
};
Vercel
{
"rewrites": [
{ "source": "/_wt/:path*", "destination": "https://api.wholetally.com/:path*" }
]
}
A rewrite may forward the visitor’s IP address in a header. We ignore it and never store it; to keep it from reaching us at all, use the Worker or Nginx.
Next.js
module.exports = {
async rewrites() {
return [{ source: '/_wt/:path*', destination: 'https://api.wholetally.com/:path*' }];
},
};
Nginx
location /_wt/ {
proxy_pass https://api.wholetally.com/;
proxy_set_header Host api.wholetally.com;
proxy_set_header X-Forwarded-For ""; # don't forward the IP
proxy_set_header X-Real-IP "";
proxy_set_header X-WT-Country $geoip2_country_code; # optional, with the GeoIP2 module
proxy_ssl_server_name on;
}
Edge collector
Counts the HTML pages the script can’t: blocked scripts, browsers without JavaScript and AI crawlers. It sends one small beacon after the response is on its way, so visitors don’t wait. Script and edge hits are matched and counted once; read how that works and where it falls short.
Cloudflare Worker
- Create an edge key on the Install page in the app. It starts with
wte_and is shown once. - Configure the Worker with your site id and our endpoint.
- Store the key as a secret and deploy the Worker on the route that serves your HTML.
{
"name": "example-edge",
"main": "cloudflare-worker.js",
"compatibility_date": "2026-10-01",
"vars": { "WT_SITE_ID": "your-site-id", "WT_ENDPOINT": "https://api.wholetally.com/api/edge" }
}
npx wrangler secret put WT_EDGE_KEY # paste the wte_… key
npx wrangler deploy
Next.js middleware
Not on npm yet: copy edge-collector/src/next.ts and collector.ts into your project (MIT). Middleware runs before the page exists, so script and edge hits are matched on a short time window instead of a request id.
import { nextCollector } from './edge-collector/next';
const collect = nextCollector({
siteId: process.env.WT_SITE_ID!,
edgeKey: process.env.WT_EDGE_KEY!,
endpoint: 'https://api.wholetally.com/api/edge',
});
export function middleware(req: Request, event: { waitUntil(p: Promise<unknown>): void }) {
collect(req, event);
}
export const config = { matcher: ['/((?!_next|_wt|api|.*\\..*).*)'] };
Events and goals
Call the script from anywhere. If your code can run before the script loads, add the queue first; queued calls are sent once it arrives.
<script>window.wt=window.wt||{q:[],track:function(){this.q.push(arguments)}};</script>
window.wt.track('signup', { plan: 'starter' });
Property names are cut at 40 characters and string values at 200; a set over 2,000 characters is dropped. In strict compliance mode properties aren’t stored.
Goals
A goal is an event name or a page path, with its conversion rate. Paths can end in * to match a section. Add them in the app or with the API:
curl -X POST https://api.wholetally.com/api/v1/sites/your-site-id/goals \
-H "Authorization: Bearer wt_…" \
-H "content-type: application/json" \
-d '{ "name": "Checkout", "kind": "path", "match": "/checkout/thanks*" }'
kind is event or path; name up to 80 characters, match up to 200. Needs a token with write access.
Funnels
A funnel is 2 to 8 ordered steps: a page path, a custom event or an outbound click. Let the AI propose steps from your pages and events (you accept or edit them), or set them up by hand. You see visitors per step, conversion, drop-off, the previous period and a split by channel, including AI assistants.
Cookieless: a visitor is followed only within one UTC day, using the same daily-rotating salted hash as unique visitor counting. Only per-day aggregates are kept, so a journey that spans two days counts as two. Not available in strict compliance mode.
Revenue
Stripe (incl. iDEAL), in test mode for now: live payments are switched on at launch. At checkout, pass the visit’s source as metadata; the Stripe webhook brings it back with the amount.
- In the app, open your site’s Settings → Revenue connections and copy the Stripe webhook URL.
- In Stripe (test mode), go to Developers → Webhooks, add an endpoint with that URL and the events
checkout.session.completed,checkout.session.async_payment_succeeded,invoice.paidandcharge.refunded. - Paste the endpoint’s signing secret (
whsec_…) into the app. No Stripe API key is needed.
// Browser: send the visit's source to your server with the order
const metadata = window.wt.checkoutMetadata(); // wt_source, wt_medium, wt_campaign, wt_landing, wt_ref
// Server: Stripe Checkout
await stripe.checkout.sessions.create({
// ...line items, mode, URLs
metadata,
payment_intent_data: { metadata }, // one-off payments: refunds keep the source
// subscription_data: { metadata }, // subscriptions: renewals keep the source
});
Read it back with GET /api/v1/stats/{siteId}/revenue, /revenue/breakdown/{by} (source, channel, campaign, landing, provider) or the revenue_by_source tool. Amounts are in cents.
REST API overview
JSON over HTTPS at https://api.wholetally.com/api/v1. Create a token on the Account page in the app (it starts with wt_), with read, write or admin access, for all sites or only some. Public sites can be read without a token.
curl "https://api.wholetally.com/api/v1/stats/your-site-id/overview?period=30d" \
-H "Authorization: Bearer wt_…"
Periods and filters
| Parameter | Values |
|---|---|
period | today, yesterday, 24h, 7d, 30d, 90d or 12mo |
from, to | Instead of period: epoch milliseconds or ISO dates (to is inclusive). Up to 400 days. |
path, source, channel, country, device, browser, os | Filter any stats endpoint. |
limit | For lists: 1 to 1,000, default 20. |
Every stats response has site, range and data. The full schema is at api.wholetally.com/api/v1/openapi.json.
Endpoints
Collect
- GET
/script.jsThe tracker. MIT licence, under 2 KB gzipped. - POST
/api/eventPageviews and events from the script. - POST
/api/edgeEdge collector hits: one object, or { hits: [...] } with up to 100. Needs the edge key.
Sites and goals
- GET
/api/v1/sitesYour sites. - POST
/api/v1/sitesAdd a site. Returns the install snippets. - GET
/api/v1/sites/{siteId}One site and its settings. - PATCH
/api/v1/sites/{siteId}Change settings: hash mode, compliance mode, retention, public, outbound links. - GET
/api/v1/sites/{siteId}/installInstall snippets for this site. - POST
/api/v1/sites/{siteId}/edge-keyCreate or rotate the edge key. Shown once. - GET
/api/v1/sites/{siteId}/goalsList goals. - POST
/api/v1/sites/{siteId}/goalsAdd a goal. - DELETE
/api/v1/sites/{siteId}/goals/{goalId}Remove a goal.
Stats
- GET
/api/v1/stats/{siteId}/overviewVisitors, pageviews, edge-only pageviews, events and AI crawler requests. - GET
/api/v1/stats/{siteId}/timeseriesPer day or per hour (interval=day|hour). - GET
/api/v1/stats/{siteId}/pagesTop pages. - GET
/api/v1/stats/{siteId}/breakdown/{dim}channel, source, referrer, campaign, medium, country, device, browser, os or lang. - GET
/api/v1/stats/{siteId}/eventsCustom events. - GET
/api/v1/stats/{siteId}/goalsGoals with conversion rates. - GET
/api/v1/stats/{siteId}/funnels/{funnelId}Funnel: visitors per step, conversion, drop-off, previous period, by channel. - GET
/api/v1/stats/{siteId}/ai/crawlersAI crawler requests per bot. - GET
/api/v1/stats/{siteId}/ai/crawlers/pagesWhich pages AI crawlers read. - GET
/api/v1/stats/{siteId}/ai/referralsVisitors from AI assistants. - GET
/api/v1/stats/{siteId}/compareThis period against the previous one. - GET
/api/v1/stats/{siteId}/realtimeVisitors right now. - GET
/api/v1/stats/{siteId}/export.csvRaw hits as CSV, up to 100,000 rows.
Tokens
- GET
/api/v1/tokensYour API tokens. - POST
/api/v1/tokensCreate a token: read, write or admin, optionally for some sites only. - DELETE
/api/v1/tokens/{id}Revoke a token.
Also: revenue under /stats and the Stripe webhook (see Revenue above). The weekly report can also arrive by email every Monday (Settings).
MCP server
Connect Claude Code, Cursor or your own agent with read-only tools. Create a key under Account in the app, then add the server. Claude.ai and ChatGPT connectors need OAuth sign-in, which follows later. See the MCP page.
claude mcp add --transport http wholetally https://api.wholetally.com/mcp --header "Authorization: Bearer wt_…"
{
"mcpServers": {
"wholetally": {
"url": "https://api.wholetally.com/mcp",
"headers": { "Authorization": "Bearer wt_…" }
}
}
}
What we collect
No cookies, no IDs, nothing stored on the device. The full field-by-field list is on What we collect.
We store
- Page path and UTM tags
- Other query parameters are dropped.
- Referrer hostname
- Where the visit came from.
- Country
- From your CDN’s country header. We don’t look up IP addresses.
- Browser, OS, device type
- Derived from the user agent; the raw string isn’t kept.
- Language and window width
- Width is stored as mobile, tablet or desktop.
- Event names and properties
- Only what you send with wt.track().
We never
- Cookies, localStorage or any other storage on the device
- Visitor, user or device IDs, or fingerprints
- IP addresses
- Selling data, or using it for ads