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

# API keys

> Create and manage API keys for authenticated requests to Traceten.

## What API keys are for

An API key authenticates requests you make to Traceten directly, outside the dashboard UI. Six things use one:

* **The server SDKs.** Node, Python, and Go all require a key and send it on every ingestion request. See the [server SDK overview](/sdks/overview#authentication) for what the key buys you there.
* **The [data deletion and access endpoints](/privacy/data-deletion)** used to fulfill GDPR/CCPA requests.
* **The read API.** [Stats](/api/stats), [breakdowns](/api/breakdowns), [sessions](/api/sessions), [revenue](/api/revenue), [detection](/api/detection), [AI crawlers](/api/ai-crawls), [sites](/api/sites), [events](/api/events), [sources](/api/sources#get-v1sourcesbreakdown), [goals](/api/goals) and [funnels](/api/funnels).
* **The management API.** Creating sites, minting keys, registering [webhooks](/api/webhooks), and reading your [account](/api/account) and [team](/api/account#get-v1team).
* **The [CLI](/cli/overview).** The same surface from a terminal or a CI job. It reads the key from stdin or `TRACETEN_TOKEN` and never takes one on the command line.
* **The [MCP server](/mcp/overview).** The same surface, exposed to AI apps as tools. It uses the key you give it and nothing else, so a key's permissions are exactly what a connected assistant can do. Claude, Claude Code, Codex, ChatGPT and VS Code can also connect with no key at all. See [below](#connecting-an-ai-app-instead-of-using-a-key).

Keys are managed at the account level from **Settings → API keys**. Every key has two independent limits, and a request must satisfy both:

* **Which sites it reaches.** One site, or every site on the account.
* **What it is allowed to do.** See [permissions](#permissions).

Team management has no API-key **write** path. Inviting people, changing roles and setting which sites a member can reach are all dashboard-only. You can *read* the member list with `GET /v1/team`; you cannot change it. See [Team and roles](/dashboard/team).

## Creating a key

1. Go to **Settings → API keys** in the dashboard.
2. Click **Create key**, give it a name, and optionally scope it to one site.
3. Tick the [permissions](#permissions) this key needs. **Read analytics** is selected by default; everything else is off until you turn it on.
4. Copy the new key. It is masked on screen: **Copy** copies the full key without showing it, and the eye button reveals it. It is available exactly once. Traceten stores only a hash, never the plaintext, so it cannot be recovered later.

You cannot change a key's permissions after it is created. To widen or narrow one, create a replacement, deploy it, then revoke the old key.

## Permissions

A key carries only the permissions you tick. A request to an endpoint the key is not permitted to use is refused, even though the key itself is valid.

| Permission          | What it allows                                                                       |
| ------------------- | ------------------------------------------------------------------------------------ |
| `stats:read`        | Read analytics: stats, breakdowns, sessions and revenue. Cannot change anything.     |
| `ingest:write`      | Send events and conversions from your server, and contribute detection fingerprints. |
| `config:write`      | Create and edit configuration: goals, funnels, sites and webhooks.                   |
| `credentials:write` | Create anything that hands back a secret: sites, API keys, bot tokens, webhooks.     |
| `privacy:read`      | Look up one visitor's own data to answer a GDPR/CCPA access request. Cannot delete.  |
| `privacy:delete`    | Permanently erase a visitor's events for a GDPR/CCPA request. **Irreversible.**      |

Permissions are not hierarchical. `privacy:delete` does not include `stats:read`, `config:write` does not include `ingest:write` or `credentials:write`, and `privacy:delete` does not include `privacy:read`. Grant each one you need.

<Warning>
  `config:write` never mints a credential. Five endpoints hand back a new secret, and each needs
  `credentials:write`, plus everything that secret will itself be able to do. See [minting
  credentials with a key](#minting-credentials-with-a-key).
</Warning>

Reading a visitor's data and erasing it are separate permissions because answering an access request is routine and erasing is irreversible. A support tool that only needs to answer "what do you hold about me" should carry `privacy:read` alone.

### Which endpoints need which permission

| Endpoints                                                                           | Required permission                                 |
| ----------------------------------------------------------------------------------- | --------------------------------------------------- |
| `/v1/ingest/*`, `/v1/server/*`, `/v1/consortium/*`                                  | `ingest:write`                                      |
| `GET /v1/privacy/sites/{siteId}/visitors/{email}`                                   | `privacy:read`                                      |
| `DELETE /v1/privacy/data`, `DELETE /v1/privacy/sites/{siteId}/visitors/{email}`     | `privacy:delete`                                    |
| [Sites](/api/sites), [events](/api/events) and [sources](/api/sources)              | `stats:read`                                        |
| [Stats](/api/stats) and [breakdowns](/api/breakdowns)                               | `stats:read`                                        |
| [Sessions](/api/sessions), [visitors](/api/visitors) and [revenue](/api/revenue)    | `stats:read`                                        |
| [Detection](/api/detection) and [AI crawlers](/api/ai-crawls)                       | `stats:read`                                        |
| Reading goals, funnels and journeys                                                 | `stats:read`                                        |
| Creating or editing goals and funnels                                               | `config:write`                                      |
| Reading your account, usage, team and integrations                                  | `stats:read`                                        |
| Listing [API keys](/api/api-keys) and [bot tokens](/api/bot-tokens)                 | `config:write`                                      |
| Editing or deleting a site; editing, deleting or testing a [webhook](/api/webhooks) | `config:write`                                      |
| Registering a webhook, or rotating its signing secret                               | `credentials:write`                                 |
| `POST /v1/sites`                                                                    | `credentials:write` + `stats:read` + `ingest:write` |
| `POST /v1/account/api-keys`                                                         | `credentials:write` + every scope requested         |
| `POST /v1/account/bot-tokens`                                                       | `credentials:write` + `ingest:write`                |

Two read endpoints take no key at all, whatever its permissions: [`GET /v1/conversions`](/api/conversions) and [`GET /v1/sources`](/api/sources#get-v1sources). They authenticate with a dashboard session only, and a key gets `401`. Each of those pages says so where the endpoint is documented, and the [CLI](/cli/commands) has no command for them.

Listing credentials is the one read that needs a write permission, and it is deliberate: a list of every key on the account, with its prefix, permissions, expiry and site scope, is a target list rather than a metric. A leaked read-only key should not produce one.

This applies to **API keys only**. In the dashboard, any member can still see the account's keys under **Settings → API keys**, and bot tokens stay admin-only there, exactly as before. The table above describes what an API key needs.

A key scoped to a single site cannot use either listing at all, because the rows name the account's other sites.

### Defaults, and what happened to existing keys

* **New keys default to `stats:read` only.** A key that can only read cannot damage anything, so that is where we start you.
* **The key created automatically with a new site** carries `stats:read` and `ingest:write`, because the install instructions point the server SDKs at it.
* **Keys created before permissions existed were given all of them**, including `privacy:read` and `privacy:delete`. Only keys created from now on start narrow. If an older key is deployed somewhere that never needed to touch visitor data, replace it with a narrower one.
* **`credentials:write` was added later, and every key that already held `config:write` was given it automatically.** Nothing you had stopped working, and you do not need to re-mint. A key created from now on gets `credentials:write` only if you tick it.

## You must re-mint every existing key

<Warning>
  **Every API key issued before this release has been revoked and must be replaced.** This is not
  gradual and there is no grace period. A revoked key returns `401` on every endpoint, including
  ingestion, so anything still presenting one has stopped working.
</Warning>

We now record which team member created each key, so that when a key is used to create a site or issue another credential we can check whether that person is still an administrator. Existing keys were created before we stored that, and the value cannot be reconstructed after the fact.

We had two options: treat "no recorded creator" as "allow", which would leave the exact gap this change closes, or revoke and reissue. We revoked.

**What to do:**

1. Sign in and go to **Settings → API keys**. Every previous key is listed under revoked.
2. Create a replacement for each one, ticking the same [permissions](#permissions) and the same site scope. The new key is shown once.
3. Deploy the replacements everywhere the old key was used: server SDK configuration, CI secrets, scheduled jobs, and anything calling the API directly.

Bot tokens are unaffected. Only `tk_live_` API keys were revoked.

If you use the API to mint keys, note that the calling key must itself be valid, so the first replacement has to be created in the dashboard.

## Minting credentials with a key

Five endpoints return a new secret, and all five need `credentials:write`: `POST /v1/sites` (which hands back an API key for the new site), `POST /v1/account/api-keys`, `POST /v1/account/bot-tokens`, `POST /v1/sites/{siteId}/webhooks` and `POST /v1/sites/{siteId}/webhooks/{webhookId}/rotate-secret`.

`credentials:write` is separate from `config:write` so that a key which manages your goals and funnels cannot also mint keys. A permission you can withdraw should not be able to issue one you then have to hunt down.

**A key can never mint a credential more capable than itself.** Every permission the new secret will carry has to be one the calling key already holds. If it is not, the request is refused with `403` and the response names the missing permission in `required_scope`:

```json theme={null}
{
  "status": "error",
  "error": "insufficient_scope",
  "message": "This API key does not hold privacy:delete, so it cannot mint a key that does.",
  "required_scope": "privacy:delete",
  "field": "scopes",
  "statusCode": 403
}
```

Listing credentials needs `config:write` too, even though it is a read. A list of every key on the account (prefix, name, permissions, expiry, site pin) is a target list, not a metric, and a leaked read-only key should not produce one.

That is why `POST /v1/sites` needs three permissions rather than one: the site it creates comes with an API key carrying `stats:read` and `ingest:write`, so the caller must hold those too. And `POST /v1/account/bot-tokens` needs `ingest:write` because a bot token writes crawl reports.

We refuse rather than quietly issuing a narrower secret. A key that silently lacks a permission does not fail at the moment you create it, it fails days later in an unrelated part of your system, and the `403` here tells you exactly what to fix.

Omitting `scopes` on `POST /v1/account/api-keys` mints a `stats:read` key. That default is checked against the caller as well, so not asking is not a way around the rule.

## Connecting an AI app instead of using a key

The [MCP server](/mcp/overview) can also be reached by connecting your Traceten account from an AI app, with no key at all. That connection is a different kind of credential and follows different rules:

* **Only apps we have approved can connect.** Claude, Claude Code, Codex, ChatGPT and VS Code are approved today; any other app is refused before your sign-in page loads, until we add it. An app you have never heard of cannot put a Traceten sign-in in front of your team. Each entry also fixes which permissions that app may request.
* **It acts as the person who approved it**, in the organization they chose on the sign-in screen, with their own [site access](/api/authentication#who-is-behind-a-key). It cannot reach another organization, and it never widens what that person can already do.
* **It can carry `stats:read` and `config:write`, and nothing else.** `ingest:write`, `privacy:read`, `privacy:delete` and `credentials:write` are not offered. So a connected app cannot send events, cannot touch a visitor's personal data, and **cannot mint an API key, a bot token or a webhook secret**. The permission for that is deliberately withheld, because a connection you can stop by removing one person should not be able to leave behind a credential that outlives them. Both credential listings are refused to it as well; revoking a key is still reachable, but only for an account-wide admin and only for a key id it was already given.
* **It cannot be revoked, it does not expire on its own, and membership is the only thing that ends it.** Access tokens are JWTs and cannot be withdrawn once issued, and every connection also holds a refresh token it uses to replace its own token, so waiting achieves nothing. Every request, read or write, re-checks the approving person's live organization membership, so removing them stops the connection within about 30 seconds. The 24-hour access-token lifetime bounds a token that has leaked on its own, not a live connection.
* **Writes additionally re-check the token with our identity provider** before anything is changed, and are refused if we cannot get an answer. Reads skip that round-trip, which is what keeps them fast.

This is configuration in the AI app, not something you set up here. See [Install the MCP server](/mcp/install).

## Who is behind a key

Creating a site, minting a credential and revoking one are **admin actions**. When you make one of those calls with an API key, we look up the person who created that key and check their **current** role in your Clerk organisation.

The consequences are worth knowing before you build against it:

* If that person is demoted from admin, their key stops working on those endpoints on the next call.
* If they are removed from the organisation, or their user is deleted, every key they created is revoked, so it stops working on every endpoint. You do not have to remember to revoke it. See [when the person who created a key leaves](/api/api-keys#when-the-person-who-created-a-key-leaves).
* If they are an admin restricted to specific sites, their key cannot create sites or mint credentials at all. Restricted admins are not account-wide admins.
* A key scoped to a single site cannot create sites or mint account-level credentials, whoever made it.
* Reads are unaffected. Only the admin endpoints do this check.

If we cannot reach Clerk to answer that question, the request fails with `503` and `error: "clerk_unavailable"`. **Retry it.** Nothing was changed. We refuse rather than assume the role still holds, because letting a demoted admin's key through is worse than a retry.

## How to authenticate

Include your API key as a Bearer token in the `Authorization` header:

```bash theme={null}
curl https://api.traceten.com/v1/privacy/data \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

## Security

* **Do not include API keys in client-side code.** Put them in a server-side environment variable.

* **Keys are one-way hashed before storage.** If the database were compromised, your key would not be exposed as plaintext.

* **Rotate a key by creating the new one first, deploying it, then revoking the old one.** Revocation is immediate for the account API. On the ingestion path it takes effect within about a minute, because each edge location caches a verification result for 60 seconds.

  One exception, and we would rather state it than hide it: if our database is unreachable at that moment, an edge location that was **already** using the key may keep honouring it for up to about fifteen minutes more. That grace window exists so a database blip cannot silently drop your events, and it only ever applies to a key that location had already verified. A key it has never seen is always rejected.

* **Scope keys to a single site when you can.** An account-wide key can act on every site in the account; a site-scoped key is limited to one. A site-scoped key presented for a different site is rejected the same way an invalid key is.

* **Grant the fewest permissions that work.** A key used only to read your numbers should carry only `stats:read`. If that key leaks, it cannot send fake events or erase anything.

## Error responses

Authentication errors return `401`. The account API responds:

```json theme={null}
{ "error": "Unauthorized" }
```

The ingestion endpoints respond:

```json theme={null}
{ "error": "unauthorized" }
```

Both are deliberately indistinct. A missing key, a malformed key, a revoked key, an expired key, and a key scoped to a different site all produce the same `401`, so the response cannot be used to probe which keys exist.

### Missing permission

A valid key sent to an endpoint it is not permitted to use returns `403`, and names the permission it needs:

```json theme={null}
{
  "status": "error",
  "error": "insufficient_scope",
  "message": "This API key does not carry the \"ingest:write\" scope.",
  "required_scope": "ingest:write",
  "statusCode": 403
}
```

This is more specific than the `401`s above on purpose. You have already proved you hold the key, so telling you what it is missing gives away nothing you could not read off your own settings page, and leaving you to guess would be unhelpful.

**What happens depends on which endpoint you called. One group does not return an error at all.**

| Endpoints                                           | What a missing permission does                                 |
| --------------------------------------------------- | -------------------------------------------------------------- |
| `/v1/ingest/*`, `/v1/consortium/*`, `/v1/privacy/*` | `403 insufficient_scope`, as above                             |
| `/v1/server/events`, `/v1/server/conversions`       | `401`, with no detail                                          |
| `/v1/events`, `/v1/conversions`                     | **No error. The request is accepted and your key is ignored.** |

The last row is the one to read twice.

`/v1/events` and `/v1/conversions` are shared with the browser snippet, which can never hold a secret, so a key there is optional and the endpoint never rejects one. If your key is missing the `ingest:write` permission, the request still succeeds. Nothing in the response says otherwise.

What you lose is rate-limit isolation. A verified key is metered on its own bucket; an ignored one falls back to the bucket shared with all traffic sent under your public site id, which anyone who can read your page source can flood. Your events still arrive, and your headroom is no longer yours.

If you want a missing permission to be loud, send to `/v1/server/events` and `/v1/server/conversions` instead. Same payloads, same behaviour otherwise, but a key is mandatory and a bad one is a `401`. The server SDKs already use these.

### Not found vs forbidden

When you ask about a site your key cannot reach, you get `404`, not `403`. That is the same answer as a site that does not exist. This is deliberate: distinguishing the two would let one leaked key enumerate every site id we host. Dashboard sessions get `403` in the same situation, because a signed-in user is already known to us.

If Traceten cannot check your key at all (a dependency is unavailable), `/v1/server/events` and `/v1/server/conversions` return `503` with `{ "error": "unavailable" }` rather than `401`. That is a retryable condition, not an authentication failure. `/v1/events` and `/v1/conversions` follow the last row of the table above instead: the request is accepted as anonymous, exactly as it is when a key is ignored.

Requests are rate-limited. If you exceed the limit you will receive a `429`, so back off and retry.

On the ingestion path the limit is measured against a bucket tied to your key, sized the same as your plan's per-site allowance and separate from it. Traffic sent under your public site id cannot consume the quota your authenticated calls rely on.
