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,
});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
Seventeen names belong to the billing integration and are rejected with a 400:
payment · free_trial · free_plan · trial_started · trial_will_end ·
trial_converted · subscription_started · subscription_renewed ·
subscription_upgraded · subscription_downgraded ·
subscription_cancel_scheduled · subscription_reactivated ·
subscription_paused · subscription_resumed · subscription_ended ·
payment_failed · payment_action_required
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.
payment is the one exception, and it does something rather than nothing.
Calling it with an e-mail address attributes a payment your provider has
already reported to the visit in front of you:
window.truestat("payment", { email: "customer@example.com" });That is the fallback for checkouts with nowhere to carry a visitor id and no return URL to read — each provider's Other methods page covers when to reach for it. It never creates revenue: it fills in the visit on a payment the provider sent, which is why the name stays reserved as a goal.
free_trial and free_plan are held for later rather than written today —
names an integration built for another tool might send. Reserving a name is
easy; taking one back once someone is using it is not.
The match is exact, so payment_method_added and free_trial_reminder are
yours to use.
What the subscription goals measure
Connect Stripe, Paddle, LemonSqueezy or Polar and these appear on their own, with no code from you:
| Goal | Fires when | Stripe | Paddle | LemonSqueezy | Polar |
trial_started | A subscription starts in trial | ✅ | ✅ | ✅ | ✅ |
subscription_started | A subscription starts paid | ✅ | ✅ | ✅ | ✅ |
trial_converted | A trial becomes a paying subscription | ✅ | ✅ | ✅ | ✅ |
subscription_upgraded | The plan's amount goes up | ✅ | ✅ | ✅ | ✅ |
subscription_downgraded | The plan's amount goes down | ✅ | ✅ | ✅ | ✅ |
subscription_renewed | A billing period renews and is paid | ✅ | ✅ | ✅ | ✅ |
subscription_cancel_scheduled | A cancellation is set for the end of the period | ✅ | ✅ | ✅ | ✅ |
subscription_reactivated | A scheduled cancellation is called off | ✅ | ✅ | ✅ | ✅ |
subscription_paused | Billing pauses without the subscription ending | ✅ | ✅ | ✅ | ✅ |
subscription_resumed | A paused subscription starts billing again | ✅ | ✅ | ✅ | ✅ |
subscription_ended | The subscription actually ends — this is churn | ✅ | ✅ | ✅ | ✅ |
payment_failed | A subscription payment is declined and will be retried | ✅ | ✅ | ✅ | ✅ |
payment_action_required | The bank asks the customer to authenticate (3-D Secure) | ✅ | — | — | — |
trial_will_end | Three days before a trial ends | ✅ | — | — | — |
Two of these are Stripe-only, and the reason is the other providers', not ours.
Only Stripe sends anything before a trial ends, so there is nothing to build
trial_will_end from elsewhere. And only Stripe reports a 3-D Secure
challenge as its own step — Paddle, LemonSqueezy and Polar each handle
authentication inside their own checkout and tell us only whether the payment
succeeded, so payment_action_required has no counterpart there. A declined
card still arrives as payment_failed on all four.
Everything else in the table behaves the same whichever provider you connect.
Three of these are warnings, not outcomes. payment_failed,
payment_action_required and trial_will_end say something is about to
happen while there is still time to act.
Most churn in a subscription business is a declined card rather than a
decision, and every provider retries those automatically — LemonSqueezy four
times over two weeks, Polar four times over three. payment_failed fires on
each attempt, so the same subscription can appear several times before it
either recovers or ends — that repetition is the signal, not a duplicate.
It carries no amount, for that reason: nothing was collected, and a figure repeated four times would read as revenue that never arrived.
Paused and ended are different things. A pause stops the billing and keeps
the subscription — it can resume. subscription_ended is the subscription
actually closing, which is churn.
Which one a cardless trial produces is your own setting at the provider: on
Stripe an end_behavior of pause gives you a pause and cancel gives you an
ending. Both are recorded; they simply are not the same event.
LemonSqueezy names these in a way worth knowing if you read its own dashboard:
its resumed means a cancellation was called off, and its unpaused is a
pause ending. We report those as subscription_reactivated and
subscription_resumed respectively — the names above always mean the same
thing, whichever provider is behind them.
They carry the terms, not a revenue figure. Open one of these goals and its properties say which plan, on what interval, for how much, and how that subscription moved your MRR:
| Property | What it is |
mrr_delta_cents | How the monthly recurring revenue moved. Negative on churn, on a pause, and on a downgrade |
mrr_pending_cents | What a trial would be worth if it converts — on trial_started and trial_will_end |
mrr_at_risk_cents | What a failed payment threatens while the provider is still retrying |
amount_cents | The amount actually billed, unnormalised: a $990 yearly plan reads 99000 here and 8250 in mrr_delta_cents |
billing_interval, billing_interval_count | month, year, and how many of them |
plan_id, product_id | The price and product at your provider |
currency, status, cancel_at_period_end | The subscription as your provider sees it |
provider_subscription_id, provider_customer_id | For matching a row back to your provider |
The three MRR keys are separate on purpose. A retrying card has not churned and a trial has not converted, so folding them into one figure would draw cancellations that never happened.
None of these goals adds to your revenue total, because the money already arrived as a payment — a $339 subscription starting is one $339 payment, not $339 of revenue plus $339 of goal. Churn does not subtract from it either: your revenue total matches your provider's dashboard, and no refund was issued.
Because they are goals like any other, clicking one filters the dashboard to those people. That is what makes "which channel do the customers who churn come from" answerable — and it is usually not the channel bringing the most signups.
These are attributed backwards. Nobody is on your site when a subscription renews or ends, so the visitor is recovered from the payment that started it — the one carrying the checkout metadata. If that payment was never attributed, the goal is still counted; it just has no channel behind it.
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.
Seeing your parameters
The numbers above count the goal. The parameters you sent with it answer the next question — which plan, which product, which step.
Press the breakdown icon beside a goal's name to open them. Each parameter is listed with its values underneath, and each value with how many people sent it:
plan
growth 412
starter 139
pro 44So a goal fired 595 times becomes three numbers you can act on. If a goal was sent without parameters, the panel says so rather than showing an empty list.
Parameters are read as text, always. price sent as 59 and as 59.0 would
otherwise land on two rows, and the useful question is "how many chose 59", not
the average of a plan name.
Goals are also available through the API — see Endpoint reference — or by exporting.