> ## Documentation Index
> Fetch the complete documentation index at: https://docs.traceten.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Visitors API

> List people rather than visits, filter them by how they arrived, and open one visitor's whole history on your site down to the events inside each visit.

## Base URL and authentication

```
https://api.traceten.com/v1/visitors
```

Takes `Authorization: Bearer <YOUR_API_KEY>` ([details](/api/authentication)) 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`](/api/sites).

<Note>
  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.
</Note>

## 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](/api/sessions). If they cleared cookies or switched devices, Traceten sees a second visitor, unless your site calls `traceten.identify()`, which links the two halves.

Rows are addressed by a `handle`, not by an identifier:

```
vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE
```

What a handle is:

* **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 `404` there.
* **Not a credential.** Holding a handle grants nothing. You still need a key authorised for that site.

What you cannot do with one: search by it, or by anything else that identifies a person. No endpoint on this page accepts a name, an email address, an IP address or a `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.

```bash theme={null}
curl "https://api.traceten.com/v1/visitors?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-01&to=2026-08-31&sort=revenue" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "visitors": [
    {
      "handle": "vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE",
      "label": { "name": "Amber Fox", "initials": "AF", "short_id": "v_a95a7076" },
      "first_seen": "2026-08-03T14:21:09Z",
      "last_seen": "2026-08-27T08:02:51Z",
      "session_count": 4,
      "pageviews": 19,
      "active_time_sec": 903,
      "is_new": false,
      "entry": {
        "ai_source": "chatgpt",
        "source_kind": "ai",
        "source_name": "",
        "campaign": "",
        "ref": "",
        "url": "https://example.com/pricing"
      },
      "device": { "device_type": "desktop", "os": "macOS", "browser": "Chrome" },
      "location": { "country_code": "NL", "city": "Amsterdam" },
      "revenue": { "total_cents": 8900, "currency": "USD", "conversion_count": 1 },
      "client_revenue": null,
      "latest_session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05"
    }
  ],
  "page": 1,
  "page_size": 50,
  "has_more": false,
  "total_count": 1,
  "meta": {
    "requested_window": { "from": "2026-08-01", "to": "2026-08-31" },
    "effective_window": { "from": "2026-08-01", "to": "2026-08-31" },
    "clamped": false,
    "retention_days": 730,
    "filter_match": "any_session_in_window",
    "identity_merge_complete": true
  }
}
```

### Parameters

