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

# Goals API

> List, rename, reorder, and archive goals, and read goal completions and property breakdowns.

## Base URL and authentication

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

Every endpoint takes `Authorization: Bearer <YOUR_API_KEY>`. Create a key under **Settings → API keys** ([details](/api/authentication)).

`site_id` is required on every request. With an API key it is the site's **snippet key**, the `ttid_…` value your install snippet carries as `data-site`. (The dashboard uses the same endpoints with its own session and passes the site's internal UUID instead; if you are reading this page, you want the snippet key.)

A site your key does not cover returns `404`, not `403`, so a key cannot be used to discover which sites exist.

## The goal registry

You do not have to create a goal before firing it. The first time an event with a new name is attributed, Traceten registers it automatically, up to 200 distinct names per site. These endpoints exist to pre-declare a goal, to give it a display name and emoji, to order the list, and to archive names you no longer use.

`name` is immutable. It is the event name on the wire, so changing it would orphan every event already recorded under it. Change `display_name` instead.

## `GET /v1/goals`

List every goal on the site, ordered by `sort_order` then `name`. Archived goals are omitted.

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

```json theme={null}
{
  "goals": [
    {
      "id": "0f3c7a1e-1c2b-4a55-9e0c-2b7f2f9a1d44",
      "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
      "name": "signup",
      "display_name": "Signed up",
      "emoji": "🎉",
      "sort_order": 0,
      "created_at": "2026-08-01T09:12:44.120Z",
      "archived_at": null
    }
  ]
}
```

| Field          | Type               | Notes                                                             |
| -------------- | ------------------ | ----------------------------------------------------------------- |
| `id`           | UUID               | The registry row. Used in the update, reorder, and archive calls. |
| `site_id`      | string             | The same snippet key you passed in.                               |
| `name`         | string             | The wire event name. Immutable.                                   |
| `display_name` | string \| `null`   | What the dashboard shows. Falls back to `name`.                   |
| `emoji`        | string \| `null`   | A single emoji, or `null`.                                        |
| `sort_order`   | integer            | Ascending. Ties break on `name`.                                  |
| `created_at`   | ISO-8601           |                                                                   |
| `archived_at`  | ISO-8601 \| `null` | Always `null` in this response.                                   |

## `POST /v1/goals`

Pre-declare a goal, so it appears in the dashboard before it has ever fired.

```bash theme={null}
curl -X POST https://api.traceten.com/v1/goals \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "name": "demo_booked",
    "display_name": "Demo booked",
    "emoji": "📅",
    "sort_order": 3
  }'
```

