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

# Sessions API

> List sessions, read one in full, and follow the AI touchpoints behind a conversion.

## Base URL and authentication

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

Takes `Authorization: Bearer <YOUR_API_KEY>` ([details](/api/authentication)) and the `stats:read` permission.

<Note>
  Every endpoint on this page rejects a parameter it does not recognise with a `422`, rather than
  ignoring it. 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. In particular the per-session
  endpoints take `site_id` and nothing else.
</Note>

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

## What a session is, and what it is not

A `session_id` is an opaque first-party cookie value. It groups one visit. It is not a person, it is not an account, and it does not identify anyone by itself.

Traceten never returns an IP address, an `ip_hash`, a user agent, a language or a timezone from these endpoints, and `visitor_id` is never returned at any depth in any response on this page.

<Warning>
  One class of field is an exception, and it is yours rather than ours: the page URLs, in
  `entry_url`, `exit_url` and `page_urls`. We do not generate those, so we cannot make a guarantee
  about them on your behalf. If you do not want personal data in this API, keep it out of your URLs.
  See [what we collect](/privacy/data-collected).
</Warning>

#### Page URLs

A page URL arrives by two paths with two different guarantees, so no single statement covers it.

**From the browser snippet**, `sanitizeUrl` (`packages/snippet/src/track.ts:389-415`) rewrites the URL before it is sent. It works only on the QUERY STRING, and there it does exactly three things:

* It replaces the VALUE of any query parameter whose name matches one of 19 exact names, compared case-insensitively: `email`, `e`, `mail`, `phone`, `tel`, `name`, `firstname`, `lastname`, `fname`, `lname`, `token`, `access_token`, `id_token`, `code`, `state`, `password`, `pwd`, `ssn`, `dob` (the `PII_PARAMS` list, `packages/snippet/src/track.ts:182-202`).
* It also replaces the value of any parameter whose name contains `token`, `secret` or `key`.
* Replaced values become the literal string `REDACTED`. The parameter NAME is kept.

Everything else is sent verbatim. In particular:

* **The path is never touched.** `/u/jane.doe@example.com` and `/reset/<token>` arrive intact.

* **The fragment (`#...`) is never examined.** The redaction reads `URL.searchParams`, which cannot see past the `#`, and the fragment survives into the value we store. An OAuth implicit-flow callback such as `https://example.com/cb#access_token=eyJ...&id_token=...` is sent **verbatim**, even though `access_token` and `id_token` are on the list above. Being in the fragment is what makes the difference:

  ```text theme={null}
  https://example.com/p?access_token=abc   ->  https://example.com/p?access_token=REDACTED
  https://example.com/p#access_token=abc   ->  https://example.com/p#access_token=abc
  ```

  If your app receives tokens in a fragment, do not let Traceten see that page. Strip the hash before the snippet runs, or exclude the callback route.

* **Any query parameter not on that list is sent as-is**, including ones that plainly carry personal data under another name, such as `?customer=jane.doe@example.com` or `?address=...`.

* If the URL cannot be parsed, it is sent unchanged.

**From a server-side SDK**, none of the above applies. Server-side ingestion does not run the snippet, so `sanitizeUrl` never executes and the URL is stored exactly as your code supplied it. **If you send events from your server, scrub URLs before you emit them.** Nothing downstream does it for you.

