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 — no cookies, 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.

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 visitedIP 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)A 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. Nothing identifying is sent unless you do one of those three 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
  • 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 guideImage posts & carousels