# Tags API

> List and manage tags, tag and untag creators, campaigns, accounts and videos one at a time or in bulk, and filter lists by tag.

Tags are free-form labels on creators, campaigns, accounts and videos. The organization keeps one list of tags (the vocabulary) for each entity type, so a creator tag can only go on creators. These endpoints are the same ones the MCP tag tools use (`list_tags`, `add_tags`, `apply_tags` and the rest). See the [Tags guide](https://www.trackagoat.com/docs/guides/tags.md) for how people use them in the app.

Tags are not an account's `tracking_hashtags` (hashtag tracking mode), the hashtags in a video's `caption`, or the `tags` on [Knowledge](https://www.trackagoat.com/docs/api-reference/knowledge.md) entries.

**Entity types:** `creator`, `campaign`, `account`, `video`. **Colors:** `gray`, `blue`, `green`, `orange`, `purple`, `pink`, `red`, `yellow`. A `color` of `null` shows as gray.

## Permissions

| Action | Needs |
|---|---|
| Read tags, and filter lists by tag | The `read` scope |
| Create tags, and tag or untag anything | The `write` scope |
| Rename, recolor, merge and delete tags | The `write` scope, and the key must act as an org admin (`403 insufficient_role` otherwise) |

Every write accepts an `Idempotency-Key` header and is recorded in the API audit log.

## Names

A tag name is trimmed, and every run of spaces becomes one space. It must be 1 to 40 characters, with no commas, and it can't look like an id (a UUID). Names match case-insensitively: `VIP`, `vip` and `Vip` are one tag, and the tag keeps the casing it was created with.

Wherever an endpoint takes `tags`, each entry can be a tag's name or its id. An entry that is a UUID is treated as an id; anything else is a name. Names that have no tag yet are created on the fly when you add or set tags.

## Limits

| Limit | Value |
|---|---|
| Tags on one creator, campaign, account or video | 20 |
| Tags per entity type in one organization | 500 |
| Tags named in one request | 20 |
| Items in one `POST /api/v2/tags/apply` | 500 |
| Tags in one list filter | 20 |
| Source tags in one merge | 20 |

## Tag fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | uuid | Tag ID |
| `entity_type` | `creator` \| `campaign` \| `account` \| `video` | What the tag is for |
| `name` | string | The name, in the casing it was created with |
| `color` | string \| null | One of the colors above; `null` is gray |
| `usage_count` | number | How many items carry the tag. Removed creators and accounts and excluded videos keep their tags but aren't counted |
| `created_by` | uuid \| null | The user who created it |
| `created_via` | `ui` \| `api` \| `mcp` | Where it was created: the app, the REST API or MCP |
| `created_at`, `updated_at` | ISO 8601 | |

Creators, campaigns, accounts and videos carry a shorter form, `{ id, name, color }`, in their `tags` array.

---

## GET /api/v2/tags

List the organization's tags with their usage counts. Needs the `read` scope.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/tags?entity_type=creator"
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `entity_type` | `creator` \| `campaign` \| `account` \| `video` | Only tags for this entity type. Leave it out for all four (grouped by type, each sorted by name). |
| `q` | string | Case-insensitive part of the name (up to 100 characters). |

```json
{
  "data": [
    {
      "id": "uuid",
      "entity_type": "creator",
      "name": "Paid",
      "color": "green",
      "usage_count": 8,
      "created_by": "uuid",
      "created_via": "ui",
      "created_at": "2026-10-14T15:02:11Z",
      "updated_at": "2026-10-14T15:02:11Z"
    },
    {
      "id": "uuid",
      "entity_type": "creator",
      "name": "Q4 launch",
      "color": null,
      "usage_count": 12,
      "created_by": "uuid",
      "created_via": "api",
      "created_at": "2026-10-14T15:04:40Z",
      "updated_at": "2026-10-14T15:04:40Z"
    }
  ],
  "error": null,
  "meta": { "total": 2 }
}
```

The list isn't paginated.

---

## POST /api/v2/tags

Add a tag to the vocabulary without putting it on anything. Needs the `write` scope. You rarely need this: adding tags by name creates them.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"entity_type": "video", "name": "Sponsored", "color": "orange"}' \
  https://www.trackagoat.com/api/v2/tags
```

| Field | Type | Description |
|-------|------|-------------|
| `entity_type` | string | Required. |
| `name` | string | Required. See [Names](#names). |
| `color` | string \| null | Optional. Defaults to gray. |

Returns the [tag](#tag-fields). A new tag is **201** with `meta.created: true`. If a tag of that type already has the name (in any casing), it is returned with **200** and `meta.created: false`, and its color is left unchanged (recoloring needs an org admin, with `PATCH`).

| HTTP | Cause |
|------|-------|
| 400 | `invalid_body`: a bad name (empty, over 40 characters, a comma, or an id) or an unknown `entity_type` or `color`. |
| 403 | Key lacks the `write` scope. |
| 422 | `tag_vocabulary_full`: the organization already has 500 tags of this type. |

---

## PATCH /api/v2/tags/\{id\}

Rename a tag or change its color. The new name shows everywhere the tag is used. Needs the `write` scope and an org admin.

```bash
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"name": "Paid creators", "color": "blue"}' \
  https://www.trackagoat.com/api/v2/tags/<uuid>
