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>| Attribute | Default | Meaning |
data-ts-scroll | — | The event name. Required. |
data-ts-scroll-threshold | 0.5 | How much of the element must be visible, 0.0 to 1.0. |
data-ts-scroll-delay | 0 | Milliseconds 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.
| Thing | Limit |
| Event name — length | 64 characters |
| Event name — allowed characters | lowercase letters, digits, _, -, : |
| Parameter name — length | 64 characters |
| Parameter name — allowed characters | lowercase letters, digits, _, - — no colon |
| Parameter value — length, if a string | 255 characters |
| Parameters per event | 10 |
| All parameters, serialized together | 2,048 bytes |
| Whole request body | 8 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.
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.
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.
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.