Beyond the pageview: track what readers actually do.
window.__afm.track(name, props?) records a named event of your own choosing — a video play, a newsletter signup, a poll vote — through the same ~2.5KB tracker you already installed. No extra script, no init call, no dashboard toggle. This page is the complete reference: the API, six ready-to-paste recipes, how to name events well, and exactly where the numbers show up.
Nothing to configure
If the tracker tag from the main install guide is already on the page, custom events need nothing else — no per-site toggle, no separate script, no build step.
<script defer src="https://t.pageview.ro/tracker.v1.js"
data-site="pk_your_public_key"></script>
<script>
window.__afm.track("newsletter-signup", { placement: "footer" });
</script>No setup required
track() is part of the same tracker you already installed. There is no feature flag to flip and nothing to enable per site.
Shows up in seconds
The event fires a beacon immediately. It appears on the Realtime board's Events card within a couple of seconds, and in the Events report shortly after.
Safe to call early or often
track() never throws into your page. Calls made before the tracker finishes loading are queued and flushed automatically, and a per-page-view budget caps runaway calls so a bug in your own code can't flood your own report.
Works from anywhere
A click handler, a video player's own events, a React effect, a WordPress theme's inline script — track() is a plain global function, not tied to any framework.
The track() API
One function, two arguments. Everything else is validation you don't have to think about: invalid input is dropped silently, never sent half-broken.
window.__afm.track(
name: string,
props?: Record<string, string | number | boolean>
): void| Rule | What it means |
|---|---|
| namerequired | Lowercase, matching ^[a-z0-9][a-z0-9_.:-]{0,63}$ — letters, digits, hyphens, underscores, dots and colons, starting with a letter or digit, at most 64 bytes. Anything else and the whole event is dropped, not partially sent. |
| props | Optional flat key → value pairs. Strings, numbers and booleans are stringified for you automatically — you never call String() yourself. Up to 10 entries; extras beyond the 10th are dropped, not merged. |
| prop keys | Same charset as the event name, at most 32 bytes. An invalid key drops only that one entry — the rest of the event still sends. |
| prop values | Up to 200 bytes of UTF-8 text once stringified. An oversized value is dropped whole — never silently truncated — so trim long free text (like a search query) yourself before calling track(). |
| budget per page view | Up to 60 accepted events. Beyond that, extra calls are silently dropped for the rest of that view — protecting your own report from a runaway loop. The budget resets on every new page view, including single-page-app route changes. |
| identity | Never carries the opt-in first-party visitor id, and never carries any pageview-only field (referrer, scroll depth, engaged time). A custom event is its own thing: an anonymous count of what happened, not a record of who did it. |
| transport & response | Sent as its own event type over the exact same channel as a pageview — sendBeacon first, fetch with keepalive as fallback, no CORS preflight. The server answers 202 when accepted and 204 when rejected; your code never sees either. |
Calling track() before the tag has finished loading
window.__afm only exists once the (deferred) tracker script has executed. Code that might fire earlier — an inline script near the top of <head>, for instance — should guard the call, or paste this one-line stub above it:
window.__afm = window.__afm || {
q: [],
track(n, p) {
if (this.q.length < 20) this.q.push([n, p]);
},
};
window.__afm.track("early-event", { source: "inline" });The real tracker adopts the stub's queue the moment it loads and flushes every call through it — don't add an optout() method to the stub, or the real one would never install over it.
Six things newsrooms actually track
Copy, paste, adjust the selectors. Each recipe uses ONE stable event name for the whole action — see “Naming events well” below for why that matters.
Video engagement
Know which videos actually hold attention, not just which pages have a player.
const video = document.querySelector("video");
const firedAt = new Set();
function maybeFire(pct) {
if (firedAt.has(pct)) return;
firedAt.add(pct);
window.__afm.track("video-played", {
quartile: String(pct),
title: video.dataset.title,
});
}
video.addEventListener("timeupdate", () => {
const pct = Math.floor((video.currentTime / video.duration) * 100);
if (pct >= 25) maybeFire(25);
if (pct >= 50) maybeFire(50);
if (pct >= 75) maybeFire(75);
});
video.addEventListener("ended", () => maybeFire(100));Conversions: signups & paywall hits
Attribute newsletter signups and paywall clicks to the article and placement that earned them.
document.querySelector("#newsletter-form")
.addEventListener("submit", () => {
window.__afm.track("newsletter-signup", { placement: "in-article" });
});
document.querySelectorAll(".paywall-cta").forEach((el) => {
el.addEventListener("click", () => {
window.__afm.track("paywall-hit", { plan: el.dataset.plan });
});
});Polls & quizzes
See which questions readers actually answer, and how — not just that a quiz page got traffic.
function onQuizAnswered(question, answer) {
window.__afm.track("quiz-answered", { question, answer });
}
function onPollVoted(poll, option) {
window.__afm.track("poll-voted", { poll, option });
}Outbound & affiliate links
Measure clicks to partners and affiliate offers without leaving your own analytics.
document.querySelectorAll("a[data-affiliate]").forEach((a) => {
a.addEventListener("click", () => {
window.__afm.track("affiliate-click", { merchant: a.dataset.affiliate });
});
});
document.querySelectorAll("a[data-outbound]").forEach((a) => {
a.addEventListener("click", () => {
window.__afm.track("outbound-click", {
domain: new URL(a.href).hostname,
});
});
});On-site search
Find out what readers are looking for that your navigation doesn't already surface.
searchForm.addEventListener("submit", () => {
// Trim it yourself: an oversized value is DROPPED whole (never truncated).
window.__afm.track("site-search", {
query: input.value.trim().slice(0, 80),
results: String(resultCount),
});
});Games & interactive content
Track engagement inside embedded games, calculators or interactive graphics.
function onGameOver(score, level, won) {
// Numbers and booleans are stringified for you — no String() needed.
window.__afm.track("game-played", { score, level, won });
}Naming events well
The event name is the dimension every board, chart and CSV groups by — get it right once and it stays a clean leaderboard forever.
- One stable name per ACTION, not per instance. video-played for every video on the site, not a new name per title — the video itself is a prop, not part of the name.
- Lowercase and hyphenated, matching how the tracker will normalize it anyway: newsletter-signup, not Newsletter_Signup or signedUpForNewsletter.
- Put the variable part in props, not the name: quiz-answered with { question, answer }, never quiz-answered-question-3.
- Reuse the same name across every page it applies to — that repetition is exactly what turns individual beacons into a ranked report instead of one row per page.
In practice
video-played
One name for every video on the site. { quartile, title } carries the part that varies.
play-titanic-25pct
A new name per video fragments the report into hundreds of one-off rows that never roll up into anything.
newsletter-signup
Reused across every article, sidebar and footer form. { placement } tells them apart.
footer-form-submitted-2026
Bakes the placement and the year into the name — it will need a new name next year, and won't compare against this one.
Reading the data
Every accepted event appears in two places automatically — nothing else to configure.
Realtime → Events card
The top 8 event names from the last 30 minutes, each with a count and a unique-visitor number. Refreshes on the same cadence as the rest of the realtime board.
Events report
A full history: a ranked, sortable, searchable board (name, count, visitors, share of the range) and a stacked trend of the top 8 names over any date window, with CSV export. Custom events keep 13 months of history — longer than the 90-day window most raw-event reports use, since there's no pre-aggregated rollup behind them.
Hiding a name
A site admin can hide an event name from every report, board and CSV in Settings → Custom events — useful for a test name, a bug, or anything you'd rather not show a co-worker. Hiding never stops collection and never deletes anything: unhide the name and its full history reappears, typically within about a minute.
Not yet on the public read API — the Events report is dashboard-only for now, while every other historical report (pageviews, sources, authors, categories…) is readable with your site's key. See the public API reference in the main documentation. Documentation →
Limits & privacy, in one table
Two of these are limits, not features — know them before your first bug report about a “missing” event.
| Limit | Value |
|---|---|
| Event name | Lowercase a–z, 0–9, - _ . : (must start with a letter or digit); at most 64 bytes. |
| Props per event | At most 10 entries; extras are dropped, not merged. |
| Prop key | Same charset as the event name; at most 32 bytes. |
| Prop value | At most 200 bytes of UTF-8 once stringified; oversized values are dropped whole, never truncated. |
| Events per page view | 60 accepted; extras are silently dropped. Resets on every new page view, including single-page-app route changes. |
| Pre-init queue | 20 calls buffered before the tracker finishes loading. |
| History kept | 13 months (395 days) in the Events report. |
Privacy
No identity attached
Custom events never carry the opt-in first-party visitor id or any other identity field. They're anonymous counts of what happened, never a record of who did it.
Same opt-out as everything else
Global Privacy Control and window.__afm.optout() silence custom events exactly like pageviews — an opted-out reader sends nothing at all, ever.
Server-side validation is the backstop
The tracker sanitizes everything before it sends a byte; the ingest service independently re-validates and silently rejects anything a hand-rolled sender gets wrong, so a broken third-party integration can never corrupt your report with half-valid data.
Questions people actually ask
Do I need to configure anything before calling track()?
No. If the tracker tag is installed, window.__afm.track(...) works immediately — there is no per-site toggle, no separate script and no dashboard step to complete first.
What happens if I call track() with an invalid name?
The event is silently dropped: nothing is sent, and your code never sees an error. If an event isn't showing up, check the name against ^[a-z0-9][a-z0-9_.:-]{0,63}$ — lowercase, and no spaces or punctuation outside - _ . :
Can I attach the visitor's identity to a custom event?
No, by design. Custom events never carry the opt-in first-party id or any other identity field — they're anonymous counts of what happened, not records of who did it.
Will a flood of track() calls affect my pageview data?
No. Custom events are validated, rate-limited and stored completely separately from pageviews, pings and exits — a runaway loop can only exhaust its own 60-events-per-view budget, never spill into pageview counts.
Can I read custom events through the public API?
Not yet. The Events report is dashboard-only today; every other historical report is already available through the public read API with your site's key.
I hid an event name by mistake — is the data gone?
No. Hiding only affects display. Unhide the name in Settings → Custom events and its full history reappears, typically within about a minute.
Want a key to try this against?
Early access is invite-only for now. Ask for access and we'll get your board streaming.