```

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | New name. See [Names](#names). |
| `color` | string \| null | New color; `null` resets it to gray. |

Send at least one. Returns the updated [tag](#tag-fields).

| HTTP | Cause |
|------|-------|
| 400 | `invalid_body`. |
| 403 | `insufficient_role`: the key acts as a member who isn't an org admin. |
| 404 | `not_found`: no tag with that id in your organization. |
| 409 | `tag_name_taken`: another tag of the same type already has that name. `meta.existing_tag_id` is its id. [Merge](#post-apiv2tagsmerge) the two instead. |

---

## DELETE /api/v2/tags/\{id\}

Delete a tag and remove it from every item that carries it. This can't be undone. Needs the `write` scope and an org admin.

```bash
curl -X DELETE \
  -H "Authorization: Bearer tga_<key>" \
  https://www.trackagoat.com/api/v2/tags/<uuid>
```

```json
{ "data": { "id": "uuid", "deleted": true, "removed_from": 14 }, "error": null, "meta": null }
```

`removed_from` is how many items had the tag. **403** `insufficient_role` for a key that doesn't act as an org admin, **404** `not_found` when the tag isn't in your organization.

---

## POST /api/v2/tags/merge

Fold duplicate tags into one. Everything that carries a source tag gets the target tag (an item that already has the target keeps one), then the source tags are deleted. Needs the `write` scope and an org admin.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"target_tag_id": "<uuid of VIP>", "source_tag_ids": ["<uuid of vips>", "<uuid of VIP creators>"]}' \
  https://www.trackagoat.com/api/v2/tags/merge
```

| Field | Type | Description |
|-------|------|-------------|
| `target_tag_id` | uuid | The tag to keep. |
| `source_tag_ids` | uuid[] | 1 to 20 tags to fold into the target and delete. |

```json
{
  "data": {
    "tag": { "id": "uuid", "entity_type": "creator", "name": "VIP", "color": "purple", "usage_count": 23, "created_by": "uuid", "created_via": "ui", "created_at": "2026-10-14T15:02:11Z", "updated_at": "2026-10-14T16:30:00Z" },
    "moved": 9,
    "deleted_tags": 2
  },
  "error": null,
  "meta": null
}
```

`tag` is the target as [listed](#get-apiv2tags), `moved` is how many items gained the target tag, and `deleted_tags` is how many source tags were deleted.

| HTTP | Cause |
|------|-------|
| 400 | `invalid_body` (including a merge whose only source is the target). `tag_type_mismatch`: the tags are for different entity types. |
| 403 | `insufficient_role`. |
| 404 | `not_found`: the target or a source tag isn't in your organization. |

---

## POST /api/v2/tags/apply

Add tags to, or remove them from, up to 500 items of one entity type. Needs the `write` scope.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"entity_type": "creator", "entity_ids": ["<uuid1>", "<uuid2>", "<uuid3>"], "tags": ["Q4 launch", "Paid"]}' \
  https://www.trackagoat.com/api/v2/tags/apply
```

| Field | Type | Description |
|-------|------|-------------|
| `entity_type` | string | Required. |
| `entity_ids` | uuid[] | 1 to 500 items of that type. |
| `tags` | string[] | 1 to 20 tag names or ids. |
| `mode` | `add` \| `remove` | `add` (default) keeps each item's other tags and creates names that have no tag yet. `remove` takes these tags off. |

```json
{
  "data": {
    "entity_type": "creator",
    "entity_ids": ["uuid", "uuid"],
    "not_found_ids": ["uuid"],
    "tags": [
      { "id": "uuid", "name": "Q4 launch", "color": null },
      { "id": "uuid", "name": "Paid", "color": "green" }
    ],
    "added": 3,
    "removed": 0,
    "created_tags": ["Q4 launch"],
    "not_found_tags": []
  },
  "error": null,
  "meta": null
}
```

| Field | Description |
|-------|-------------|
| `entity_ids` | The items found in your organization. Removed creators and accounts aren't. |
| `not_found_ids` | Requested ids that aren't items of this type in your organization. They are skipped. |
| `tags` | The tags the request named. |
| `added`, `removed` | How many item and tag pairs were added or removed. An item that already had a tag isn't counted again. |
| `created_tags` | Names this call created. |
| `not_found_tags` | In `remove` mode, names or ids that matched no tag. |

| HTTP | Cause |
|------|-------|
| 400 | `invalid_body`. `invalid_tag_name`: a name breaks the [rules](#names) (`meta.invalid` lists each `input` and its `problem`). `unknown_tag_ids` (add mode): an id isn't a tag of this type in your organization (`meta.unknown_tag_ids`). |
| 404 | `not_found`: none of the `entity_ids` are in your organization (`meta.not_found_ids`). |
| 422 | `too_many_tags`: an item would carry more than 20 tags. Nothing is written. `tag_vocabulary_full`: creating the new names would take the organization over 500 tags of this type. |

---

## One item's tags

Each creator, campaign, account and video has a `tags` endpoint:

- `/api/v2/creators/{id}/tags`
- `/api/v2/campaigns/{id}/tags`
- `/api/v2/accounts/{id}/tags`
- `/api/v2/videos/{id}/tags`

All four return **404** `not_found` when the item isn't in your organization. Removed creators and accounts are 404, as they are for `GET /api/v2/creators/{id}` and `GET /api/v2/accounts/{id}`.

### GET

The item's tags, sorted by name. Needs the `read` scope.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  https://www.trackagoat.com/api/v2/creators/<uuid>/tags
```

