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

# Tags

> Enumerate workspace tags and create new ones with the public API.

A tag is a named label a workspace applies to its **campaigns, flows, templates and segments**. The public API can enumerate every tag in the workspace and create new ones.

Both endpoints authenticate with the [`X-Sender-Tenant` + `X-API-Key`](/en/api-reference/authentication) headers and are rooted at `https://api.senderz.app/api/v1`.

<Warning>
  These tags are **not** the `tags` array on a profile. The profile `tags` field
  documented on [Profiles](/en/api-reference/profiles) is free text stored on the
  contact itself and is never validated against this resource. Writing
  `"tags": ["vip"]` on a profile does not create a tag here, and creating a tag
  here does not make it available on a profile.
</Warning>

<Info>
  The public surface is read and create only. There is no public endpoint to
  rename, recolour or delete a tag. Do that in the dashboard.
</Info>

## List tags

```http theme={"system"}
GET /api/v1/public/tags
```

Returns every tag in the workspace, ordered alphabetically by name.

### Query parameters

<ParamField query="limit" type="integer" default="50">
  Page size. Must be an integer between 1 and 100.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Number of records to skip. Must be an integer of 0 or more.
</ParamField>

There are no other query parameters. The API rejects unknown properties, so a stray `?search=` returns `400`.

The response uses the standard [pagination](/en/api-reference/pagination) envelope with a tag option per item.

### The tag option

<ResponseField name="value" type="string">
  The tag name. This is the `value` field so the shape drops straight into a
  select control.
</ResponseField>

<ResponseField name="label" type="string">
  The tag name again, as the display label. `value` and `label` are always
  identical for tags.
</ResponseField>

<ResponseField name="id" type="string">
  Tag UUID. This is the value `GET /public/campaigns` accepts in `tagIds`.
</ResponseField>

<ResponseField name="color" type="string | null">
  The tag colour, or `null` when none was chosen. One of `neutral`, `primary`,
  `accent`, `warn`, `danger`, `info`, `violet`.
</ResponseField>

### Example

<CodeGroup>
  ```bash cURL theme={"system"}
  curl "https://api.senderz.app/api/v1/public/tags?limit=50&offset=0" \
    -H "X-Sender-Tenant: acme-store" \
    -H "X-API-Key: $SENDERZ_API_KEY"
  ```

  ```javascript Node.js theme={"system"}
  const res = await fetch(
    "https://api.senderz.app/api/v1/public/tags?limit=50&offset=0",
    {
      headers: {
        "X-Sender-Tenant": process.env.SENDERZ_WORKSPACE,
        "X-API-Key": process.env.SENDERZ_API_KEY,
      },
    },
  );
  const { data } = await res.json();
  const byName = new Map(data.items.map((t) => [t.value, t.id]));
  ```

  ```python Python theme={"system"}
  import requests

  res = requests.get(
      "https://api.senderz.app/api/v1/public/tags",
      params={"limit": 50, "offset": 0},
      headers={"X-Sender-Tenant": WORKSPACE, "X-API-Key": API_KEY},
  )
  items = res.json()["data"]["items"]
  ```
</CodeGroup>

```json 200 OK theme={"system"}
{
  "success": true,
  "data": {
    "items": [
      {
        "value": "black-friday",
        "label": "black-friday",
        "id": "6f1c2e58-4a3d-4d2b-9a7e-0c51b8d94a11",
        "color": "danger"
      },
      {
        "value": "vip",
        "label": "vip",
        "id": "b8e4a1d0-77c9-4f16-8b30-2ad5e9f7c204",
        "color": "primary"
      },
      {
        "value": "winback",
        "label": "winback",
        "id": "d21f7a95-0e6b-4c88-91a4-5f3ce0b7d833",
        "color": null
      }
    ],
    "limit": 50,
    "offset": 0,
    "total": 3,
    "has_more": false,
    "next_cursor": null
  }
}
```

<Note>
  This endpoint loads the whole tag set and applies `limit` and `offset` in
  memory. `total` is exact, and a workspace is capped at 200 tags, so a single
  request with `limit=100` covers most workspaces in two calls.
</Note>

## Create a tag

```http theme={"system"}
POST /api/v1/public/tags
```

Creates a tag in the workspace the API key resolves to. Returns `201 Created`.

### Body

