Documentation
Using Tastatur
One script tag installs it. Everything below is optional.
Install
Paste this into your <head>. That is the entire installation, and nothing below is required to get numbers.
<script defer data-site="YOUR_SITE_KEY" src="https://tastatur.dev/t.js"></script>
defer keeps it off the critical path. The script is about 4.1 KB over the
wire and makes one request per pageview. Visits from
localhost, private network ranges and file: are ignored, so
local development will not appear in your numbers.
Script options
Attributes on the script tag. All optional.
data-site
required
Your site key. Public by design; it appears in your page source.
data-api
optional
Override the ingest endpoint. Only needed if you proxy events through a different path than the script.
data-hash
optional
Count URL fragment changes as pageviews. For apps that route on #.
data-auto
default "true"
Set to "false" to stop automatic pageviews and send them yourself.
data-exclude
optional
Present means collect nothing. Useful on a staging build from the same codebase.
data-ignore-dnt
optional
Stop honouring Do Not Track and Global Privacy Control. Read the note under Opt-out before using this.
<script defer
data-site="YOUR_SITE_KEY"
data-hash
src="https://tastatur.dev/t.js"></script>
Custom events
Anything worth counting that is not a pageview: a signup, a download, a plan selected.
tastatur('event', 'Signup')
tastatur('event', 'Signup', { props: { plan: 'pro', source: 'pricing-page' } })
Event names are free text up to 120 characters. Keep them stable; renaming one starts a new series rather than renaming the old.
Properties are limited to 24 keys per event, 60 characters per key and 500 per value. Values may be strings, numbers or booleans.
Do not put personal data in properties. This is the
one field whose contents you control, and nothing in Tastatur can tell that
{ email: "…" } should not have been sent.
Where they show up. Custom events get their own panel on
the dashboard. Click one and the whole dashboard scopes to it, with a card per property
key listing its top values — click one of those to narrow further. Properties are
only shown against a chosen event, because plan=pro on a signup and
plan=pro on a cancellation are not the same fact and a pooled total of the
two means nothing. Property rows are held to the same
k-anonymity threshold
as every other breakdown.
If you call tastatur() from code that may run before the deferred script has
loaded, add this stub first. It queues calls and replays them once the script is ready.
window.tastatur = window.tastatur || function () {
(window.tastatur.q = window.tastatur.q || []).push(arguments)
}
Revenue
Attach a monetary value to an event and it shows up against your goals.
tastatur('event', 'Purchase', {
revenue: { amount: 4900, currency: 'EUR' },
props: { plan: 'growth' }
})
amount is in minor units, so 4900 is €49.00. A currency is required
whenever an amount is sent: without one, a total would silently mix euros and yen.
Revenue attribution
Not shipped yet. Connect Stripe and see which channel produced paying customers, not just visitors. Three steps, and the middle one is the only code you write.
Why it needs your help. The browser knows where somebody came from. The payment happens somewhere else, possibly days later, possibly on another device. We store nothing on the visitor's device, so there is no cookie to join those two together — instead the attribution travels with the payment, inside it.
1. Capture it on the landing page. This reads the current URL's campaign tags and referrer. Store it in your own user record or form state — calling it at the end of a signup funnel would read your own funnel as the source.
const attribution = tastatur.attribution()
// { source, medium, campaign, landing_path, referrer_host, first_seen_at }
2. Send it when they sign up, from your server, with an API key from your site's settings. The email is hashed on receipt and never stored.
POST https://tastatur.dev/api/v1/identify
Authorization: Bearer tk_...
Content-Type: application/json
{
"external_id": "user_9182",
"email": "[email protected]",
"attribution": { "source": "reddit", "medium": "social", "landing_path": "/pricing" }
}
3. Attach it to Stripe Checkout. This is what makes attribution survive a closed tab or a payment finished three days later on a phone.
stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [...],
client_reference_id: user.id,
metadata: tastatur.checkoutMetadata(attribution)
})
checkoutMetadata() returns a flat object with tst_-prefixed
keys, so it cannot collide with metadata of your own. Call it with no argument to use
the current page, or pass a first touch you saved earlier. If you build the object
yourself, the keys are tst_source, tst_medium,
tst_campaign, tst_content, tst_term,
tst_landing_path, tst_referrer_host and
tst_first_seen_at.
First touch wins, and is never overwritten. Apps call identify on every sign-in, so last-write-wins would re-attribute a customer you earned from a Reddit post to whatever they searched months later — every paid channel would decay towards zero on its own.
Attribution is not anonymous, and is kept separately. Everything on this page above it is. These endpoints carry data your own application already holds about somebody who signed up for it, sent server-to-server under your own legal basis. Nothing here is ever linked back to an anonymous visitor.
Single-page apps
Handled automatically. There is nothing to configure for React, Vue, Svelte, Turbo, HTMX or hand-rolled routing.
The script patches history.pushState and replaceState, and
listens for popstate, so client-side navigation counts as a pageview.
Views are deduplicated against the last URL sent. Frameworks that call
replaceState during hydration would otherwise double-count every first
pageview.
Back/forward cache restores are counted via pageshow, because a restored
page does not re-run the script.
If you route on the URL fragment, add data-hash.
To take control yourself, set data-auto="false" and call it from your router:
router.afterEach(() => tastatur('pageview'))
Goals
A goal is a page or an event worth counting, with a conversion rate attached.
/pricing
Matches that page only.
/blog
Matches /blog and everything beneath it.
/blog/**
* matches inside one path segment, ** matches across segments.
Signup
Matches an event name you send.
Funnels
An ordered sequence of steps, with the drop-off between each.
Steps use the same matchers as goals. A visitor counts at step 3 only if they reached it after step 2, which means someone who backtracks and returns is still counted correctly.
Campaigns
Name what you are promoting, build a link for each place you post it.
A campaign link is an ordinary URL to one of your own pages with utm_source,
utm_medium and utm_campaign on it. Nothing needs installing —
the tracker already reads those five parameters. Building the link here means the report
can match it back afterwards, which is the part that goes wrong when links are written
by hand.
A campaign is a name and a tag, and you build a link under it for each place you post — Discord, Facebook, the newsletter. They all carry the campaign's tag, which is what puts them under one heading with one figure as well as one row each.
That heading figure is counted over the tag rather than added up from the links, so it is usually smaller than they come to: somebody who follows two of your links is one visitor to the campaign and a visitor on each link. Adding the rows together would count them twice.
Matching is exact and case-sensitive, so Newsletter and
newsletter are two campaigns. Values you build here are lower-cased and
the pickers offer what the site has already used.
Tagged traffic from links built anywhere else — an ad platform, a mail tool — is measured too, and listed separately so a spelling that should have matched one of yours is visible.
A value that reads like a token is discarded on arrival, because mail platforms put
per-recipient identifiers in utm_content. The builder refuses those
rather than saving a link that would report nothing.
Filtering and sharing
Click any row on the dashboard to filter everything by it.
Filter state lives in the URL, so a filtered view can be bookmarked, sent to a colleague, or kept open in a tab.
Filterable dimensions: page, entry page, source, referrer, country, browser,
operating system, device, screen class, every utm_* field, event name and
hostname.
Shared dashboards give a read-only link that needs no account, optionally password-protected and optionally expiring. They do not offer filters, because narrow filters on someone else's audience are a re-identification tool.
Content Security Policy
If your site sets a CSP, allow this origin in two directives.
script-src https://tastatur.dev;
connect-src https://tastatur.dev;
script-src lets the browser fetch t.js;
connect-src lets it send the beacon. The script needs no
unsafe-inline, no unsafe-eval, and sets no cookie, so it does
not interact with your cookie or frame policy at all.
Server-side events
The ingest endpoint is a plain HTTP API, so backend and mobile events work without the script.
curl -X POST https://tastatur.dev/api/event \
-H 'Content-Type: application/json' \
-H 'X-Forwarded-For: <the end user's IP>' \
-H 'User-Agent: <the end user's user-agent>' \
-d '{
"s": "YOUR_SITE_KEY",
"u": "https://example.com/checkout",
"n": "Purchase",
"v": 4900,
"c": "EUR"
}'
s
Site key
u
Absolute URL of the page or screen
n
Event name. Defaults to "pageview"
r
Referrer URL
w
Viewport width in pixels, bucketed server-side
p
Properties object
v
Revenue in minor units
c
Three-letter currency code
The endpoint answers 202 for everything, including a payload it rejected.
That is deliberate: a distinguishable response would let anyone test which site keys are
valid. Check your dashboard to confirm events are arriving.
Without JavaScript
A tracking pixel for visitors who run no scripts.
<noscript>
<img src="https://tastatur.dev/api/pixel?s=YOUR_SITE_KEY&u=https://example.com/"
alt="" width="1" height="1" referrerpolicy="no-referrer-when-downgrade">
</noscript>
The u parameter has to be written per page, so this is most practical in a
server-rendered layout where you can interpolate the current URL. Everything else
behaves the same, including bot filtering and the opt-out check.
Opt-out
Do Not Track and Global Privacy Control are honoured by default.
If a visitor's browser sends DNT: 1 or Sec-GPC: 1, no
identifier is computed and no event is stored. Only an anonymous per-hour counter is
incremented, so you can see that some requests opted out without any record of who.
This is checked in the script and again server-side, because the endpoint is a public API that an older cached script or the pixel might call without checking.
There is no per-person opt-out flag, because storing one would mean keeping a durable identifier for exactly the people who asked us not to.
data-ignore-dnt turns this off. A working opt-out is a condition of the
audience-measurement consent exemptions Tastatur is designed to qualify for, so
disabling it likely forfeits that argument. Take your own advice before using it.
Ad blockers
Some block Tastatur. You can proxy it through your own domain, and here is our position on doing so.
The script derives its endpoint from its own src, so proxying the script
through your domain automatically proxies the events with it. Point a path on your site
at this instance:
location /stats.js { proxy_pass https://tastatur.dev/t.js; }
location /stats/api { proxy_pass https://tastatur.dev/api/event; }
<script defer data-site="YOUR_SITE_KEY" src="/stats.js"></script>
Where we stand on this. Blockers exist because tracking earned them. Tastatur sets no cookie, stores nothing on the device and builds no cross-site profile, so a blocked Tastatur request protects nobody from anything.
That said, proxying is a way around a user's stated choice, and we will not pretend otherwise. It is documented because you will find it anyway and it is better done with the tradeoff in front of you. Note also that Do Not Track and Global Privacy Control are still honoured through a proxy, so a visitor who has explicitly objected remains uncounted.
What gets collected
The short version. The long version is a page of its own.
Page path
Query string stripped except utm_* tags. Personal data is removed from the path itself: emails, tokens, UUIDs and long ids are replaced with placeholders.
Referrer host
news.ycombinator.com, not the full referring URL.
Country
Two letters. No region, no city, no coordinates.
Browser and OS
Family plus major version only.
Device class
One of desktop, mobile, tablet.
Screen class
One of five buckets. The exact pixel width is never stored.
Visitor identifier
A truncated HMAC of a daily-rotating secret with the IP, site, and the coarse browser facts below — never the raw user-agent. Unlinkable once the secret is destroyed.
Raw IP addresses and user-agent strings are never written to the database, a log, or disk. A returning visitor is counted as a new visitor tomorrow, so a 30-day figure is the sum of 30 daily figures rather than a count of distinct people.
Cookies, precisely
On your visitors' devices
Nothing
No cookie is set and none is read. Nothing is written to localStorage, sessionStorage or IndexedDB. No identifier is stored on the device in any form. This is the part that determines whether you need a consent banner for Tastatur, and it is the promise the product is built around.
On this dashboard, for you
Up to three
A first-party session cookie, set when you sign in, so you stay signed in. A second if you tick "stay signed in". A third if you use two-factor authentication and ask us not to send a code to this browser every time. All strictly necessary for a login and exempt from consent on that basis. They are set on our site, for account holders, and never touch the sites you measure or the people who visit them.
We say this explicitly because "no cookies" is used loosely in this category. A login needs a session cookie; pretending otherwise would be nonsense rather than privacy. What matters is that nothing of ours reaches your visitors.