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

# AI Traffic

> Capture visits from AI crawlers and AI referrals with the @usenotra/geo tracker and read them in the Traffic page or the API

AI traffic tracking shows which AI systems fetch your pages and which people arrive from an AI answer. A small request-capture SDK on your site sends a neutral request envelope to Notra; all classification happens at ingest. The results appear on the **Traffic** page (titled **AI Traffic**) and under `/geo/traffic` in the API.

## What is captured

Every captured request is classified into one of two tracked visitor types:

* **AI crawler**: bots fetching your pages to train models or build a search index. Each crawler carries a **Purpose**: **Model training** (collects pages for training corpora), **Search index** (builds the index an AI answer engine searches), or **Cited in answer** (fetched while an assistant was answering someone).
* **AI referral**: a person who clicked through to your site from an AI answer. Detection is based on the referrer host, for example chatgpt.com, perplexity.ai, gemini.google.com, claude.ai, copilot.microsoft.com, you.com, chat.deepseek.com, chat.mistral.ai, grok.com, and chat.qwen.ai.

Requests classified as human are discarded at ingest and never stored. Each stored event carries the source, the agent, the purpose, a **Confidence** of **Verified**, **Reported**, or **Heuristic** (how the agent's signature was sourced), the path and host, the country, whether the client asked for Markdown, and a journey id.

### Journeys

A journey groups the pages one agent read in a session. Two kinds exist:

* **Tagged journey**: the agent followed a link carrying an `ntr` query parameter that your site minted. This is exact.
* **Fingerprinted journey**: requests were matched by a heuristic: the source, a truncated IP prefix, and a 30 minute time bucket (10 minutes and the full IP for assistant browsing), hashed with a salt that rotates daily. This is an estimate.

Journeys appear on the Overview page under the **Journeys** section and in the API. The **Agent journeys** table has **Journey**, **Source**, **Pages**, **Unique**, **Span**, **Last seen**, and **Path** columns.

## Set up the tracker

<Steps>
  <Step title="Get your token">
    Open **Traffic** in the GEO sidebar. Until the first event arrives the page shows **No activity yet** with the full install panel: **Install the package**, **Set your token**, and **Add the proxy**. The token is shown in the **Set your token** section. The panel also has a **Copy agent prompt** button that copies an instruction block you can paste into a coding agent.

    From the API, reading the snippets needs `traffic.read` and issuing the token needs `traffic.write`:

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl https://api.usenotra.com/v1/geo/ingest/setup \
      -H "Authorization: Bearer $NOTRA_API_KEY"
    ```

    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl -X POST "https://api.usenotra.com/v1/geo/ingest/token?projectId=$PROJECT_ID" \
      -H "Authorization: Bearer $NOTRA_API_KEY"
    ```

    Both return `ingestUrl`, `snippet` (the Next.js snippet), and `snippets` with `next`, `nuxt`, `tanstack`, `astro`, `sveltekit`, and `netlify` keys. The token endpoint adds `token`. The ingest routes are organization-level; pass `projectId` to bind the token to one project or omit it to track the whole organization. Issuing a token does not invalidate tokens issued earlier. Install the same project token on every domain the project tracks; add extra hostnames under **Tracked domains** in GEO settings.
  </Step>

  <Step title="Install the package">
    ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
    bun add @usenotra/geo
    ```

    The package has zero dependencies and no Node APIs in the core, so it runs on edge runtimes. Install with npm, pnpm, or yarn if you prefer.
  </Step>

  <Step title="Set the environment variable">
    Add the token as `NOTRA_GEO_TOKEN` in your site's environment variables, locally and at your hosting provider. Never hardcode or commit it.
  </Step>

  <Step title="Add the proxy">
    Pick your framework. These are the snippets the dashboard generates; `endpoint` is the origin the SDK posts to and the SDK appends `/api/geo/ingest`.

    <Tabs>
      <Tab title="Next.js">
        ```typescript proxy.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
        import { createGeoProxy } from "@usenotra/geo/next";
        import { after, NextResponse } from "next/server";

        const geo = createGeoProxy({
          token: process.env.NOTRA_GEO_TOKEN!,
          endpoint: "https://app.usenotra.com",
        });

        export function proxy(request: Request) {
          after(() => geo(request));
          return NextResponse.next();
        }
        ```

        Use `proxy.ts` on Next.js 16 and `middleware.ts` on earlier versions. `geo(request)` never throws and never blocks the response.
      </Tab>

      <Tab title="Nuxt">
        ```typescript server/middleware/geo.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
        import { createGeoHandler } from "@usenotra/geo/nuxt";

        const geo = createGeoHandler({
          token: process.env.NOTRA_GEO_TOKEN!,
          endpoint: "https://app.usenotra.com",
        });

        export default defineEventHandler(async (event) => {
          await geo(event);
        });
        ```

        Geo headers are usually absent behind Nitro, so location fields stay undefined and ingest falls back to an IP-based lookup.
      </Tab>

      <Tab title="TanStack Start">
        ```typescript src/start.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
        import { createMiddleware, createStart } from "@tanstack/react-start";
        import { createGeoMiddleware } from "@usenotra/geo/tanstack";

        const geo = createMiddleware().server(createGeoMiddleware({
          token: process.env.NOTRA_GEO_TOKEN!,
          endpoint: "https://app.usenotra.com",
        }));

        export const startInstance = createStart(() => ({
          requestMiddleware: [geo],
        }));
        ```

        The middleware captures requests alongside your route handler and waits for the send before returning, so tracking completes on serverless hosts. It preserves the downstream response and errors. If `src/start.ts` already exists, add `geo` to its existing `requestMiddleware` array.
      </Tab>

      <Tab title="Astro">
        ```typescript src/middleware.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
        import { createGeoMiddleware } from "@usenotra/geo/astro";

        export const onRequest = createGeoMiddleware({
          token: import.meta.env.NOTRA_GEO_TOKEN!,
          endpoint: "https://app.usenotra.com",
        });
        ```

        The middleware captures requests alongside your route handler and waits for the send before returning. If `src/middleware.ts` already exists, compose with `sequence` from `astro:middleware`. Astro must serve pages from a server adapter (`output: "server"` or hybrid) so requests exist at runtime.
      </Tab>

      <Tab title="SvelteKit">
        ```typescript src/hooks.server.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
        import { env } from "$env/dynamic/private";
        import { createGeoHandle } from "@usenotra/geo/sveltekit";

        export const handle = createGeoHandle({
          token: env.NOTRA_GEO_TOKEN!,
          endpoint: "https://app.usenotra.com",
        });
        ```

        The handle captures requests alongside `resolve` and waits for the send before returning. If `src/hooks.server.ts` already has a `handle`, compose with `sequence` from `@sveltejs/kit/hooks`.
      </Tab>

      <Tab title="Netlify">
        ```typescript netlify/edge-functions/geo.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
        import { createGeoHandler } from "@usenotra/geo/netlify";
        import type { Context } from "@netlify/edge-functions";

        const geo = createGeoHandler({
          token: Deno.env.get("NOTRA_GEO_TOKEN")!,
          endpoint: "https://app.usenotra.com",
        });

        export default (request: Request, context: Context) => {
          geo(request, context);
        };

        export const config = { path: "/*" };
        ```
      </Tab>
    </Tabs>
  </Step>

  <Step title="Deploy and wait for the first visit">
    The Traffic page switches from the install panel to the dashboard as soon as the first AI crawler or referral is stored.
  </Step>
</Steps>

### Tracker options

| Option     | Default                    | Meaning                                                    |
| ---------- | -------------------------- | ---------------------------------------------------------- |
| `token`    | required                   | Per organization ingest token                              |
| `endpoint` | `https://app.usenotra.com` | Ingest origin; the SDK appends `/api/geo/ingest`           |
| `exclude`  | `["/api"]`                 | Paths to skip. Pass `[]` to disable                        |
| `sample`   | `1`                        | Fraction of eligible requests to send, 0 to 1              |
| `onError`  | none                       | Called with anything that goes wrong. The SDK never throws |
| `fetch`    | global `fetch`             | Injectable fetch, for tests                                |

Only `GET` requests that look like pages are captured. Anything under `/_next/`, `/_nuxt/`, `/_vercel/`, `/_astro/`, `/_app/immutable/`, `/static/`, and any path ending in a common static extension is skipped. `llms.txt` and `llms-full.txt` are always captured. `exclude` entries can be a string prefix, a `RegExp`, or a `(request, url) => boolean` function.

The envelope contains the timestamp, method, URL, client IP, edge location headers when present, referer, user agent, accept and accept-language headers, and a request id. No request body, no cookies, and no other headers are read or sent. The POST uses `keepalive` and a 2 second timeout.

### Journey tagging

`@usenotra/geo/markdown` exports `mintJourneyId`, `getJourneyId`, and `tagMarkdownLinks` so the links you hand to an agent in Markdown carry an `ntr` id and every follow-up request lands in the same journey. On Next.js you can instead pass `tagLinks: true` to `createGeoProxy`; the proxy then tags Markdown responses (`.md`, `llms.txt`, `llms-full.txt`) served to known AI agents on the way out, and `html: true` extends that to the anchors in HTML pages. Human visitors and search crawlers always receive the untouched origin response. Tagging needs no token.

### Rotating the token

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.usenotra.com/v1/geo/ingest/rotate-token?projectId=$PROJECT_ID" \
  -H "Authorization: Bearer $NOTRA_API_KEY"
```

Rotation invalidates every tracking token previously issued for the organization and returns a fresh one. Deployments still sending the old token stop being accepted immediately, so update `NOTRA_GEO_TOKEN` everywhere before you rotate.

## The Traffic page

Once events arrive the page shows three sections, each honouring the range picker:

* A hero row with **Crawlers**, **Referrals**, and **Total** visit counts, each compared "vs. previous period", and a daily trend chart.
* **Sources**: three groups — **Crawlers** (training and search-index bots), **Cited** (assistant-browse fetches while an engine answered someone), and **Referrals**. Columns are **Source**, **Purpose**, **Visits**, **Markdown** (visits that asked for Markdown; crawlers and cited only), **Pages**, and **Last seen**. Sources group by operator, for example OpenAI, Anthropic, Google, Perplexity, Microsoft, Meta, Instagram, Amazon, Apple, ByteDance, and Common Crawl. Instagram is its own source, not folded into Meta.
* **Top pages by AI source**: **Page** (host and path), **Sources**, and **Visits**. When a project tracks more than one domain, a domain filter narrows the table.
* **Recent citations**: the live event log with **All visitors** (AI crawler, AI referral) and **All purposes** (Model training, Search index, Cited in answer) filters. The domain filter on top pages also narrows this log. Live updates refresh every few seconds and can be paused.

## Reading traffic from the API

All paths below are prefixed with `/v1/projects/{projectId}` and need `traffic.read`. Windowed endpoints accept `days` (1 to 365) or `from`/`to` (`YYYY-MM-DD`).

| Endpoint                                | Query                                                                              | Returns                                                                                                                                                                                                                                                                            |
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /geo/traffic/overview`             | `days`, `from`, `to`                                                               | `totals` (`crawler`, `aiReferral`), `sources[]` with `source`, `visitorType`, `agent`, `category`, `confidence`, `visits`, `previousVisits`, `markdownVisits`, `paths`, `lastSeenAt`, and daily `points[]`                                                                         |
| `GET /geo/traffic/log`                  | `limit` (1 to 200), `visitorTypes`, `categories`, `host`                           | `log[]` with `capturedAt`, `visitorType`, `source`, `agent`, `category`, `confidence`, `path`, `host`, `country`, `ua`, `journeyId`, `wantsMarkdown`, plus `total`. No window; bound it with `limit`. `host` keeps the newest matching events for that hostname and its subdomains |
| `GET /geo/traffic/journeys`             | window plus `limit` (1 to 100)                                                     | `journeys[]` with `journeyId`, `source`, `visitorType`, `pages`, `distinctPaths`, `firstSeenAt`, `lastSeenAt`, `samplePaths[]`                                                                                                                                                     |
| `GET /geo/traffic/journeys/{journeyId}` | window                                                                             | `events[]` with `capturedAt`, `path`, `host`, `method`, `referer`, `country`, `agent`, `category`                                                                                                                                                                                  |
| `GET /geo/traffic/pages`                | window plus `limit` (1 to 500), `visitorType` (`crawler` or `ai_referral`), `host` | `pages[]` with `host`, `path`, `source`, `visitorType`, `visits`, `previousVisits`, `lastSeenAt`. `host` ranks pages for that hostname and its subdomains before applying `limit`                                                                                                  |

`visitorTypes` and `categories` on the log endpoint are comma-separated lists. `categories` accepts `training-crawler`, `search-index`, and `assistant-browse`.

```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const response = await fetch(
  `https://api.usenotra.com/v1/projects/${projectId}/geo/traffic/log?limit=50&visitorTypes=crawler&categories=assistant-browse`,
  { headers: { Authorization: `Bearer ${process.env.NOTRA_API_KEY}` } }
);
const { log } = await response.json();
```

Every traffic response includes `configured`. When it is `false` the traffic backend is not configured for the deployment and the payload is empty rather than an error.

## Limits to keep in mind

<AccordionGroup>
  <Accordion title="User agent matching is spoofable">
    Crawler classification relies on user agent signatures. Anyone can send a crawler's user agent string. The **Confidence** value tells you how the signature was sourced; where an operator publishes IP ranges or a reverse DNS method, the signature table points at it, but Notra does not verify IPs at ingest.
  </Accordion>

  <Accordion title="A fetch is not proof of a citation">
    The **Cited in answer** purpose means an assistant fetched the page while answering someone. It does not prove the answer quoted or linked the page.
  </Accordion>

  <Accordion title="Some agents cannot be detected">
    Agents that cannot be honestly identified by user agent are deliberately absent from the signature table, including Pi, ChatGPT Atlas, `Google-Extended`, and `Applebot-Extended`. Traffic from them is either discarded as human or attributed to a generic browser.
  </Accordion>

  <Accordion title="Referrals depend on the referrer header">
    AI referrals are recognised by referrer host. Clicks from apps that strip the referrer, or from hosts not in the list, are not counted.
  </Accordion>

  <Accordion title="Fingerprinted journeys are estimates">
    Only journeys that followed a tagged `ntr` link are exact. Fingerprinted journeys group requests by heuristic and can merge or split real sessions.
  </Accordion>

  <Accordion title="Sampling reduces counts">
    If you set `sample` below 1, every count in the Traffic page and API reflects the sampled fraction; Notra does not scale the numbers back up.
  </Accordion>
</AccordionGroup>

<CardGroup cols={2}>
  <Card title="Get the install snippets" icon="code" href="/api-reference/geo/get-the-install-snippets">
    API reference for the ingest setup route.
  </Card>

  <Card title="Agent feedback" icon="comment" href="/api/agent-feedback">
    The same SDK ships an MCP feedback tool for the agents using your product.
  </Card>
</CardGroup>
