Skip to main content

The script

Tracking custom events

Two ways: an HTML attribute, or a JavaScript call.

Two ways: an HTML attribute, or a JavaScript call. Both produce the same event.

Read the limits before you name your first event. They are enforced at the point the event arrives, and an event that breaks one is dropped, not corrected. You will not see a partial event; you will see nothing.

The HTML attribute way

Add data-ts-goal to any element. The event fires when it is clicked.

<button data-ts-goal="signup">Create account</button>

With parameters, using data-ts-goal- plus the parameter name. Hyphens in the attribute become underscores in the parameter:

<button
  data-ts-goal="initiate_checkout"
  data-ts-goal-plan="growth"
  data-ts-goal-traffic-step="200k"
  data-ts-goal-price="59">
  Subscribe to Pro
</button>

That sends the event initiate_checkout with:

{ "plan": "growth", "traffic_step": "200k", "price": "59" }

Note the quotes on "59". HTML attribute values are always strings, and the script does not guess otherwise. If you send price as a number from JavaScript and as an attribute elsewhere, they arrive as 59 and "59" and group as two different values. Pick one route per parameter.

The JavaScript way

window.truestat("signup");

With parameters:

window.truestat("initiate_checkout", {
  plan: "growth",
  traffic_step: "200k",
  price: 59,
});

You do not need to wait for the script to load. A call made before the script is ready is buffered and sent once it is. You do not need to paste a second inline snippet to make that work — the buffer ships inside the tag.

Firing a goal before the script has loaded

window.truestat does not exist until the script finishes loading. A goal fired before then — a button clicked immediately, or a signup that completes on page load — is lost.

A short stub in your <head>, above the tracking script, queues those calls until the script arrives and drains them:

<script>
  window.truestat = window.truestat || function () {
    (window.truestat.q = window.truestat.q || []).push(arguments);
  };
</script>

You do not need this if every goal fires from a click the visitor makes after the page settles, which is most of them. It matters when a goal can fire in the first moments of a page.

From your server

Some goals happen where no browser is watching: a subscription that renews on a schedule, an order confirmed by a webhook, a trial that converts overnight. Post them directly.

POST /api/events
Content-Type: application/json

{
  "site_key": "ts_your_site_key",
  "event_type": "goal",
  "name": "subscription_renewed",
  "path": "/billing",
  "visitor_id": "the visitor's id",
  "metadata": { "plan": "pro" }
}

visitor_id is what ties the goal to the rest of that person's session. Read it from the ts_vid cookie on your own request and pass it through; without it the goal is recorded but stands alone, outside the visit that led to it.

Unlike some tools, this does not require the visitor to have been seen before — a goal from a server with no prior pageview is accepted. That makes server-only tracking possible, and it also means the id is taken on trust.

On the site key. The same key is in your page's script tag, so it is not a secret and this endpoint does not treat it as one. Anyone who views your source can post events to your site. What protects you is the rate limit and the fact that nothing here can read your data or change your account — a forged event is noise in your own numbers, not a breach.

If that matters for your goals — a signup count you report to investors, say — send them from your server and compare against your own database. We would rather tell you this than let you assume the key is doing more work than it is.

The script goes in <head>, and that is fine for clicks

Click goals use event delegation: one listener on document, checking what was clicked. So an element added after the page loaded — by React, Vue, htmx, anything — is tracked without re-running anything. You can add data-ts-goal to markup your framework renders later and it works.

Scroll goals are different. They are collected once when the script loads, so an element that arrives afterwards is not watched. In a single-page app, a data-ts-scroll element on a route the visitor navigates to later will not fire.

Tracking a scroll into view

data-ts-scroll fires when an element becomes visible.

<section
  data-ts-scroll="reached_pricing"
  data-ts-scroll-threshold="0.6"
  data-ts-scroll-delay="2000">
  …
</section>
AttributeDefaultMeaning
data-ts-scrollThe event name. Required.
data-ts-scroll-threshold0.5How much of the element must be visible, 0.0 to 1.0.
data-ts-scroll-delay0Milliseconds it must stay visible before the event fires. Scrolling away before the delay elapses cancels it.
data-ts-scroll-{name}A parameter, same rules as data-ts-goal-{name}.