```json
{
  "data": [
    { "id": "uuid", "name": "Paid", "color": "green" },
    { "id": "uuid", "name": "Q4 launch", "color": null }
  ],
  "error": null,
  "meta": null
}
```

### POST: add tags

Adds tags and keeps the ones the item already has. Names with no tag yet are created. Needs the `write` scope.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"tags": ["Sponsored"]}' \
  https://www.trackagoat.com/api/v2/videos/<uuid>/tags
```

Body: `tags`, 1 to 20 names or ids.

### PUT: set tags

Replaces the item's tags with exactly this list. Tags not in the list are taken off, and an empty list clears them. Names with no tag yet are created. Needs the `write` scope.

```bash
curl -X PUT \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["Q4 launch", "Paid"]}' \
  https://www.trackagoat.com/api/v2/creators/<uuid>/tags
```

Body: `tags`, 0 to 20 names or ids.

### DELETE: remove tags

Takes tags off the item. The tags stay in the vocabulary. The tags go in a JSON body. Needs the `write` scope.

```bash
curl -X DELETE \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["Paid"]}' \
  https://www.trackagoat.com/api/v2/creators/<uuid>/tags
```

Body: `tags`, 1 to 20 names or ids.

### Response

`POST`, `PUT` and `DELETE` return the item's tags after the write, sorted by name, with counts in `meta`:

```json
{
  "data": [
    { "id": "uuid", "name": "Q4 launch", "color": null }
  ],
  "error": null,
  "meta": { "added": 0, "removed": 1, "created_tags": [] }
}
```

| `meta` field | Description |
|-------|-------------|
| `added`, `removed` | How many tags were put on or taken off. |
| `created_tags` | Names this call created. |
| `not_found` | `DELETE` only, when present: names or ids that matched no tag. |

| HTTP | Cause |
|------|-------|
| 400 | `invalid_body`. `invalid_tag_name` (`meta.invalid`). `unknown_tag_ids` (`POST` and `PUT`): an id isn't a tag of this type in your organization (`meta.unknown_tag_ids`). |
| 404 | `not_found`. |
| 422 | `too_many_tags`: the item would carry more than 20 tags. `tag_vocabulary_full`. |

---

## Filtering lists by tag

These list endpoints take a tag filter and return `tags` (`[{ id, name, color }]`) on every item:

- [`GET /api/v2/creators`](https://www.trackagoat.com/docs/api-reference/creators.md#get-apiv2creators)
- [`GET /api/v2/campaigns`](https://www.trackagoat.com/docs/api-reference/campaigns.md#get-apiv2campaigns)
- [`GET /api/v2/accounts`](https://www.trackagoat.com/docs/api-reference/accounts.md#get-apiv2accounts)
- [`GET /api/v2/videos`](https://www.trackagoat.com/docs/api-reference/videos.md#get-apiv2videos)

`GET /api/v2/creators/{id}`, `GET /api/v2/accounts/{id}` and `GET /api/v2/videos/{id}` also return `tags`.

| Parameter | Description |
|-----------|-------------|
| `tags` | Comma-separated tag names (any casing) or ids, up to 20. Tag names can't contain commas, so a comma always separates two tags. |
| `tags_match` | `any` (default): items with at least one of the tags. `all`: items with every one of them. |

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/creators?tags=q4%20launch,paid&tags_match=all"
```

A name that matches no tag is ignored with `any`; if none of them match, the list is empty. With `all`, any unknown name makes the list empty. The filter runs in the database, so `limit`, `cursor` and `hasMore` work as they do without it. More than 20 tags is `400 invalid_query`.

---
Source: https://www.trackagoat.com/docs/api-reference/tags
