Ballad
Measurement

Measure what your content does

Install the Ballad beacon: a 1.5 KB first-party script that records landings, referrers and conversions on your own domain, with no cookies and no visitor ids.

~10 min6 sectionsAny site · no cookies

Ballad already knows what it published. The beacon tells it what happened next: which page someone landed on, where they came from, and whether they converted. That's the difference between "we posted eleven times" and "this one landed, from Claude, and turned into two signups."

It's first-party and cookieless, and it comes two ways: a script tag (1.1 KB gzipped) you paste anywhere, or an npm package (2 KB) you import. Same wire format, same storage keys. Pick whichever suits your stack, and you can switch later without losing history.

Install the beacon

Either way you'll need the token from Settings → Site → Install the beacon.

The script tag

One line, on every page. Nothing to build, so it's the right answer for Webflow, Framer, Squarespace and anything else where you paste code into a site-wide custom-code or footer box.

<script src="https://app.balladlabs.com/b.js" data-site="YOUR_SITE_TOKEN"></script>

It has to be on every page, not just the blog: a landing on your pricing page from an answer engine is exactly the signal you want.

The npm package

If you have a build step, install it instead:

npm install @balladlabs/beacon

Next.js, or any React app: render the component once, in the root layout:

// app/layout.tsx
import { BalladBeacon } from '@balladlabs/beacon/react';

<BalladBeacon site={process.env.NEXT_PUBLIC_BALLAD_SITE_TOKEN} />

Any other bundler: call init once, wherever your app boots:

import { init, track } from '@balladlabs/beacon';

init({ site: process.env.BALLAD_SITE_TOKEN });

track() can be called before init. The calls queue and drain once the beacon boots, so you don't have to order your imports around it. data-ballad-track attributes and window.ballad.track() keep working exactly as they do with the script tag, so moving from one to the other is a one-line change.

Keep the token in an environment variable and mount the beacon only when it's set. That way local development and preview deploys stay silent instead of reporting test traffic into your real numbers.

Fire a conversion

A landing is only half the story. Tell the beacon when someone actually did the thing:

window.ballad.track('signup');

Event names are lowercase [a-z0-9_], 40 characters or fewer. signup is the one Ballad reads by default. Use it for your primary conversion and the numbers show up without any further configuration.

For a no-code site, skip the JavaScript and put the attribute on the button or form instead:

<button data-ballad-track="signup">Get started</button>

Two things worth getting right, because both quietly distort the funnel:

Fire on success, not on submit. If the request fails and you've already tracked, you've recorded a conversion that never happened. Ballad plans your next round of content against these numbers.

Fire on the conversion, not the click. A button that scrolls to a form is not a signup. Track the thing that completed.

If your form submits fast enough to beat the script, push onto the queue and the beacon drains it when it boots:

(window.ballad ??= {}).q?.push(['track', 'signup']);

Say who converted

A conversion can carry who it was, which turns it into a contact with the post that first brought them and the last one before they signed up.

window.ballad.track('signup', { email, name, company });

Only email is required, and nothing is sent unless you pass it. On the package it's the same call, imported:

import { track } from '@balladlabs/beacon';

track('signup', { email: user.email });

Or let the beacon read the form it already knows about, by adding data-ballad-identify beside the tracking attribute:

<form data-ballad-track="signup" data-ballad-identify>
  <input type="email" name="email" />
  <input name="name" />
  <button>Get started</button>
</form>

It reads exactly three fields and nothing else:

FieldTaken from
emailinput[type="email"], or input[name="email"]
nameinput[name="name"], or input[autocomplete="name"]
companyinput[name="company"], or input[autocomplete="organization"]

A phone number, a message box, a coupon code: none of it is read. A form without data-ballad-identify sends no identity at all.

Nothing identifying is sent under Global Privacy Control, including an identity you pass yourself in code. The landing and the conversion are still counted, anonymously, so your totals stay honest. If you need the record for a visitor who signalled GPC, you already have it. It's your own signup, on your own server.

This needs @balladlabs/beacon 0.2.1 or newer, or the script tag, which always serves the current version.

Signups from before Ballad knew who they were

