Documentation

One script tag, and everything it unlocks.

PageView.ro collects through a single asynchronous tag of about 2.5KB gzipped. This page documents exactly what it sends, which optional attributes turn on author, category and returning-visitor reporting, what the WordPress plugin puts inside wp-admin, and how to read the same numbers over the public API.

Step 1

Install the tracker

Paste one tag into the <head> of every page. There is nothing else to configure client-side — no init call, no dependencies.

One tag, ~2.5KB gzipped
<script defer src="https://t.pageview.ro/tracker.v1.js"
        data-site="pk_your_public_key"></script>
  • data-site

    Your site's public key (pk_…), copied from Settings → Install & tracking in the dashboard. It identifies the site and grants no read access, so it is safe in public HTML. It never rotates — changing it would break every installed tag.

  • Where it goes

    In <head>, on every page, ideally before other analytics or ad tags. The tag is deferred and dependency-free, so it never blocks rendering or parsing.

  • Size

    5,314 bytes raw, 2,565 bytes gzipped — about 2.5KB over the wire. The build fails outright if the bundle ever crosses a 20KB gzip budget, so it cannot quietly grow.

  • Transport

    sendBeacon first, fetch with keepalive as fallback, for every event type. The body goes as text/plain — a CORS-safelisted content type — so the browser never issues a preflight request.

  • Responses

    202 when an event is accepted, 204 when it is rejected (unknown key, disallowed origin, rate limit). Never a 4xx/5xx for a reader's browser to deal with, and the tracker swallows every error rather than throwing into your page.

  • Time to first data

    The first pageview beacon fires as soon as the script executes — it is not deferred behind idle callbacks. The realtime board receives a fresh snapshot roughly every two seconds, and a reader counts as live within a 30-second activity window kept alive by a fixed 10-second heartbeat.

  • Single-page apps

    history.pushState / replaceState, popstate and hashchange are wrapped: a real route change sends a new pageview, hash-only changes are ignored, and a Back/Forward (bfcache) restore re-fires one.

  • Reloads

    Reloading the same path in the same tab within 30 minutes counts as presence, not consumption: the first beacon downgrades to a heartbeat, so the reader stays live and the visit is not counted twice.

  • Restricting origins

    Settings → Install & tracking → Allowed domains limits collection to the hosts you list. Left empty, events are accepted from any origin that uses your public key.

Check that it works

Load your site with DevTools open on the Network tab and look for requests to t.pageview.ro/api/event — a 202 means the event was accepted. The page should appear on your live board within seconds.

Step 2

Optional tag attributes

Only data-site is required. Everything below is optional and additive: adding one turns a report on, leaving it out changes nothing else about how the tracker behaves.

Optional tag attributes
AttributeWhat it unlocks
data-siterequiredThe site's public key. Without it the tag does nothing at all.
data-authorThe byline for this page. Fills the Authors leaderboard and the author filter. Travels on the pageview; pings inherit it server-side.
data-sectionThe category for this page — the wire field and column are still called section, the reports call it category. Fills the Categories leaderboard and the category filter.
data-articleYour own article id, stored next to the path. Useful when you match analytics rows back to CMS records.
data-personOpt-in first-party visitor id — the dashboard's snippet emits data-person="1". Enables new-vs-returning reporting, and only takes effect when the per-site toggle is on. It stores an id in localStorage and a cookie, so consent is yours to collect (see Privacy below).
data-canonicalTurns the tag into a syndication marker rather than a tracker. On your own domain it does nothing at all; on a foreign host that republished your HTML it sends exactly one syndication beacon — no identity, no pings — which surfaces in the Aggregators panel.
data-endpointOverrides the collection endpoint. Defaults to https://t.pageview.ro/api/event; you only need it for a proxied or self-hosted ingest.
Article page with editorial metadata
<script defer src="https://t.pageview.ro/tracker.v1.js"
        data-site="pk_your_public_key"
        data-author="Ana Pop"
        data-section="politics"
        data-article="12345"></script>