A session is one visit. If your question counts **people** rather than visits, use the [Visitors API](/api/visitors), which returns one row per visitor and can be filtered by source, campaign, landing page, country, city, device and browser. A visitor row carries `latest_session_id`, so a person leads to their visits, and each visit opens here with [`/detail`](#get-v1-sessions-id-detail).

For what a visit actually did, event by event, use [`GET /v1/visitors/{handle}/timeline`](/api/visitors#get-v1-visitors-handle-timeline). It returns the events for a page of one person's visits, so a single request covers the trail across every visit they made rather than one visit at a time.

Neither direction accepts an identity. No endpoint takes a name, an email address, an IP address or a `visitor_id` as an input, and none returns one. Visitor rows are addressed by an opaque handle that is sealed against a single site, so a handle from one site returns `404` on another, and nothing links a person's activity across two customers.

## `GET /v1/sessions`

One row per session, newest first, with the AI source, the confidence and the detection method behind it.

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

```json theme={null}
{
  "sessions": [
    {
      "session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05",
      "first_event_ts": "2026-08-04T09:12:03Z",
      "last_event_ts": "2026-08-04T09:19:44Z",
      "ai_source": "chatgpt",
      "ai_confidence": 0.92,
      "detection_method": "referrer_match",
      "stage": 1,
      "revenue_cents": 0,
      "stripe_revenue_cents": 8900,
      "landing_page": "https://example.com/pricing",
      "exit_url": "https://example.com/checkout",
      "active_time_seconds": 214,
      "converted": true,
      "is_dark_traffic": false,
      "source_name": "chatgpt.com",
      "device_type": "desktop",
      "city": "Amsterdam",
      "country_code": "NL",
      "utm_campaign": "",
      "ref": ""
    }
  ],
  "page": 1,
  "page_size": 50,
  "has_more": false,
  "total_count": 1
}
```

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

If you send neither `from` nor `to`, 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. It will cover only the part we still hold. Session records are guaranteed for [at least 730 days](#404-here-is-normal-and-there-are-two-of-them), and past that boundary the store does not report "no data", it reports a smaller number, precisely and confidently.

So **`meta` always tells you which window was actually answered**:

```json theme={null}
{
  "sessions": [],
  "page": 1,
  "page_size": 50,
  "has_more": false,
  "total_count": 0,
  "meta": {
    "requested_window": { "from": "2025-09-01", "to": "2026-08-27" },
    "effective_window": { "from": "2026-05-30", "to": "2026-08-27" },
    "clamped": true,
    "retention_days": 90
  }
}
```

| Field              | Meaning                                                                   |
| ------------------ | ------------------------------------------------------------------------- |
| `requested_window` | What you asked for, after defaults are applied.                           |
| `effective_window` | What the query actually covered. This is the range your numbers describe. |
| `clamped`          | `true` when the two differ, so you can surface a caveat.                  |
| `retention_days`   | The session-grain retention floor the clamp is derived from.              |

`meta` is present on every response, clamped or not, so you never have to branch on its absence. **Label your charts with `effective_window`, not with what you asked for.**

### Query parameters

| Parameter        | Type    | Required | Notes                                                                     |
| ---------------- | ------- | -------- | ------------------------------------------------------------------------- |
| `site_id`        | string  | yes      | The snippet key.                                                          |
| `from`           | string  | no       | `YYYY-MM-DD`, inclusive.                                                  |
| `to`             | string  | no       | `YYYY-MM-DD`, inclusive. Must not be before `from`.                       |
| `page`           | integer | no       | 1-based. Defaults to 1. See the paging depth limit below.                 |
| `page_size`      | integer | no       | 1 to 200. Defaults to 50.                                                 |
| `source`         | string  | no       | Restrict to one `ai_source`. `direct` also matches unresolved sources.    |
| `method`         | string  | no       | Restrict to one `detection_method`.                                       |
| `min_confidence` | number  | no       | 0 to 1. Drop anything classified below this.                              |
| `converted`      | string  | no       | `true` or `false`.                                                        |
| `q`              | string  | no       | Case-insensitive substring search over the landing page. Up to 200 chars. |
| `campaign`       | string  | no       | Case-insensitive substring search over `utm_campaign`. Up to 64 chars.    |
| `sort`           | string  | no       | `timestamp` (default) or `confidence`.                                    |
| `dir`            | string  | no       | `desc` (default) or `asc`.                                                |

### Paging depth

`(page - 1) * page_size` may not exceed 100,000. Past that you get a `422`.

The database reads every row it skips, so a very deep page is the most expensive request this endpoint can serve. If you are paging that far you want a narrower window or a filter, not a higher page number.

`campaign` is a substring search rather than an exact match because campaign names are free text with no canonical form: `spring_sale` and `spring-sale` are two different campaigns, and an exact filter would silently return nothing for a near miss.

### Reading the response

`is_dark_traffic` is `true` when no header identified the source, so the classification came from other signals rather than a `Referer` or a `utm_source`. This is the \~70% of AI traffic that arrives with nothing to read, and it is the reason this product exists.

`stage` is a number identifying which detection check made the decision, recorded so you can debug a classification alongside `detection_method`. `0` means no AI source was identified.

`active_time_seconds` is **foreground** time on page, summed from what the snippet measured. It is not wall-clock session duration. `0` means nothing was measured, not that the visit was instantaneous. If you want wall-clock, subtract `first_event_ts` from `last_event_ts`, both of which are returned.

`utm_campaign` and `ref` are informational. Neither influenced the `ai_source`, `detection_method` or `stage` on the same row. They are also customer-supplied free text echoed back unchanged, so escape them before rendering.

`city` and `country_code` are empty when they could not be resolved. Empty is a real, permanent value. Render it as a dash, not as the word "unknown".

## `GET /v1/sessions/{id}/detail`

Everything known about one session.

```bash theme={null}
curl "https://api.traceten.com/v1/sessions/8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05/detail?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "session_id": "8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05",
  "ai_source": "chatgpt",
  "detection_method": "referrer_match",
  "ai_confidence": 0.92,
  "stage": 1,
  "detection_trace": "referrer host chat.openai.com matched rule set v4",
  "first_event_ts": "2026-08-04T09:12:03Z",
  "last_event_ts": "2026-08-04T09:19:44Z",
  "entry_url": "https://example.com/pricing",
  "exit_url": "https://example.com/checkout",
  "referrer": "https://chat.openai.com/",
  "pages_visited": 4,
  "time_on_site_seconds": 461,
  "page_urls": ["https://example.com/pricing", "https://example.com/checkout"],
  "revenue": { "total_cents": 8900, "currency": "USD" },
  "source_name": "chatgpt.com",
  "device_type": "desktop",
  "os": "macOS",
  "browser": "Safari",
  "city": "Amsterdam",
  "country_code": "NL",
  "utm_campaign": "",
  "ref": ""
}
```

The path is `/detail`, not `/{id}`. `detection_trace` is why the classifier decided what it did, so you can argue with a classification you disagree with: the rules that fired and the other evidence behind the score.

One field is removed before it reaches you: `fingerprint_id`, an identifier that is shared across sites by design, which is exactly why we do not hand it out. Nothing else in the trace is withheld.

A `404` means the session is no longer retained.

Session records live for **at least 730 days and at most about 760**.

The store's TTL is `toDateTime(partition_month) + INTERVAL 760 DAY`, set by `packages/db/clickhouse/migrations/071_raise_event_and_session_retention.sql`. That migration is an `ALTER`, so the earlier files that created and rebuilt the table still read `120` and are no longer the live definition. `partition_month` is the start of the month the session began in, so a session that started on the 31st is measured from the 1st: 760 minus up to 30 days of slack leaves a guaranteed floor of 730. That floor is `SESSION_GRAIN_RETENTION_DAYS` in `packages/db/src/clickhouse/queries.ts`, and it is the number this API clamps to.

730 is the floor you can rely on, not the point of deletion. If you are restating our retention in your own privacy notice, quote the range.

A session can outlive its own events. When that happens you get a row back with an empty `page_urls`, which is the honest answer, rather than a `404` that would read as "no such session".

### `exit_url`, and why the two endpoints can disagree

`exit_url` is the last page measured in the session, the counterpart to `entry_url`. Both are page URLs, so the same query-parameter redaction applies to each.

The two endpoints compute it differently, and for older sessions they can return different answers for the same session:

* **This endpoint** derives it from the session's own event records, so it is populated for the full raw-event retention window.
* **`GET /v1/sessions`** reads a value summarized when the session was recorded. Sessions that started before we began recording exit pages have no such value, so the field comes back empty there.

Empty always means "not recorded", never "the visitor did not leave a last page". Every session that viewed a page has one. If you need the exit page for a session older than the feature, read it from this endpoint.

One consequence worth stating plainly: a visit whose only page-load event arrived after a late consent grant still reports an exit page. We count both ordinary page loads and that first post-consent event as page loads, so a single-page visit reports the same URL for `entry_url` and `exit_url` rather than reporting an entry with no exit.

## `GET /v1/sessions/{id}/journey`

Every AI touchpoint leading to this session's conversion, with the credit each one gets under all four attribution models at once.

```bash theme={null}
curl "https://api.traceten.com/v1/sessions/8a2e5c11-3f60-4b92-bb04-6d1e7a9f2c05/journey?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "touches": [
    {
      "session_id": "1c0d9e77-2a41-4f88-9b3e-5d7c2a1f4e60",
      "first_event_ts": "2026-07-28T14:31:02Z",
      "last_event_ts": "2026-07-28T14:36:19Z",
      "ai_source": "perplexity",
      "ai_confidence": 0.95,
      "detection_method": "referrer_match"
    }
  ],
  "conversion": { "ts": "2026-08-04T09:19:44Z", "revenue_cents": 8900 },
  "attribution": {
    "first_touch": [
      {
        "session_id": "1c0d9e77-2a41-4f88-9b3e-5d7c2a1f4e60",
        "source": "perplexity",
        "credit_fraction": 1.0,
        "revenue_cents_attributed": 8900
      }
    ],
    "last_touch": [
      {
        "session_id": "1c0d9e77-2a41-4f88-9b3e-5d7c2a1f4e60",
        "source": "perplexity",
        "credit_fraction": 1.0,
        "revenue_cents_attributed": 8900
      }
    ],
    "linear": [
      {
        "session_id": "1c0d9e77-2a41-4f88-9b3e-5d7c2a1f4e60",
        "source": "perplexity",
        "credit_fraction": 1.0,
        "revenue_cents_attributed": 8900
      }
    ],
    "time_decay": [
      {
        "session_id": "1c0d9e77-2a41-4f88-9b3e-5d7c2a1f4e60",
        "source": "perplexity",
        "credit_fraction": 1.0,
        "revenue_cents_attributed": 8900
      }
    ]
  }
}
```

All four models come back in one response so you can let a user switch between them without another request. Within a model, `credit_fraction` sums to 1.0 whenever there is at least one AI touch.

The `session_id` in every `attribution` entry is copied verbatim from `touches`, so you can join the two on it. All four arrays are built from the same touch list, so they are all populated or all empty. They never disagree about *which* sessions are involved, only about how much credit each one gets. The single-touch example above is the degenerate case where every model agrees; the models diverge once there are two or more touches.

When `conversion` is `null` the session has not converted yet. The model arrays are then a preview of how credit *would* be split, and every `revenue_cents_attributed` is 0.

## Errors

Standard [error shapes](/api/errors) apply.

| Status | When                                                                                                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `404`  | The session is not in the window, or the site is not yours.                                                                                                        |
| `422`  | A malformed session id or `site_id`, only one of `from`/`to`, `to` before `from`, `page_size` over 200, a page past the depth limit, or an unrecognised parameter. |
| `429`  | You are over the read rate limit. Retry after the interval in the response headers.                                                                                |

## Next

* [Revenue API](/api/revenue) for the aggregate numbers these sessions add up to.
* [Sources API](/api/sources) for session counts per source.