The form only catches people who sign up after you wire it. Everyone already in your database, and everyone who signed up in a tab you weren't tracking yet, is still anonymous. Two ways to fill them in.

Call identify when someone logs in. The browser kept the first and last touch for 90 days, so a returning visitor brings their own history with them:

import { identify } from '@balladlabs/beacon';

identify({ email: user.email, name: user.name, company: user.company });

Script tag: window.ballad.identify({ email }), and like track it queues if it runs before the beacon loads. This doesn't count a conversion; it says who someone is. They appear on People with the post that first brought them.

Or import a CSV, in the app under People → Import: a file or pasted text, with an email column and optionally name, company and a signup time. Give it the signup time and Ballad matches each row to the single anonymous signup the beacon recorded within ten minutes of it, recovering the post that brought that person; those show as matched by time. Two signups in the window and it imports the person without a touch rather than guessing between them. Up to 5,000 rows, and people you already have are updated rather than duplicated. An import comes out of your own database rather than a browser, so the Global Privacy Control signal, which is a browser signal, never reaches it. The promise below covers what the beacon sends, not what you upload. Agents can do the same through import_contacts, 500 rows a call, with the signals scope.

The two work in either order: import the list today, and anyone it couldn't match fills in their first post the next time they log in and identify runs.

identify needs @balladlabs/beacon 0.3.0 or newer, or the script tag. Under Global Privacy Control it sends nothing, the same as track.

Events with properties, and accounts

A conversion can carry flat properties (strings, numbers and booleans, up to 20 keys) that a segment in Ballad can read later ("did upgraded where plan = pro, within 30 days"):

track('upgraded', { email: user.email }, { plan: 'pro', seats: 3 });

If one person can belong to several companies, teams or workspaces in your product, tell Ballad which one an event happened in. group is the same idea as Segment's: your own id for the account, a name, and traits set once for everyone in it.

import { group, identify } from '@balladlabs/beacon';

identify({ email: user.email });
group(workspace.id, { name: workspace.name, plan: workspace.plan });

The account is sent with the identity last passed to identify or track on the page (before or after, the beacon waits for one), and every later track on that page carries the account id, so "signed up, never published" reads per account rather than per email. Calling group again with new traits merges them. On People each person shows their accounts, and a segment can select in account X or in an account where plan = pro. Script tag: window.ballad.group(id, { name }), queued like the rest. Agents do the same server-side with group_contact and track_contact_event.

When someone leaves (a self-service "leave team", or deleting the workspace from their own browser), call the opposite, and Ballad records left_account in that account and drops the membership:

import { ungroup } from '@balladlabs/beacon';

ungroup(workspace.id);

A removal done by someone else never passes through the leaver's browser, so report those server-side with the ungroup_contact agent tool.

Properties and group need @balladlabs/beacon 0.5.0 or newer, ungroup 0.6.0, or the script tag. Under Global Privacy Control none of them is sent.

Conversions you record on your server

Some signups never go through the beacon at all: a form that posts to your own server, which records the person with the track_contact_event agent tool. The browser still holds the touch, so hand it over with the form and the conversion is credited to the post that brought the visitor, exactly as a track from the page would be.

import { touches } from '@balladlabs/beacon';

const t = touches(); // { first, last }, or null

Send t along with the form, and on your server pass t.first and t.last as first and last:

{
  "name": "track_contact_event",
  "arguments": {
    "email": "ada@example.com",
    "event": "subscribe",
    "first": { "ref": "a1b2c3", "path": "/blog/launch", "at": 1789000000000 },
    "last": { "path": "/pricing", "host": "news.ycombinator.com", "at": 1789100000000 }
  }
}

The result says what each touch resolved to. touches() returns null when the browser has none yet, and always under Global Privacy Control, so a visitor who asked not to be tracked is never credited to anything; the conversion still records. Script tag: window.ballad.touches(). Read it when the form is submitted rather than when the page renders, since the beacon may load after your form does. Needs @balladlabs/beacon 0.8.0 or newer, or the script tag.

When signup lives on another origin

Plenty of sites market on example.com and sign people up on app.example.com. The beacon stores its touches in localStorage, which is per-origin, so without help the app has no idea the marketing site ever saw that person, and the signup lands with no post attached.