| Field          | Type    | Required | Notes                                                                               |
| -------------- | ------- | -------- | ----------------------------------------------------------------------------------- |
| `site_id`      | string  | yes      |                                                                                     |
| `name`         | string  | yes      | `^[a-z][a-z0-9_]*$`, 1 to 64 chars, not [reserved](/goals/overview#reserved-names). |
| `display_name` | string  | no       | 1 to 120 characters.                                                                |
| `emoji`        | string  | no       | A single emoji, including flags and joined sequences. Anything else is a `400`.     |
| `sort_order`   | integer | no       | 0 to 10000. Defaults to 0.                                                          |

`201` returns `{ "goal": { … } }` with the same shape as the list rows.

| Status | Cause                                               |
| ------ | --------------------------------------------------- |
| `400`  | Bad body. The response names the offending `field`. |
| `409`  | That goal name already exists on this site.         |

## `PATCH /v1/goals/:id`

Update the presentation fields. Omitted fields are left alone.

```bash theme={null}
curl -X PATCH https://api.traceten.com/v1/goals/0f3c7a1e-1c2b-4a55-9e0c-2b7f2f9a1d44 \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12", "display_name": "Booked a demo"}'
```

Accepts `display_name`, `emoji`, and `sort_order`. `200` returns `{ "goal": { … } }`; an id belonging to another site is `404`.

## `PATCH /v1/goals/reorder`

Rewrite the display order in one call. The goals are given `sort_order` 0, 1, 2… in the order you list them.

```bash theme={null}
curl -X PATCH https://api.traceten.com/v1/goals/reorder \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "goal_ids": ["0f3c7a1e-…", "b81d2c40-…", "5a9e0011-…"]
  }'
```

`goal_ids` takes 1 to 500 ids. Ids belonging to another site are ignored rather than applied. `200` returns the full reordered list as `{ "goals": [ … ] }`.

<Note>
  This endpoint has no CLI command and no MCP tool. It is deliberately absent from the OpenAPI spec,
  which both of those are generated from, because it rewrites the dashboard's display order, a
  presentation detail with no meaning to a script or an agent. It stays callable over REST with an
  API key exactly as shown above. The [data deletion and access endpoints](/privacy/data-deletion)
  are outside the spec too, for a different reason.
</Note>

## `DELETE /v1/goals/:id`

Archive a goal. Returns `204` with no body, or `404` if the id is not on this site.

This is an archive, not a delete. Events with that name keep arriving and keep being recorded, and a hard delete would simply be recreated the next time one did. Archiving takes it out of the dashboard's lists.

```bash theme={null}
curl -X DELETE "https://api.traceten.com/v1/goals/0f3c7a1e-…?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

## `POST /v1/goals/:id/unarchive`

Bring an archived goal back. It reappears in the dashboard's lists and in `GET /v1/goals` without `include_archived`.

Note where `site_id` goes: this endpoint takes it in the JSON body, while `DELETE /v1/goals/:id` takes it in the query string.

```bash theme={null}
curl -X POST https://api.traceten.com/v1/goals/0f3c7a1e-…/unarchive \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12"}'
```

`200` returns `{ "goal": { … } }`, the same shape `POST /v1/goals` returns.

Two failures are specific to this endpoint:

| Status | Body                                                                  | Cause                                                                      |
| ------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `404`  | `{ "error": "Not found", "statusCode": 404 }`                         | No such goal on this site. An id from another site answers the same way.   |
| `409`  | `{ "error": "conflict", "code": "goal_limit_reached", "message": … }` | The site already has 200 active goals. Archive one, then restore this one. |

The `409` has no `field`: it is a per-site limit, not a bad value you can correct in the request.

## `GET /v1/goals/timeseries`

Completions per goal per day, for every goal that fired in the range.

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

`from` and `to` are `YYYY-MM-DD`. `to` defaults to today, `from` to six days before `to`.

```json theme={null}
{
  "from": "2026-08-01",
  "to": "2026-08-07",
  "series": [
    {
      "goal_name": "signup",
      "points": [
        { "date": "2026-08-01", "completions": 14 },
        { "date": "2026-08-02", "completions": 9 }
      ],
      "total": 23
    }
  ]
}
```

Days with no completions are absent from `points` rather than present as zeros.

## `GET /v1/goals/:name/properties`

The property breakdown for one goal: which keys the site sends with it, and the most common values of each. At most 10 values are returned per key, most common first.

**How the budget works, because it can surprise you.** The scan reads at most 200 key/value pairs in one pass, ordered by key name and then by count. That budget is shared across all keys, so a single key with 200 or more distinct values (an id, a timestamp, a full URL) consumes the whole thing and no other key appears in the response. When the budget runs out, the keys that survive are the ones earliest in **alphabetical** order, not the ones sent most often. Keep property values to a small set of repeated labels and this never comes up.

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

```json theme={null}
{
  "from": "2026-08-01",
  "to": "2026-08-07",
  "goal_name": "signup",
  "properties": [
    {
      "key": "plan_type",
      "values": [
        { "value": "pro", "count": 41 },
        { "value": "free", "count": 12 }
      ]
    }
  ]
}
```

`count` counts distinct events, so an event re-sent by a retry is counted once.

Conversion records, including these properties, are retained for 365 days, and each goal also writes a visitor-grain step row retained for 730 days. See [data collected](/privacy/data-collected#data-retention).

This endpoint returns property keys and values **as they were stored**. Neither is scanned for personal names or addresses, and keys are not scanned at all. Read [what happens to property keys and values](/goals/overview#what-happens-to-property-keys-and-values-exactly) before you put anything into one.

## `GET /v1/goals/:name/visitors`

The people behind one goal's completion count. `GET /v1/goals/timeseries` tells you how many times a goal was completed; this tells you who completed it.

```bash theme={null}
curl "https://api.traceten.com/v1/goals/signup/visitors?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-01&to=2026-08-07" \
  -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"
    }
  ],
  "goal_name": "signup",
  "from": "2026-08-01",
  "to": "2026-08-07",
  "page": 1,
  "page_size": 50,
  "has_more": false,
  "total_count": 1,
  "identity_merge_complete": true
}
```

| Parameter   | Type    | Required | Notes                                                                  |
| ----------- | ------- | -------- | ---------------------------------------------------------------------- |
| `name`      | string  | yes      | Path. The goal **name** (the event name), not the goal id.             |
| `site_id`   | string  | yes      | The `ttid_` snippet key.                                               |
| `from`      | date    | no       | Inclusive `YYYY-MM-DD`. Defaults to the last 7 days. At most 365 days. |
| `to`        | date    | no       | Inclusive `YYYY-MM-DD`.                                                |
| `page`      | integer | no       | 1-based, default 1.                                                    |
| `page_size` | integer | no       | 1 to 100, default 50.                                                  |

Rows are the same shape [`GET /v1/visitors`](/api/visitors) returns, including the opaque `handle` you can pass to `GET /v1/visitors/{handle}` to open one person.

**`total_count` counts people; the timeseries counts completions.** Someone who completed the goal three times is one row here and three completions there, so this number is normally smaller than the completion count for the same window. Both are right. Compare it against another count only if you know which of the two that count is.

Identifiers that belong to one signed-in person are merged before the page is cut, so someone who signed in between two completions is one row and one count. Only people with a visit in the window are listed and counted, because a row is built from their visits: a completion whose visitor had no visit in the window (for example a payment recorded server-side) is left out of both the rows and `total_count`. Every page but the last returns `page_size` rows, and `has_more` is derived from `total_count`.

`identity_merge_complete` is `false` when some people on this page were summarised from part of their history, because the links between someone's signed-in and anonymous visits are capped per page. When it is false, read the per-row visit, pageview and revenue figures as lower bounds. It is `true` on most pages, and always true on an empty one.

A goal nobody completed in the window returns an empty list, not a `404`. Goals register themselves the first time their event fires, so "never seen" and "no completions in this window" are the same observation.

`from` and `to` may span at most 365 days, because a goal completion is a conversion record and those are retained for that long. A wider window would report the expired part as nobody having completed it.

<Warning>
  The totals on each row cover that visitor's whole history in the window, not only what they did
  around this goal. `session_count`, `pageviews` and `revenue` are that person's figures for the
  whole range. Never read `revenue` here as revenue attributable to the goal.
</Warning>

## Errors

| Status | Meaning                                                                                            |
| ------ | -------------------------------------------------------------------------------------------------- |
| `400`  | Malformed body on a write. Response: `{ "error": "validation_failed", "message": …, "field": … }`. |
| `401`  | Missing, malformed, revoked, or out-of-scope key.                                                  |
| `404`  | The goal, or the site, is not reachable with this key.                                             |
| `409`  | Conflict: that goal name already exists.                                                           |
| `422`  | Malformed query string or path parameter, same body shape as `400`.                                |
| `429`  | Rate limited. Back off and retry.                                                                  |
| `500`  | `{ "error": "Internal server error", "statusCode": 500 }`.                                         |

Rate limits are per credential: 120 requests per minute for reads, 60 for writes. A second ceiling of 600 requests per minute applies per IP address across all of these endpoints.

## Next

* [Funnels API](/api/funnels)
* [Goals](/goals/overview): how goals get fired in the first place.
