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.

index.html
<script defer src="https://api.wholetally.com/script.js" data-site="your-site-id"></script>

Options

AttributeWhat it does
data-siteRequired. Your site id, shown on the Install page in the app.
data-outboundCounts clicks on links to other sites as an outbound event.
data-auto="false"Turns off the automatic pageview. Call wt.pageview() yourself.
data-apiSends hits to another endpoint, for example your own proxy.
data-allow-localhostCounts hits on localhost. Off by default, so development stays out of your stats.

JavaScript API

CallWhat 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".

index.html
<script defer src="/_wt/script.js" data-site="your-site-id"></script>

Cloudflare Worker

worker.js
// 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

vercel.json
{
  "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

next.config.js
module.exports = {
  async rewrites() {
    return [{ source: '/_wt/:path*', destination: 'https://api.wholetally.com/:path*' }];
  },
};

Nginx

nginx.conf
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

  1. Create an edge key on the Install page in the app. It starts with wte_ and is shown once.
  2. Configure the Worker with your site id and our endpoint.
  3. Store the key as a secret and deploy the Worker on the route that serves your HTML.
wrangler.jsonc
{
  "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" }
}
Terminal
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.

middleware.ts
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.

index.html
<script>window.wt=window.wt||{q:[],track:function(){this.q.push(arguments)}};</script>
signup.js
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:

Terminal
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.

  1. In the app, open your site’s Settings → Revenue connections and copy the Stripe webhook URL.
  2. 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.paid and charge.refunded.
  3. Paste the endpoint’s signing secret (whsec_…) into the app. No Stripe API key is needed.
checkout.js
// 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.

Terminal
curl "https://api.wholetally.com/api/v1/stats/your-site-id/overview?period=30d" \
  -H "Authorization: Bearer wt_…"

Periods and filters

ParameterValues
periodtoday, yesterday, 24h, 7d, 30d, 90d or 12mo
from, toInstead of period: epoch milliseconds or ISO dates (to is inclusive). Up to 400 days.
path, source, channel, country, device, browser, osFilter any stats endpoint.
limitFor 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 Code
claude mcp add --transport http wholetally https://api.wholetally.com/mcp --header "Authorization: Bearer wt_…"
mcp.json
{
  "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