Name the origins you hand off to:

<script
  src="https://app.balladlabs.com/b.js"
  data-site="YOUR_SITE_TOKEN"
  data-handoff="app.example.com"
></script>

On the package it's a list:

<BalladBeacon site={process.env.NEXT_PUBLIC_BALLAD_SITE_TOKEN} handoff={['app.example.com']} />

Links to those hosts pick up a short bt parameter carrying the stored touch: URL-safe, around 200 characters. The receiving origin needs the beacon too, with the same site token; it adopts the touch only if it doesn't already have one of its own, and strips the parameter out of the URL so your visitor never sees it. Nothing is carried under Global Privacy Control.

The events endpoint accepts any subdomain of your site's domain, so your app doesn't need a token of its own, but this is subdomains only. A genuinely different domain won't report.

Keep your canonical tags

The links Ballad emits carry a ?ref= token. That makes /pricing?ref=a1b2c3 a distinct URL from /pricing, and without a canonical tag search engines can index both.

Every page needs one, including your homepage, which is where profile and bio links tend to land:

export const metadata = { alternates: { canonical: '/pricing' } };

In Next.js, note that a page-level alternates replaces the one in your layout rather than merging with it. If your layout declares an RSS feed there, repeat it on any page that sets its own canonical, or the page silently loses the feed link.

What Ballad stores

First-party only, on your own domain:

StoredNot stored
Path visited (tied to a person only on pages where you passed an identity)IP address
Referrer host (chatgpt.com, not the full URL)User agent
Country, and a location rounded to about 10 kmCookies of any kind
Conversion name (signup), and any properties you passA visitor id of any kind
First and last touch tokensAnything joined across sites

The one exception is the identity you choose to send: by passing an email to track, by marking a form data-ballad-identify, or by calling identify. That email, plus a name and company if you pass them, is kept with the touches the browser carried, which is what makes a person on People. An account you name with group is kept the same way, under your own id for it. Once you have passed an identity on a page, the landing and later page views on that page carry it too (held in memory for that page only, never written to the browser), so a signed-in user's reads show on their record and "last seen" moves; a page where you never call identify stays anonymous. Nothing identifying is sent unless you do one of those things, and nothing is sent under Global Privacy Control even if you do.

First touch lives in localStorage on your domain under ballad:touch, with a 90-day expiry, and is sent along when a conversion fires. Ballad keeps no visitor id and nothing is joined across sites. The browser carries the history, not us. An identified conversion joins that history to the email you sent, on your site alone.

navigator.globalPrivacyControl is respected: if a visitor signals Global Privacy Control, nothing is stored on their device and nothing identifying is sent, not even an identity you pass yourself in code. The landing and the conversion are still counted, anonymously, so your totals stay honest.

Content Security Policy

If your site sets a CSP, what you need depends on how you installed.

The npm package needs one directive. The code is in your own bundle; only the reporting call leaves your origin.

connect-src https://app.balladlabs.com

The script tag needs that one plus script-src, because the file itself is fetched from us:

script-src  https://app.balladlabs.com
connect-src https://app.balladlabs.com

Either way, without connect-src the beacon loads and silently fails to report, which looks exactly like no traffic.

Ad blockers will block some of this. The file is served as /b.js and the path contains no track, beacon or analytics, but some lists block on behaviour rather than filename. Expect to undercount rather than to measure perfectly. The shape of the trend is what the plan is built on.

Checklist

  • Script tag on every page, or the package initialised once at boot
  • Token from an environment variable; not rendered in dev or previews
  • track('signup') fires on success, not on submit
  • Pass the email at signup if you want contacts rather than counts
  • Backfill the people you already have: identify on login, or a CSV
  • One person in several accounts? group after identify
  • Signup on another subdomain? data-handoff, and the beacon on both
  • Canonical tag on every page, homepage included
  • CSP allows connect-src for app.balladlabs.com, plus script-src if you used the script tag
  • Settings → Site shows "Receiving events"

Once it's reporting, Signals stops being a list of what you published and starts being a picture of what it did.

Next guideConnect Google Search Console