If your template cannot add attributes to the script tag, the tracker reads the same three values from meta tags instead:

The same values as meta tags
<meta name="afm:author"  content="Ana Pop">
<meta name="afm:section" content="politics">
<meta name="afm:article" content="12345">

Auto-detection

When neither the attribute nor the meta tag is present, the tracker fills author and category itself, in this order:

  1. 01<meta name="author"> and <meta property="article:section"> — what Yoast and Rank Math already output.
  2. 02schema.org JSON-LD: author.name and articleSection.
  3. 03the WordPress body class category-<slug>.

So a standard WordPress news site reports author and category with no markup changes. The page title is taken from document.title (truncated at 200 characters) and needs no markup either.

Opting a reader out

  • Global Privacy Control: if the browser sets navigator.globalPrivacyControl, the tracker sends nothing.
  • localStorage: setting afm_optout to "1" has the same effect. window.__afm.optout() writes it for you and takes effect immediately, across every tag on the page.
  • In the default configuration there is no cookie to clear — the tracker sets none.

Custom events

Record a custom event
window.__afm.track("video-played", { quartile: "50" });
  • window.__afm.track(name, props?) records a custom event of your own choosing (e.g. a video play, a signup, a paywall hit). It never throws, and calls made after the tag has run but before the first pageview is sent are queued and flushed automatically.
  • window.__afm only exists once the (deferred) tag has executed — code that might fire earlier should guard the call (window.__afm && window.__afm.track(...)) or paste this one-line stub before it: window.__afm = window.__afm || { q: [], track(n, p) { if (this.q.length < 20) this.q.push([n, p]); } }; — the tracker adopts the stub's queue when it loads (do not add an optout stub: the real one would never install).
  • name must be lowercase and match ^[a-z0-9][a-z0-9_.:-]{0,63}$ — anything else is silently dropped, not sent.
  • props is optional: flat key → value pairs (strings, numbers and booleans are stringified), up to 10 entries, 32 bytes per key, 200 bytes per value.
  • Up to 60 events per page view are accepted; custom events never carry the first-party visitor id and are not on the public read API yet.
Read the full guide — API reference, six ready-to-paste recipes, limits
WordPress

The WordPress plugins

Next Stage Analytics connects a WordPress site to PageView.ro with the site API key and puts the numbers where editors already work — inside wp-admin. Every call to PageView.ro is made server-side in PHP; the browser only ever talks to your own WordPress.

  • “Trafic” column in the posts list

    Adds a traffic column to Posts → All Posts (edit.php) showing each published article's total pageviews over roughly the last 400 days. Values are filled asynchronously, so the list never waits on an external call; unpublished posts show a dash.

  • “Trafic azi” in the admin toolbar

    A counter on the right-hand side of the admin bar with today's pageviews for the site's local day, filled asynchronously behind a short (~2 minute) cache. Clicking it opens the PageView.ro dashboard.

  • Dashboard widget

    On the wp-admin dashboard: today's traffic, an inline-SVG chart of the last 7 days (no external libraries) and the top 5 articles of the week, each linked. It renders instantly with placeholders and fills from one composed request cached ~10 minutes.

  • Post editor metabox

    A “Trafic articol” box in the editor sidebar — identical in Gutenberg and the classic editor — with views and visitors for the last 24 hours, 7 days and 30 days, plus the article's top traffic sources over 7 days. Unpublished posts make no API call at all.

  • Tracker injection (optional)

    The plugin can add the tracking tag to <head> itself using your public pk_ key, for sites that don't already have the snippet in the theme. On a single post it also passes data-author and data-section straight from WordPress — the real author and the primary category (Yoast's primary if set, otherwise the first assigned one) — which the tracker prefers over any client-side guess.

  • AI / aggregator marker (optional)

    Injects a hidden attribution note plus a tracker tag carrying data-canonical into published posts. Copies republished with the HTML intact then surface in the dashboard's Aggregators panel. Best-effort by design: RSS feeds are excluded, and an aggregator that strips scripts cannot be detected this way.