| Parameter   | Type    | Required | Notes                                                                    |
| ----------- | ------- | -------- | ------------------------------------------------------------------------ |
| `site_id`   | string  | yes      | The `ttid_` snippet key.                                                 |
| `from`      | date    | no       | Inclusive `YYYY-MM-DD`. Send with `to` or not at all.                    |
| `to`        | date    | no       | Inclusive `YYYY-MM-DD`.                                                  |
| `page`      | integer | no       | 1-based, default 1. `(page - 1) * page_size` may not exceed 100000.      |
| `page_size` | integer | no       | 1 to 100, default 50.                                                    |
| `sort`      | string  | no       | `last_seen` (default), `first_seen`, `sessions`, `pageviews`, `revenue`. |
| `dir`       | string  | no       | `desc` (default) or `asc`.                                               |
| `filter_*`  | string  | no       | One per dimension, up to 8 in total. See [Filters](#filters).            |

### Filters

Write a filter as `filter_<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`.

```bash theme={null}
curl "https://api.traceten.com/v1/visitors?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12\
&filter_country=DE\
&filter_ai_source=is_not:chatgpt\
&sort=revenue" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

Repeat a parameter to add more conditions. All of them have to hold, and at most 8 combine. A value is capped at 256 characters. An unknown dimension is a `422`, never a silently dropped condition.

| Dimension          | Matches                                              |
| ------------------ | ---------------------------------------------------- |
| `filter_device`    | Device type, such as `desktop`, `mobile`, `tablet`.  |
| `filter_os`        | Operating system, such as `iOS`.                     |
| `filter_browser`   | Browser, such as `Chrome`.                           |
| `filter_country`   | ISO 3166-1 alpha-2 country code, such as `DE`.       |
| `filter_city`      | City name, such as `Berlin`.                         |
| `filter_channel`   | Traffic category: `ai`, `non_ai` or `unknown`.       |
| `filter_source`    | The specific referring host, such as `google.com`.   |
| `filter_campaign`  | The `utm_campaign` on the landing URL.               |
| `filter_ref`       | The `?ref=` value on the landing URL.                |
| `filter_page`      | The landing page URL, matched exactly.               |
| `filter_ai_source` | The AI assistant, such as `chatgpt` or `perplexity`. |

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.

<Warning>
  The consequence when you report a filtered list: the totals on each row still cover that visitor's
  **whole** history in the window, not only the visits that matched. The revenue beside
  `filter_country=DE` is that person's total revenue, not their revenue from Germany. If you need
  per-condition revenue, use the [Revenue API](/api/revenue), which aggregates on that basis.
</Warning>

### The window, and what happens when you ask for more than we kept

Send neither `from` 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:

```json theme={null}
{
  "requested_window": { "from": "2023-01-01", "to": "2026-08-31" },
  "effective_window": { "from": "2024-09-01", "to": "2026-08-31" },
  "clamped": true,
  "retention_days": 730,
  "filter_match": "any_session_in_window",
  "identity_merge_complete": true
}
```

Label charts with `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:

| Value   | What the counts mean                                                                                                     |
| ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `true`  | An exact headcount. True for any site under the ceiling, which in practice is nearly all of them.                        |
| `false` | An upper bound. This site has more sign-in links than one query can carry, so some people are still counted as two rows. |

Show a caveat when it is `false` rather than presenting the count as exact.

### Fields

| Field               | Notes                                                                                                                          |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `handle`            | How to address this visitor. Opaque, stable, site-scoped.                                                                      |
| `label`             | A generated two-word name, initials and a short id, derived from a one-way hash. Not a name Traceten holds. See below.         |
| `first_seen`        | First activity in the window, UTC.                                                                                             |
| `last_seen`         | Most recent activity in the window, UTC.                                                                                       |
| `session_count`     | Visits in the window.                                                                                                          |
| `pageviews`         | Pageviews in the window.                                                                                                       |
| `active_time_sec`   | Foreground seconds the browser reported, not wall-clock dwell. `0` means nothing was measured, not that the visit was instant. |
| `is_new`            | `true`, `false`, or `null` when we never recorded it. `null` is not `false`.                                                   |
| `entry`             | How the visitor **first** arrived, across every visit they have.                                                               |
| `device`            | The device of their **first** visit.                                                                                           |
| `location`          | Where their **first** visit came from. City is the finest granularity we hold.                                                 |
| `revenue`           | Revenue a payment provider confirmed. `null` when there is none. US cents.                                                     |
| `client_revenue`    | Revenue your own site reported with `track()`. `null` when there is none. US cents.                                            |
| `latest_session_id` | Their most recent visit in the window. Open it with [`GET /v1/sessions/{id}/detail`](/api/sessions#get-v1-sessions-id-detail). |

#### `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}`](#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.

```bash theme={null}
curl "https://api.traceten.com/v1/visitors/vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "visitor": {
    "handle": "vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE",
    "label": { "name": "Amber Fox", "initials": "AF", "short_id": "v_a95a7076" },
    "first_seen": "2026-08-03T14:21:09Z",
    "last_seen": "2026-08-27T08:02:51Z",
    "session_count": 4,
    "pageviews": 19,
    "active_time_sec": 903,
    "is_new": false,
    "entry": {
      "ai_source": "chatgpt",
      "source_kind": "ai",
      "source_name": "",
      "campaign": "",
      "ref": "",
      "url": "https://example.com/pricing"
    },
    "device": { "device_type": "desktop", "os": "macOS", "browser": "Chrome" },
    "location": { "country_code": "NL", "city": "Amsterdam" },
    "revenue": { "total_cents": 8900, "currency": "USD", "conversion_count": 1 },
    "client_revenue": null,
    "latest_session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05"
  },
  "sessions": [
    {
      "session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05",
      "first_event_ts": "2026-08-27T08:02:51Z",
      "ai_source": "",
      "source_kind": "non_ai",
      "source_name": "google.com",
      "pageviews": 3
    }
  ],
  "activity": [{ "date": "2026-08-03", "pageviews": 6, "sessions": 1 }],
  "retention": { "events_window_days": 730 },
  "meta": {
    "requested_window": { "from": "2026-07-29", "to": "2026-08-28" },
    "effective_window": { "from": "2026-07-29", "to": "2026-08-28" },
    "clamped": false,
    "retention_days": 730,
    "filter_match": "any_session_in_window",
    "identity_merge_complete": true
  }
}
```

Take the `handle` from a visitor list, or from a [funnel step's visitors](#get-v1-funnels-id-steps-position-visitors). Each `session_id` under `sessions` opens with [`GET /v1/sessions/{id}/detail`](/api/sessions#get-v1-sessions-id-detail). For what the visitor did inside those visits, use [`GET /v1/visitors/{handle}/timeline`](#get-v1-visitors-handle-timeline), which returns the events for a page of visits in one request.

### Parameters

| Parameter    | Required | Description                                                                                  |
| ------------ | -------- | -------------------------------------------------------------------------------------------- |
| `site_id`    | yes      | The site. With an API key, pass the public `ttid_` snippet key.                              |
| `from`, `to` | no       | `YYYY-MM-DD`, sent together, on the same terms as the list. They bound the `visitor` rollup. |
| `tz`         | no       | IANA time zone name that the days in `activity` are cut in. Defaults to `UTC`.               |

### `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:

```bash theme={null}
curl "https://api.traceten.com/v1/visitors/vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&tz=America/New_York" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

A visit that ran from 01:10 to 01:40 on `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:

```json theme={null}
{
  "activity": [{ "date": "2026-08-03", "pageviews": 3, "sessions": 1 }]
}
```

Nothing else in the response moves. `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 `tz` values are grouped into different days, so adding them compares different hours.
* A zone we do not recognise returns `422` with `field: "tz"`. Send a name from the IANA database, such as `Europe/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.

They are deliberately indistinguishable. If they were not, a `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.

```bash theme={null}
curl "https://api.traceten.com/v1/visitors/vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE/timeline?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&page_size=2" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "sessions": [
    {
      "session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05",
      "ordinal": 4,
      "first_event_ts": "2026-08-27T08:02:51Z",
      "last_event_ts": "2026-08-27T08:11:30Z",
      "active_time_sec": 412,
      "pageviews": 3,
      "event_count": 5,
      "entry_url": "https://example.com/pricing",
      "exit_url": "https://example.com/checkout/thanks",
      "exit_domain": "",
      "ai_source": "chatgpt",
      "source_kind": "ai",
      "source_name": "",
      "revenue": { "total_cents": 8900, "currency": "USD", "conversion_count": 1 },
      "client_revenue": null,
      "entries": [
        {
          "kind": "pageview",
          "ts": "2026-08-27T08:02:51Z",
          "url": "https://example.com/pricing",
          "event_name": "",
          "properties": {},
          "revenue_cents": 0,
          "currency": ""
        },
        {
          "kind": "click",
          "ts": "2026-08-27T08:04:12Z",
          "url": "https://example.com/pricing",
          "event_name": "",
          "properties": {},
          "revenue_cents": 0,
          "currency": ""
        },
        {
          "kind": "custom_event",
          "ts": "2026-08-27T08:11:02Z",
          "url": "",
          "event_name": "purchase",
          "properties": { "plan": "pro" },
          "revenue_cents": 8900,
          "currency": "usd"
        }
      ],
      "truncated": false
    },
    {
      "session_id": "1c0d9e77-2a41-4f88-9b3e-5d7c2a1f4e60",
      "ordinal": 3,
      "first_event_ts": "2026-08-14T11:40:08Z",
      "last_event_ts": "2026-08-14T11:43:55Z",
      "active_time_sec": 190,
      "pageviews": 2,
      "event_count": 2,
      "entry_url": "https://example.com/blog/ai-traffic",
      "exit_url": "https://example.com/blog/ai-traffic",
      "exit_domain": "github.com",
      "ai_source": "",
      "source_kind": "non_ai",
      "source_name": "google.com",
      "revenue": null,
      "client_revenue": null,
      "entries": [],
      "truncated": false
    }
  ],
  "page": 1,
  "page_size": 2,
  "has_more": true,
  "total_count": 4,
  "history_window": { "from": "2024-08-29", "to": "2026-08-28" },
  "meta": {
    "requested_window": { "from": "2026-07-29", "to": "2026-08-28" },
    "effective_window": { "from": "2026-07-29", "to": "2026-08-28" },
    "clamped": false,
    "retention_days": 730,
    "filter_match": "any_session_in_window",
    "identity_merge_complete": true
  }
}
```

### Parameters

| Parameter   | Type    | Required | Notes                                                 |
| ----------- | ------- | -------- | ----------------------------------------------------- |
| `handle`    | string  | yes      | Path. The `vh_` handle from a visitor list.           |
| `site_id`   | string  | yes      | The `ttid_` snippet key.                              |
| `from`      | date    | no       | Inclusive `YYYY-MM-DD`. Send with `to` or not at all. |
| `to`        | date    | no       | Inclusive `YYYY-MM-DD`.                               |
| `page`      | integer | no       | 1-based, default 1.                                   |
| `page_size` | integer | no       | **Visits** per page, 1 to 25, default 10. Not events. |

### 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

| Field                   | What it covers                                                                                                                                                       |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `meta.effective_window` | The range you asked for, clamped to how long an individual session record is kept.                                                                                   |
| `history_window`        | The range the **visits** are drawn from: the whole span of event history we still hold, bounded by how long raw events are kept and not narrowed by `from` and `to`. |

`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

| State                               | What happened                                                                                                                                |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `truncated: true`                   | This visit had more events than the per-visit ceiling, and the **oldest** were dropped. Recent activity, including a conversion, is present. |
| `truncated: false`, `entries` empty | The visit's events aged out of the event store while the visit itself survived. Nothing was dropped for size.                                |

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_url` with `source_name` for how the visit started, and `exit_url` with `exit_domain` for how it ended. `exit_url` is the last page on **your** site; `exit_domain` is 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 for `github.com` as having stayed on your site.
* **A payment is not a kind.** It is a `custom_event` with `revenue_cents` above 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](/api/sessions#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](#404-here-is-normal-and-it-covers-three-cases-on-purpose).

## `GET /v1/funnels/{id}/steps/{position}/visitors`

The people behind one number on [`GET /v1/funnels/{id}/results`](/api/funnels).

```bash theme={null}
curl "https://api.traceten.com/v1/funnels/2f1c5a90-9e14-4f0b-9f2a-4a3f1b7c8d21/steps/3/visitors?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&outcome=dropped" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "visitors": [
    {
      "handle": "vh_QkR2c19WTHhKS0FtdWJmVW5wS0F5RnRySEE",
      "label": { "name": "Amber Fox", "initials": "AF", "short_id": "v_a95a7076" },
      "first_seen": "2026-08-03T14:21:09Z",
      "last_seen": "2026-08-27T08:02:51Z",
      "session_count": 4,
      "pageviews": 19,
      "active_time_sec": 903,
      "is_new": false,
      "entry": {
        "ai_source": "chatgpt",
        "source_kind": "ai",
        "source_name": "",
        "campaign": "",
        "ref": "",
        "url": "https://example.com/pricing"
      },
      "device": { "device_type": "desktop", "os": "macOS", "browser": "Chrome" },
      "location": { "country_code": "NL", "city": "Amsterdam" },
      "revenue": { "total_cents": 8900, "currency": "USD", "conversion_count": 1 },
      "client_revenue": null,
      "latest_session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05"
    }
  ],
  "position": 3,
  "outcome": "dropped",
  "page": 1,
  "page_size": 50,
  "has_more": false,
  "total_count": 1
}
```

| Parameter   | Type    | Required | Notes                                                                  |
| ----------- | ------- | -------- | ---------------------------------------------------------------------- |
| `id`        | uuid    | yes      | Path. The funnel, from [`GET /v1/funnels`](/api/funnels).              |
| `position`  | integer | yes      | Path. Which step, counting from 1.                                     |
| `site_id`   | string  | yes      | The `ttid_` snippet key.                                               |
| `outcome`   | string  | no       | `dropped` (default) or `reached`.                                      |
| `from`      | date    | no       | Inclusive `YYYY-MM-DD`. Defaults to the last 7 days. At most 730 days. |
| `to`        | date    | no       | Inclusive `YYYY-MM-DD`.                                                |
| `page`      | integer | no       | 1-based, default 1.                                                    |
| `page_size` | integer | no       | 1 to 100, default 50.                                                  |
| `filters`   | json    | no       | The same filter object [`/results`](/api/funnels) takes.               |

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

<Warning>
  The totals on each row cover that visitor's whole history in the window, not just their run
  through this funnel. Do not report `revenue` here as the step's revenue. The funnel's own per-step
  revenue is on [`GET /v1/funnels/{id}/results`](/api/funnels).
</Warning>

There is a goals-side equivalent that returns these same rows for the people who completed one goal: [`GET /v1/goals/{name}/visitors`](/api/goals). 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

| Status | When                                                                                                                                                                                                                                                                               |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Missing or invalid credential.                                                                                                                                                                                                                                                     |
| `403`  | A dashboard session asking for a site the signed-in user cannot reach. An API key gets `404` instead, so it cannot enumerate site ids.                                                                                                                                             |
| `404`  | A site your key cannot reach, or a handle that does not resolve for this site. See [above](#404-here-is-normal-and-it-covers-three-cases-on-purpose).                                                                                                                              |
| `422`  | A parameter we do not recognise, an unknown filter dimension, a partial date range, `from` after `to`, more than 8 filters, a filter value over 256 characters, a `page_size` over the endpoint's cap (100 on a list, 25 on a timeline), or a page deeper than the offset ceiling. |
| `429`  | Rate limited. Retry after the interval in the response.                                                                                                                                                                                                                            |
| `500`  | The query failed. Retry.                                                                                                                                                                                                                                                           |

## Next

* [Sessions API](/api/sessions) for the per-visit view
* [Funnels API](/api/funnels) for the step counts these lists sit behind
* [What we collect](/privacy/data-collected) for what is stored against a visitor
