Base URL and authentication
Authorization: Bearer <YOUR_API_KEY> (details) and the stats:read permission.
site_id is the site’s snippet key, the ttid_ value your install snippet carries as data-site. You can read it from GET /v1/sites.
Every endpoint on this page rejects a parameter it does not recognise with a
422, rather than
ignoring it. An unknown filter_ dimension is refused the same way. A filter we cannot apply
would otherwise come back as a 200 carrying a number that answers a different question, and you
would have no way to tell.A visitor is not a session, and not a person you can name
A visitor is one browser on one site, tracked by a first-party cookie. A customer who visited four times is one row here and four rows in the Sessions API. If they cleared cookies or switched devices, Traceten sees a second visitor, unless your site callstraceten.identify(), which links the two halves.
Rows are addressed by a handle, not by an identifier:
- Opaque. It is a sealed encoding of an internal id. You cannot decode it, and it contains no fragment of anything we store about the person.
- Stable. The same visitor produces the same handle for as long as we hold them, so you can cache it, store it beside your own records, and link to it.
- Site-scoped. A handle is sealed against the site it was issued for. The same person on another of your sites has a different handle, and this one returns
404there. - Not a credential. Holding a handle grants nothing. You still need a key authorised for that site.
visitor_id, because none of those is stored in a form a query could match. A handle addresses a visitor you have already listed.
Nothing on this page returns a name, an email address, a phone number, an IP address, an ip_hash or a user agent.
GET /v1/visitors
One row per visitor, with how they first arrived and what they have done since.
Parameters
Filters
Write a filter asfilter_<dimension>=<value> for an exact match, or filter_<dimension>=<operator>:<value> to choose the operator. There are two operators, is and is_not, and a bare value means is.
422, never a silently dropped condition.
There is no
contains operator, and that is a decision rather than an omission. A substring search over open-ended fields such as city, campaign and page is an unindexed scan of every visit in the window, on a path that has to answer in under two seconds.
The rule that changes the answer
A visitor matches when at least one of their visits in the window matches, not when their first visit does.filter_country=DE therefore finds the person who first arrived on a United States address and came back from Berlin. Scoping the test to first touch would drop them and report a smaller number, confidently. For the same reason is_not: means none of their visits matched, not their first visit did not.
meta.filter_match echoes the rule as any_session_in_window, so a client never has to hardcode it.
The window, and what happens when you ask for more than we kept
Send neitherfrom nor to and the window is the last 30 days, not all of history. Send both together, or neither: a partial range is a 422.
You may ask for a window reaching further back than we retain, and you will get an answer covering the part we still hold. Session records are kept for 730 days, and past that boundary the store does not report “no data”, it reports a smaller number. So meta always tells you which window was actually answered:
effective_window. When clamped is true, the numbers describe a shorter range than you asked for.
Pagination
has_more is derived from total_count, not from whether the page came back full. A last page of exactly page_size rows does not advertise an empty page after it.
total_count is the number of visitors matching your filters across all pages. Unlike a session count it counts people rather than visits, and when meta.identity_merge_complete is true it is an exact headcount. When that field is false it is an upper bound instead, and the next section says why.
meta.identity_merge_complete, and when the count is an upper bound
Someone gets a new anonymous identifier until they sign in, at which point we link the two so their visits before and after read as one person. Those links travel with the query and there is a ceiling on how many fit. Past it, the most recent links are applied and the rest are not. The same ones are applied on every page, so paging stays consistent, but the counts become an upper bound on how many distinct people there are rather than an exact figure.
meta.identity_merge_complete is on the meta of GET /v1/visitors and GET /v1/visitors/{handle}, and tells you which of the two you are holding:
Show a caveat when it is
false rather than presenting the count as exact.
Fields
entry, device and location are first touch, not latest
All three answer “how did this person first arrive”, which is the attribution question. They are not the visitor’s most recent device or most recent source, and a dashboard that labels them that way is wrong. A visitor who found you through ChatGPT on a phone in March and came back through Google on a laptop in May shows the March values here. Their per-visit history is on GET /v1/visitors/{handle}.
revenue and client_revenue are two populations, never a sum
revenue is what a payment provider confirmed. client_revenue is what your own site reported by calling traceten.track() in the browser. The same purchase can appear in both, once from your track() call and once from your provider’s webhook for that order, so adding them double counts. Show them as two figures, which is what the sessions table has always done.
About label
Every field in label is derived from a one-way hash of the internal visitor id. No character of that id survives into it, and no lookup table reverses it. It exists so a person reading a report can say “that Amber Fox visitor” instead of reading a token aloud. It is not a name Traceten collects, stores or can look up.
GET /v1/visitors/{handle}
One visitor, plus their visits and their day-by-day activity.
handle from a visitor list, or from a funnel step’s visitors. Each session_id under sessions opens with GET /v1/sessions/{id}/detail. For what the visitor did inside those visits, use GET /v1/visitors/{handle}/timeline, which returns the events for a page of visits in one request.
Parameters
activity days are UTC unless you ask for another zone
Each entry in activity counts one calendar day. By default that day runs from 00:00 to 24:00 UTC. Pass tz to get the days your readers recognise instead:
2026-08-04 UTC comes back under 2026-08-04 by default, and under 2026-08-03 with tz=America/New_York, where those clocks read 9:10 PM to 9:40 PM the evening before:
first_seen, last_seen and every first_event_ts stay instants in UTC, and the visitor totals still cover meta.effective_window, which is a range of UTC dates.
Two consequences to design around:
- Pick one zone per chart. Counts from two responses fetched under different
tzvalues are grouped into different days, so adding them compares different hours. - A zone we do not recognise returns
422withfield: "tz". Send a name from the IANA database, such asEurope/Berlin, not an offset like+02:00.
404 here is normal, and it covers three cases on purpose
A 404 means any of the following, and the response does not say which:
- The handle was issued for a different site.
- The handle has been altered, even by one character.
- The handle is real and this visitor had no activity in the window.
404 for a bad handle and a 403 for a foreign one would together confirm which handles are real, which is exactly what an opaque handle is for. Do not treat a 404 as “malformed” and retry with a different window unless you meant to widen the window anyway.
sessions and activity can be empty beside a non-zero session_count
Both come from the raw event history, which is kept for a shorter period than the visitor rollup. A visitor whose events have aged out still has a row, still has a visit count, and returns empty lists here. retention.events_window_days says how far back they reach. Empty means “we no longer hold the detail”, not “nothing happened”.
GET /v1/visitors/{handle}/timeline
One page of a visitor’s visits, with the events inside each one.
Parameters
It pages by visit, not by event
page_size counts visits and total_count is the number of visits, not the number of events. A page of 10 visits can carry several hundred events, which is the budget this endpoint actually spends.
Paging by event would put a page boundary inside a visit and split its events across two requests, leaving you to stitch them back together. The unit is the thing the feed is grouped by.
has_more comes from total_count, so a last page of exactly page_size visits does not advertise an empty page after it.
ordinal counts from 1 at this person’s first visit in history_window, so “visit 4” means their fourth visit ever, not the fourth row on this page. It is assigned on the server, which is why it does not restart at 1 on page 2.
Two windows come back, and they are different ranges
history_window is generally the wider of the two. The visits are that person’s history rather than an answer to your date range, so a one-week request can return a visit list going back years. Do not label one window with the other’s dates, and do not add a figure computed over one to a figure computed over the other.
truncated and an empty entries are two different things
The second visit in the example above is the second case. Render it as “we no longer hold the detail for this visit”, not as “nothing happened” and not as “some events were dropped”.
There are exactly three event kinds
pageview, click and custom_event. That is the whole set, and two things people expect to find are deliberately not in it.
- Arrival and departure are not events. Read them from the visit’s own fields:
entry_urlwithsource_namefor how the visit started, andexit_urlwithexit_domainfor how it ended.exit_urlis the last page on your site;exit_domainis the registrable domain the visitor left for. They answer different questions, and rendering the first under an “exited to” label reports a visitor who left forgithub.comas having stayed on your site. - A payment is not a kind. It is a
custom_eventwithrevenue_centsabove 0. Treating it as its own kind would mean the store had to agree on what counts as a payment, and it does not need to.
url is empty on a custom_event, because conversions carry no URL. event_name is empty on a pageview and a click.
properties is your text, not ours
properties is whatever your own code passed to traceten.track(). We sanitise it when it is written and return it as stored, so treat it as untrusted free text: render it as text, never as markup and never as a link.
On the way in we redact any value that looks like an email address, a phone number, a payment card or a US SSN, cap values at 256 characters and keep at most 20 keys per event. That check does not recognise names or postal addresses.
Key names are treated differently, and one route differs from the others. A property whose key is exactly email, phone, name, password, token, ssn, credit_card or card_number is dropped before storage when it arrives from the tracking snippet or from a server SDK. It is not dropped on /v1/ingest/conversions, the authenticated ingestion API you call with a server API key: there the value is scanned and redacted as everywhere else, but a property named email is stored, and returned here, under that name.
Beyond that fixed list of eight, keys are not inspected for meaning on any route. So traceten.track('purchase', { customer_name: 'Jane Doe' }) stores and returns customer_name: 'Jane Doe', whichever route it arrived by. If you do not want personal data in this API, keep it out of properties and out of your URLs. The url on an entry carries the same caveat and the same redaction rules as every other page URL: see page URLs on the Sessions API.
404 is the same three cases as the detail route
A malformed handle, a handle issued for another site, and a real handle for a visitor with nothing in the window all return the same 404, and the response does not say which. See above.
GET /v1/funnels/{id}/steps/{position}/visitors
The people behind one number on GET /v1/funnels/{id}/results.
outcome=dropped returns the visitors who reached this step and went no further: the people the funnel lost between this step and the next one. outcome=reached returns everyone who got to this step, whether or not they continued.
position means the same step for both, so the drop-off between step 1 and step 2 is position=1&outcome=dropped. On the last step the two outcomes return the same people, because there is nothing after it to continue to.
A position past the funnel’s own step count is a 404, not an empty list, so “there is no step 5” never reads as “nobody got there”.
total_count here counts people: identifiers that belong to one signed-in person are merged before the page is cut, so a full page returns page_size rows. It can be lower than the step count on the funnel chart, which counts entries (someone who signed in partway through the funnel is two entries and one person). identity_merge_complete is false when a site has more identity links than the merge reads, in which case the count is an upper bound on people.
There is a goals-side equivalent that returns these same rows for the people who completed one goal: GET /v1/goals/{name}/visitors. Its total_count counts people rather than completions, so it is normally smaller than the figure on GET /v1/goals/timeseries for the same window.
Errors
Next
- Sessions API for the per-visit view
- Funnels API for the step counts these lists sit behind
- What we collect for what is stored against a visitor