The [pageview] reader widget

A public, reader-facing live strip you can drop into an article: readers on the site now, readers of this article today, visitors today and visitors this month, with a “Powered by PageView.ro” badge. If the plugin isn't configured it renders nothing at all.

Examples
[pageview]
[pageview show="live,articol" lang="en"]
[pageview show="total,live"]
  • show

    Comma-separated subset of live, articol, total, luna in the order you want them (default: all four). Synonyms: article ≡ articol, azi/today ≡ total, month/monthly ≡ luna. Unknown tokens are ignored, and the articol metric appears only on singular pages.

  • lang

    ro (default) or en — the language of the labels.

  • design

    Accepted but currently ignored; there is one design today and the attribute is reserved for future variants.

  • Caching

    Every metric is cached server-side (live 15s, today 60s, per-article 120s, month 300s) behind an anti-stampede guard. After load only the live number refreshes in place, and only while the tab is visible — no invented numbers in between.

Where the API key lives

  • The pv_ API key is stored in WordPress options and used only from PHP. The browser talks to your own site — admin-ajax, or a lighter dedicated route — never to PageView.ro directly.
  • The public tracker key (pk_) is a separate setting, and the sanitizer rejects any value that isn't a pk_ key, so the secret read key cannot be published into a page by mistake.
  • If the plugin isn't configured or the API doesn't answer, nothing renders — no visible errors on the front end, no blocked admin screens.

