# Post tags API: label posts and compare what works

> Index of all OmniSocials docs pages: https://docs.omnisocials.com/llms.txt

Tags are your own labels on your posts: the hook a video opens with ("Question hook"), its format ("Talking head"), the campaign it belongs to ("Black Friday"). They answer one question: which kind of post works best for you.

Tags stay in OmniSocials. They are never sent to a platform and never change a caption, so you can tag a post that is already live.

| Endpoint | What it does | Scope |
| --- | --- | --- |
| `GET /tags` | List the workspace's tags | `posts:read` |
| `POST /tags` | Create a tag | `posts:write` |
| `PATCH /tags/:id` | Rename or recolour a tag | `posts:write` |
| `DELETE /tags/:id` | Delete a tag; it comes off every post | `posts:write` |
| `POST /tags/apply` | Add, remove or replace the tags of up to 500 posts | `posts:write` |
| `GET /analytics/tags` | Compare each tag with your normal | `analytics:read` |

The full request and response schemas are in the API reference under [Tags](https://docs.omnisocials.com/api/tags). Posts also take `tags` on [`POST /posts/create`](https://docs.omnisocials.com/creating-posts.md#post-tags) and `PATCH /posts/:id`, and `GET /posts` and `GET /analytics/overview` take a `tag` filter. Tags belong to a workspace. The dashboard uses the same tags, and its Analytics page shows the same comparison under **Tags**.

## The tag object

```json
{
  "id": 12,
  "name": "Question hook",
  "color": "blue",
  "created_at": "2026-11-02T09:14:00.000Z"
}
```

| Field | Meaning |
| --- | --- |
| `id` | The tag id. Use it, or the name, wherever a tag is asked for. |
| `name` | Up to 40 characters, unique in the workspace (case-insensitive). Runs of spaces become one space. |
| `color` | `blue`, `green`, `orange`, `red`, `purple`, `pink`, `teal`, `yellow`, `brown` or `gray`. Only the app uses it. Leave it out on create to get the next colour of the palette. |
| `created_at` | When the tag was made. |

The `tags` of a post are tag objects without `created_at`.

## Create, rename and delete tags

```bash
curl -X POST https://api.omnisocials.com/v1/tags \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Question hook" }'
```

The response is `201` with the new tag in `data`. A name the workspace already has answers `409 tag_exists`, with that tag in `error.tag`, so you can use its id instead.

You rarely need `POST /tags`: wherever you give a tag by name (`tags` on a post, `add` or `set` on `POST /tags/apply`), a name that does not exist yet becomes a new tag.

`PATCH /tags/:id` renames or recolours a tag. A key you leave out keeps its value, and the posts keep the tag:

```bash
curl -X PATCH https://api.omnisocials.com/v1/tags/12 \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Question opener" }'
```

`DELETE /tags/:id` deletes the tag and takes it off every post of the workspace. The posts stay. The response names the tag and how many posts carried it:

```json
{
  "data": {
    "tag": { "id": 12, "name": "Question opener", "color": "blue", "created_at": "2026-11-02T09:14:00.000Z" },
    "posts_untagged": 14
  }
}
```

## Tag a post

Pass `tags` when you create a post:

```json
POST /posts/create
{
  "content": { "default": "Would you post this at 6am?" },
  "accounts": ["tiktok", "instagram"],
  "type": "reel",
  "media_ids": ["508"],
  "schedule_at": "2026-11-10T09:00:00Z",
  "tags": ["Question hook", "Talking head"]
}
```

`tags` takes tag names or ids, mixed:

- A name matches case-insensitively. A name that does not exist yet becomes a new tag of the workspace.
- A number, or a string of digits such as `"12"`, is read as a tag id. An unknown id answers `404 tag_not_found`.
- At most 20 tags per post. A tag named twice counts once.

The post comes back with `tags` as a list of tag objects. `GET /posts` and `GET /posts/:id` return them too.

`PATCH /posts/:id` with `tags` replaces the post's tags; `null` or `[]` removes them all. A PATCH that sends only `tags` works on every post, published and failed posts included. A PATCH with other fields still follows the normal rule: a published or failed post cannot change.

```bash
curl -X PATCH https://api.omnisocials.com/v1/posts/1024 \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tags": ["Question hook"] }'
```

## Tag many posts at once

`POST /tags/apply` changes the tags of up to 500 posts in one call, published posts included. Use it to tag your older posts, so the comparison has enough posts to work with.

```bash
curl -X POST https://api.omnisocials.com/v1/tags/apply \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "post_ids": ["1024", "1025", "1031"],
    "add": ["Question hook"]
  }'
```

| Field | Meaning |
| --- | --- |
| `post_ids` | Required. 1 to 500 OmniSocials post ids (the `id` from `GET /posts`). |
| `add` | Tags to add, by name or id. A new name becomes a tag. The post's other tags stay. |
| `remove` | Tags to take off, by name or id. An unknown name answers `404 tag_not_found`. |
| `set` | Replaces each post's tags with exactly these. `[]` removes them all. |

Give at least one of `add`, `remove` and `set`. When you give more than one, `set` goes first, then `add`, then `remove`.

```json
{
  "data": {
    "updated": 1,
    "posts": [
      { "id": "1024", "tags": [{ "id": 12, "name": "Question hook", "color": "blue" }] },
      { "id": "1025", "tags": [{ "id": 12, "name": "Question hook", "color": "blue" }, { "id": 15, "name": "Talking head", "color": "green" }] }
    ],
    "not_found": ["1031"]
  }
}
```

- `posts` lists every post that was found, with its tags now.
- `updated` counts the posts whose tags changed. Here post 1024 already had the tag, so only 1025 counts.
- `not_found` lists the ids that are not posts of this workspace. The other posts are still tagged.

The call is all or nothing for the posts it finds: when one post would end up with more than 20 tags, it answers `400 too_many_tags` with that post in `error.post_id`, and no post changes.

## Filter by tag

`GET /posts?tag=` returns only the posts with that tag. Comma separate several tags to get the posts that have any of them. Names and ids both work:

```bash
curl "https://api.omnisocials.com/v1/posts?tag=Question%20hook,Talking%20head&status=published" \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY"
```

`GET /analytics/overview?tag=` works the same way: the post numbers (`total_posts`, engagement, impressions, the platform breakdown) then cover only the posts with the tag. Account numbers such as followers stay those of the whole account.

A filter never makes a tag: an unknown name or id answers `404 tag_not_found`. For a tag whose name holds a comma, filter by its id.

## Compare tags

`GET /analytics/tags` shows how the posts of each tag did against your normal, for the posts published in a period.

```bash
curl "https://api.omnisocials.com/v1/analytics/tags?start_date=2026-08-01&end_date=2026-10-31" \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY"
```

| Parameter | Meaning |
| --- | --- |
| `start_date` | `YYYY-MM-DD`, `YYYY-MM` or ISO 8601. Default: 90 days before `end_date`. |
| `end_date` | Same formats, inclusive. Default: today. |
| `platform` | Only this channel's numbers, for example `tiktok`. It must be a connected channel. Default: every connected channel. |
| `min_posts` | How many measured posts a tag needs before `enough_posts` is true. Default 5, from 1 to 50. |

```json
{
  "data": {
    "period": { "start_date": "2026-08-01T00:00:00.000Z", "end_date": "2026-10-31T23:59:59.999Z" },
    "platform": null,
    "min_posts": 5,
    "tags": [
      {
        "tag": { "id": 12, "name": "Question hook", "color": "blue" },
        "posts": 14,
        "measured": 13,
        "median_impressions": 4210,
        "engagement_rate": 5.4,
        "vs_normal": 0.38,
        "best_channel": "tiktok",
        "enough_posts": true
      },
      {
        "tag": { "id": 15, "name": "Talking head", "color": "green" },
        "posts": 9,
        "measured": 9,
        "median_impressions": 2890,
        "engagement_rate": 4.1,
        "vs_normal": -0.12,
        "best_channel": "instagram",
        "enough_posts": true
      },
      {
        "tag": { "id": 18, "name": "Black Friday", "color": "orange" },
        "posts": 3,
        "measured": 2,
        "median_impressions": 6100,
        "engagement_rate": 6.0,
        "vs_normal": 0.9,
        "best_channel": null,
        "enough_posts": false
      }
    ],
    "untagged": {
      "posts": 41,
      "measured": 38,
      "median_impressions": 3050,
      "engagement_rate": 4.6,
      "vs_normal": 0.02,
      "best_channel": "tiktok",
      "enough_posts": true
    },
    "normals": { "tiktok": 3400, "instagram": 2900, "linkedin": 1100 }
  }
}
```

| Field | Meaning |
| --- | --- |
| `posts` | Posts with the tag in the period |
| `measured` | Those posts that have numbers yet |
| `median_impressions` | The median of the measured posts' impressions, each post summed over its channels |
| `engagement_rate` | Engagement divided by impressions over all measured posts, in percent (5.4 = 5.4%) |
| `vs_normal` | How far above or below your normal the tag's posts are: `0.38` = 38% above, `-0.12` = 12% below, `0` = normal. `null` without numbers |
| `best_channel` | The channel where the tag's posts beat your normal by the most. Only channels with at least 3 of the tag's posts count; `null` when none has that many |
| `enough_posts` | `false` below `min_posts` measured posts. Read `vs_normal` then as a hint, not a result |

`tags` lists the tags with enough posts first, best `vs_normal` first, then the tags with too few posts. `untagged` has the same numbers for the posts without a tag. `normals` is your normal per channel.

### How vs_normal is computed

1. **Your normal per channel.** For each channel, the median impressions of every measured post on that channel in the period. These are the `normals`.
2. **An index per post.** Each post's impressions on a channel are divided by that channel's normal, and the post's index is the mean over its channels. A TikTok post is compared with your TikTok posts, a LinkedIn post with your LinkedIn posts.
3. **A value per tag.** `vs_normal` is the median index of the tag's posts, minus 1.

Raw views would let a tag win only because its posts went to the channel with the most views. The median keeps one viral post from carrying a tag. Impressions here are the same number as in the rest of the analytics: views where the platform gives them, else impressions, else reach (see [How engagement is calculated](https://docs.omnisocials.com/analytics.md#how-engagement-is-calculated)).

Which posts count:

- Published posts (`published` or `warning`) whose publish time is in the period, on a connected channel (or on `platform` when you pass it).
- A post with two tags counts in both rows.
- Posts made outside OmniSocials do not count.

A post that published minutes ago has no numbers yet and is not `measured`. See [How metrics are collected](https://docs.omnisocials.com/analytics.md#how-metrics-are-collected).

## Trial reels

Posts carry `trial_reel`: `{ "strategy": "MANUAL" }` or `{ "strategy": "SS_PERFORMANCE" }` when the post went to Instagram as a [Trial Reel](https://docs.omnisocials.com/creating-reels.md#instagram-trial-reels) (`instagram.is_trial_reel`), and `null` on every other post. It is on `GET /posts`, `GET /posts/:id` and each entry of `GET /analytics/posts`, so you can compare trial reels with your other reels.

```json
{
  "post_id": "1024",
  "trial_reel": { "strategy": "MANUAL" },
  "tags": [{ "id": 12, "name": "Question hook", "color": "blue" }],
  "platforms": { "instagram": { "metrics": { "views": 5120, "likes": 210 } } }
}
```

Instagram's API does not say whether a trial reel was shared with your followers later. `trial_reel` is the setting the post went out with, not what happened after. To see trial reels in the tag comparison, tag them, for example "Trial reel".

## Limits

| Limit | Value |
| --- | --- |
| Tags per workspace | 200 |
| Tags per post | 20 |
| Posts per `POST /tags/apply` | 500 |
| Tag name | 40 characters |

## Errors

Tag errors follow the [shared error shape](https://docs.omnisocials.com/rate-limits-and-errors.md#error-body-shape). Some add a field on `error`, named in the table.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_tag_name` | The name is empty or longer than 40 characters |
| 400 | `invalid_tag_color` | `color` is not one of the 10 colours |
| 400 | `invalid_tags` | `tags`, `add`, `remove` or `set` is not a list of names or ids |
| 400 | `invalid_tag_ids` | `POST /tags/apply` without `add`, `remove` or `set` |
| 400 | `invalid_post_ids` | `POST /tags/apply` without a post id |
| 400 | `too_many_posts` | More than 500 post ids |
| 400 | `too_many_tags` | More than 20 tags on one post; on `POST /tags/apply` the post is in `error.post_id` |
| 400 | `invalid_date`, `invalid_date_range`, `invalid_platform` | `GET /analytics/tags`: a date it cannot read, a start after the end, or a channel that is not connected |
| 404 | `tag_not_found` | An unknown tag id (in `error.tag_ids`), or an unknown name in `remove` or a `tag` filter (in `error.name`) |
| 409 | `tag_exists` | Another tag has that name; it is in `error.tag` |
| 409 | `tag_limit_reached` | The workspace has 200 tags. Delete one you no longer use first |

## Using it from an AI assistant

The [MCP server](https://docs.omnisocials.com/mcp-server.md) has `list_tags`, `create_tag`, `update_tag`, `delete_tag`, `tag_posts` (the bulk call) and `get_tag_analytics`. `create_post`, `create_and_publish_post` and `update_post` take `tags`, and `list_posts` and `get_analytics_overview` take `tag`. `get_post` and `list_posts` show each post's tags and mark trial reels.

An assistant can tag your older posts for you: it reads the captions with `list_posts`, tags them with `tag_posts`, and then reads the result with `get_tag_analytics`. It reuses the names from `list_tags`, so no near-duplicate tags appear.

The [CLI](https://docs.omnisocials.com/cli/management.md#post-tags) has `tags:list`, `tags:create`, `tags:update`, `tags:delete` and `tags:apply`, and [`analytics:tags`](https://docs.omnisocials.com/cli/analytics.md) for the comparison.
