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,
});

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-scroll—The 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

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:

GoalFires whenStripePaddleLemonSqueezyPolar
trial_startedA subscription starts in trial✅✅✅✅
subscription_startedA subscription starts paid✅✅✅✅
trial_convertedA trial becomes a paying subscription✅✅✅✅
subscription_upgradedThe plan's amount goes up✅✅✅✅
subscription_downgradedThe plan's amount goes down✅✅✅✅
subscription_renewedA billing period renews and is paid✅✅✅✅
subscription_cancel_scheduledA cancellation is set for the end of the period✅✅✅✅
subscription_reactivatedA scheduled cancellation is called off✅✅✅✅
subscription_pausedBilling pauses without the subscription ending✅✅✅✅
subscription_resumedA paused subscription starts billing again✅✅✅✅
subscription_endedThe subscription actually ends — this is churn✅✅✅✅
payment_failedA subscription payment is declined and will be retried✅✅✅✅
payment_action_requiredThe bank asks the customer to authenticate (3-D Secure)✅———
trial_will_endThree 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:

PropertyWhat it is
mrr_delta_centsHow the monthly recurring revenue moved. Negative on churn, on a pause, and on a downgrade
mrr_pending_centsWhat a trial would be worth if it converts — on trial_started and trial_will_end
mrr_at_risk_centsWhat a failed payment threatens while the provider is still retrying
amount_centsThe amount actually billed, unnormalised: a $990 yearly plan reads 99000 here and 8250 in mrr_delta_cents
billing_interval, billing_interval_countmonth, year, and how many of them
plan_id, product_idThe price and product at your provider
currency, status, cancel_at_period_endThe subscription as your provider sees it
provider_subscription_id, provider_customer_idFor 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.

People first, completions beside the name when they differ. subscription_ended is one person seven times — the same subscriber, cancelling repeatedly — which is why the two numbers are kept apart.
Filtered to signup. The Country card now counts only the people who signed up — every page they loaded, not just the goal itself — and the Filters popover names the filter.

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         44

So 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.

The panel opens beneath the Goals card. Each value shows people · completions, so -2000 here is one subscriber who cancelled three times.

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

A rejected name is a warning, not an error, and it names the goal. Nothing is sent for it.
Was this page helpful?

Last updated September 14, 2026