<ParamField body="name" type="string" required>
  The tag name. Non-empty, maximum 60 characters. Leading and trailing
  whitespace is trimmed before the tag is stored and before the uniqueness
  check runs.
</ParamField>

<ParamField body="color" type="string">
  Optional colour. One of `neutral`, `primary`, `accent`, `warn`, `danger`,
  `info`, `violet`. Stored as `null` when omitted.
</ParamField>

### Response

Unlike the list endpoint, the create response is the tag record itself, not a paginated envelope and not the `value` / `label` option shape.

<ResponseField name="id" type="string">
  Tag UUID.
</ResponseField>

<ResponseField name="name" type="string">
  The stored (trimmed) name.
</ResponseField>

<ResponseField name="color" type="string | null">
  The colour, or `null`.
</ResponseField>

<ResponseField name="createdAt" type="string">
  ISO 8601 timestamp.
</ResponseField>

<ResponseField name="updatedAt" type="string">
  ISO 8601 timestamp.
</ResponseField>

### Example

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://api.senderz.app/api/v1/public/tags \
    -H "X-Sender-Tenant: acme-store" \
    -H "X-API-Key: $SENDERZ_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "vip",
      "color": "primary"
    }'
  ```

  ```javascript Node.js theme={"system"}
  const res = await fetch("https://api.senderz.app/api/v1/public/tags", {
    method: "POST",
    headers: {
      "X-Sender-Tenant": process.env.SENDERZ_WORKSPACE,
      "X-API-Key": process.env.SENDERZ_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ name: "vip", color: "primary" }),
  });
  const { data } = await res.json();
  ```

  ```python Python theme={"system"}
  import requests

  res = requests.post(
      "https://api.senderz.app/api/v1/public/tags",
      headers={
          "X-Sender-Tenant": WORKSPACE,
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
      },
      json={"name": "vip", "color": "primary"},
  )
  tag = res.json()["data"]
  ```
</CodeGroup>

```json 201 Created theme={"system"}
{
  "success": true,
  "data": {
    "id": "b8e4a1d0-77c9-4f16-8b30-2ad5e9f7c204",
    "name": "vip",
    "color": "primary",
    "createdAt": "2026-08-26T09:14:02.517Z",
    "updatedAt": "2026-08-26T09:14:02.517Z"
  }
}
```

## Filtering campaigns by tag

The `id` returned by `GET /public/tags` is what the campaigns list accepts in `tagIds`. Pass one or more UUIDs, comma separated, and the response is narrowed to campaigns carrying any of them.

```bash theme={"system"}
curl "https://api.senderz.app/api/v1/public/campaigns?tagIds=b8e4a1d0-77c9-4f16-8b30-2ad5e9f7c204,6f1c2e58-4a3d-4d2b-9a7e-0c51b8d94a11" \
  -H "X-Sender-Tenant: acme-store" \
  -H "X-API-Key: $SENDERZ_API_KEY"
```

Every value must be a UUID. A tag **name** in `tagIds` returns `400`, so resolve names to ids with `GET /public/tags` first. See [Resource dropdowns](/en/api-reference/dropdowns) for the rest of the campaigns query.

## Errors

| Status | Code                | When                                                                                                                                                                                                                 |
| ------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  |                     | Validation failed: `name` missing or empty, `name` over 60 characters, `color` not one of the seven allowed values, `limit` outside 1 to 100, `offset` below 0, or any unknown property in the body or query string. |
| `400`  | `tag_limit_reached` | The workspace already holds 200 tags. Delete one in the dashboard before creating another.                                                                                                                           |
| `401`  |                     | `X-Sender-Tenant` or `X-API-Key` missing (`Missing tenant or API key`) or the pair did not resolve (`Invalid API key`).                                                                                              |
| `409`  | `tag_name_taken`    | A tag with that name already exists in the workspace. Names are unique per workspace.                                                                                                                                |
| `429`  |                     | Rate limited.                                                                                                                                                                                                        |

A coded error carries the machine `code` alongside the human `message`:

```json theme={"system"}
{
  "success": false,
  "message": "A tag with that name already exists",
  "code": "tag_name_taken"
}
```

Match on `code`, not on `message`. See [Errors](/en/api-reference/errors) for the full envelope.

<Tip>
  Creating a tag is not idempotent. To make a create safe to retry, treat `409`
  with `code: "tag_name_taken"` as success and read the existing id back from
  `GET /public/tags`.
</Tip>
