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.
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:
| Field | Taken from |
|---|---|
input[type="email"], or input[name="email"] | |
| name | input[name="name"], or input[autocomplete="name"] |
| company | input[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:
| Stored | Not stored |
|---|---|
| Path visited | IP address |
Referrer host (chatgpt.com, not the full URL) | User agent |
| Country, and a location rounded to about 10 km | Cookies of any kind |
Conversion name (signup) | A visitor id of any kind |
| First and last touch tokens | Anything 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 —
identifyon 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-srcfor app.balladlabs.com — plusscript-srcif 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.