Each element fires at most once per page visit. Without this, a visitor scrolling up and down your pricing page would burn through your event quota.

The threshold is not recorded. Two events named reached_pricing fired at different thresholds are indistinguishable in your dashboard. The threshold describes your instrumentation, not the visit — if you need to tell them apart, put it in a parameter, which costs one of your ten slots.

Scroll tracking needs IntersectionObserver. In the rare browser without it, scroll events silently do nothing; nothing else is affected.

The limits

Every one of these is enforced when the event arrives. Breaking any of them drops the event.

ThingLimit
Event name — length64 characters
Event name — allowed characterslowercase letters, digits, _, -, :
Parameter name — length64 characters
Parameter name — allowed characterslowercase letters, digits, _, -no colon
Parameter value — length, if a string255 characters
Parameters per event10
All parameters, serialized together2,048 bytes
Whole request body8 KB

Three of those deserve expanding.

Lowercase is required, not normalised

Signup is rejected. It is not folded to signup.

This is on purpose. Silently correcting it would mean your dashboard shows a name you did not write, and you would never learn the name was wrong. Rejecting it surfaces the mistake while you are still installing, which is the only moment it is cheap to fix.

The script checks the name locally before sending and logs a console error naming the offending event — unless you set data-disable-console="true". That console message is the only place you can learn which event was wrong; the server's response is a fixed string with no field names in it, deliberately.

The colon is allowed in event names but not in parameter names

checkout:started is a valid event name. plan:type is not a valid parameter name — use plan_type.

The 2,048-byte total is the one that catches people

Ten parameters at 255 characters each is 2,550 characters of values before you count the names and the JSON punctuation. So an event that respects every individual limit can still exceed the total.

When that happens, the event is kept and the parameters are dropped. You see the goal fire with nothing attached. That is the better failure — the click really happened and the count should reflect it — but it is confusing if you do not know it can happen. The script warns in the console when it is about to send an oversized parameter set.

If your parameters are near the limit, the usual fix is to stop sending an identifier that you can already look up on your own side.

Reserved and unsupported

window.truestat("identify", …) does nothing. It is accepted silently so a migration from a tool that has it does not throw, but there is no visitor profile to attach anything to. TrueStat deliberately has no cross-device person identity — see What we deliberately cannot do.

Reserved names

Eleven names belong to the billing integration and are rejected with a 400:

payment · free_trial · trial_started · trial_converted · subscription_started · subscription_upgraded · subscription_downgraded · subscription_renewed · subscription_cancel_scheduled · subscription_reactivated · subscription_ended

These are written automatically once a payment provider is connected, and a goal of your own with the same name would land in the same place — the panel would report payments that were button clicks.

The match is exact, so payment_method_added and free_trial_reminder are yours to use.

Where your events appear

The Goals card on the dashboard lists every goal name with two numbers: how many people triggered it, and how many times it fired. They differ when someone triggers the same goal twice — a second signup, a retried checkout — and the card prints the second number only when it is higher, so an equal pair is not shown twice.

Clicking a goal filters the whole dashboard to the people who triggered it. That is a different narrowing from the other cards: filtering by a page keeps that page's rows, but filtering by a goal keeps everything those visitors did, which is what makes "where did the people who signed up come from" a question the map can answer.

screenshot coming

The Goals card with three or four rows, at least one showing a differing completions count beneath the name (e.g. "signup / 8 · 9 completions"). Light theme, cropped to the card.

screenshot coming

The dashboard with a goal filter active — the filter chip visible at the top and the country card narrowed to the visitors who triggered it. Shows the visitor-scoped behaviour described above. Light theme, top half of the dashboard.

Goals are also available through the API — see Endpoint reference — or by exporting.

screenshot coming

Browser console showing the script's error message when an event name breaks the rules — e.g. an event named "Signup" rejected for the capital letter, naming the offending goal. Dark DevTools theme, cropped to the message.

Was this page helpful?

Last updated August 28, 2026