Custom events

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.

Quick start

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.

The same tag, one extra line
<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.

Reference

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.

Signature
window.__afm.track(
  name: string,
  props?: Record<string, string | number | boolean>
): void
The track() API
RuleWhat it means
namerequiredLowercase, 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.
propsOptional 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 keysSame 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 valuesUp 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 viewUp 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.
identityNever 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 & responseSent 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:

Optional pre-init stub
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.

Recipes

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.

Video engagement
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.

Conversions: signups & paywall hits
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.

Polls & quizzes
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.

Outbound & affiliate links
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.

On-site search
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.

Games & interactive content
function onGameOver(score, level, won) {
  // Numbers and booleans are stringified for you — no String() needed.
  window.__afm.track("game-played", { score, level, won });
}
Best practice

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.

Where it shows up

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

Reference

Limits & privacy, in one table

Two of these are limits, not features — know them before your first bug report about a “missing” event.

Limits & privacy, in one table
LimitValue
Event nameLowercase a–z, 0–9, - _ . : (must start with a letter or digit); at most 64 bytes.
Props per eventAt most 10 entries; extras are dropped, not merged.
Prop keySame charset as the event name; at most 32 bytes.
Prop valueAt most 200 bytes of UTF-8 once stringified; oversized values are dropped whole, never truncated.
Events per page view60 accepted; extras are silently dropped. Resets on every new page view, including single-page-app route changes.
Pre-init queue20 calls buffered before the tracker finishes loading.
History kept13 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.

FAQ

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.