Requirements

  • WordPress 5.6+ and PHP 7.4+.
  • A site API key from Settings → API access, plus the API base URL (https://app.pageview.ro by default).
  • The Next Stage Schedule plugin only if you want traffic badges on its Social Schedule calendar; everything else works standalone.
  • The plugin is distributed directly rather than through the WordPress.org directory.

Next Stage Cross (optional)

A companion plugin for publisher networks. When an article passes a live-reader threshold you set, PageView.ro sends an HMAC-signed webhook to another site in your network, which verifies the signature and imports the article — with an exactly-once ledger, so an evergreen story never fires twice. It is a separate, entirely optional plugin; nothing in the reporting above depends on it.

API

Public read API

Every historical report the dashboard draws is also readable over HTTP with a per-site key. Read-only, no dashboard session required — it is the same API the WordPress plugin uses.

Authentication

  • A site admin mints the key in Settings → API access (Generate / Regenerate / Revoke). It looks like pv_… and is a secret: anyone holding it can read that site's analytics.
  • Send it as the header x-api-key: pv_… or as authorization: Bearer pv_….
  • The key resolves to exactly one site. There is no site_id parameter to pass, so a leaked key can never reach another site's data.
  • The base URL is your dashboard origin — https://app.pageview.ro — and is shown next to the key.
Public read API
EndpointReturns
GET /api/v1/timeseriesPageviews, unique visitors and engaged seconds per minute, hour or day. granularity=total returns a single aggregate row for the whole window.
GET /api/v1/pagesTop pages: path, article id, title, pageviews, visitors, average engaged seconds and average scroll depth.
GET /api/v1/sourcesTraffic per channel and referrer domain. With granularity=hour|day it becomes a channels-over-time series instead.
GET /api/v1/breakdownAudience mix for the range: countries, device split and UTM source/medium/campaign. JSON only.
GET /api/v1/authorsPer-byline totals: pageviews, visitors, average engaged seconds and the number of distinct articles.
GET /api/v1/categoriesThe same figures, per category.
GET /api/v1/returningNew vs returning people for the range. Requires the opt-in first-party id; returns zeros when it is off.
GET /api/v1/live{ "concurrents": n } — readers on the site right now, using the same definition as the realtime board.
Hourly pageviews for one day
curl -s -H "x-api-key: pv_your_api_key" \
  "https://app.pageview.ro/api/v1/timeseries?from=1782864000&to=1782950400&granularity=hour"
Response
[
  { "t": 1782864000, "pageviews": 1200, "visitors": 800, "engaged_sec": 54321 },
  { "t": 1782867600, "pageviews": 940,  "visitors": 612, "engaged_sec": 41180 }
]

Parameters worth knowing

  • from / to

    Unix seconds, half-open [from, to). Required on every report except live, with to greater than from and a span of at most 400 days.

  • granularity

    Required on timeseries: minute | hour | day | total. Optional on sources, where hour | day switches the response to a channels-over-time series (minute and total are rejected there).

  • limit

    On pages, authors and categories. Authors and categories cap at 100 rows.

  • precise=1

    Forces the exact raw-event path instead of the pre-aggregated rollups. Worth setting for short, recent ranges — and for any trailing window that isn't hour-aligned, where the hourly rollup would drop the older bucket's partial contribution.

  • Scope filters

    author, category, article, source, country, device, utm_source, utm_medium and utm_campaign on timeseries, pages, sources and breakdown. They are ANDed, and any filter switches the report to raw events — so results reach back at most 90 days.

  • format=csv

    On timeseries, pages, sources, authors and categories. breakdown, returning and live are JSON only.

Errors

  • 401 — the key is missing or invalid.
  • 404 — the report name is not one of the eight above.
  • 400 — the range is missing or invalid (to must be greater than from, span at most 400 days), or granularity is missing/invalid where it is required.
  • Anything else is the reporting backend's own status, passed through unchanged.
Privacy & limits

What's collected, what's kept

The default configuration is cookieless. Read this section before you deploy — two of the items below are limits, not features, and they change how you should read the numbers.

  • Cookieless by default

    The visitor id is a SipHash of user agent, IP address and domain, computed with a salt that rotates at UTC midnight. Raw IP and raw user agent are never persisted, no cookie is set, and there is no cross-site tracking.

  • What one event carries

    URL path, referrer, device class, browser, a country derived server-side from the IP, and whatever editorial metadata you choose to send (author, category, title, article id). No names, no email addresses, no free-text personal data.

  • Opt-in first-party identity

    person_id (afm_pid) is per-site and off by default. It stores a first-party id in localStorage and a cookie, so it is explicitly not cookieless: under ePrivacy you are the controller and obtaining consent is your responsibility — we process on your behalf. The client-side id regenerates after 365 days.

  • Opt-out

    Global Privacy Control and the afm_optout flag are honoured by the tracker. An opted-out reader sends nothing at all — not a pageview, not a heartbeat.

  • Erasure

    Analytics data can be erased for a single person id or for an entire site, across raw events, the rollups and the visitor profiles.

Retention

Retention
DataKept for
Raw events — what every filtered or precise report reads90 days
Per-minute site totals13 months
Hourly page and source rollups25 months
Visitor profiles — only with the opt-in first-party id18 months
Hits from JavaScript-executing bots30 days
Syndication / aggregator detections90 days

Limits worth knowing up front

  • Long-range unique visitors are approximate

    For two reasons: bucketed reports count uniques with an approximate sketch, and the cookieless id is re-salted every UTC midnight, so one person returning on three days counts as three. Short, recent ranges are read exactly from raw events; exact cross-day identity needs the opt-in first-party id.

  • Some reports cannot see past 90 days

    Author, category, audience, per-article and aggregator reports read raw events — the rollups carry none of those dimensions — so they are bounded by the 90-day window and flag partial: true when your range starts earlier. Pageview and source totals go back 25 months.

  • Only bots that run JavaScript are visible

    A crawler that never executes the tag never reaches ingest, so bot reporting covers JS-executing bots only. Recognized bots are kept out of every human metric and stored separately.

  • Reloads are deliberately not pageviews

    Reloading the same path in the same tab within 30 minutes keeps the reader live without recording a second pageview. That is intentional — it keeps concurrents honest — but it is a real difference from tools that count every load.

Want a key to try this against?

Early access is invite-only for now. Ask for access and we'll get your board streaming.