---
title: "Label passages in Speak AI transcripts"
description: "Manage your company's labels and groups, add Speak label sets, and apply, move or remove labels on passages of a transcript without losing edits."
---

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

# Label passages in Speak AI transcripts


import EndpointIndex from "@/components/api/EndpointIndex.astro";

The Speak AI API exposes 11 labels endpoints under the base URL `https://api.speakai.co/v1`. Every request needs the `x-speakai-key` and `x-access-token` headers described in [Authentication](/api/authentication/).

Labels mark passages of a transcript, such as an objection on a sales call or a quote for a report. Your company keeps one list of labels, with one level of groups, and anyone with the **Labels: Assign** permission can put them on any transcript they can open.

## Permissions

Admins set these per person in Team management. Owners pass every check. Reading labels needs no permission.

| Permission | Allows | Admin default | Member default |
| --- | --- | --- | --- |
| Labels: Create | Create labels and groups, add Speak label sets | Yes | Yes |
| Labels: Update | Rename, recolour, describe, regroup, reorder; merge with Delete | Yes | Yes |
| Labels: Delete | Archive and restore; merge with Update | Yes | No |
| Labels: Assign | Apply, change and remove labels on transcripts | Yes | Yes |

A missing permission returns `403` with `{"status": "failed", "message": "You do not have permission to perform this action"}`.

## Transcript revisions

Every saved change to a transcript increases the media's `transcriptRevision`. Passages are addressed by word position on a given revision, so labelling and commenting send `expectedTranscriptRevision` and get 409 when it is stale. When a transcript is edited, labelled passages move with their words; a passage whose words changed too much is marked `needs_review` with a suggested new position, and nothing is deleted.

## Notifications

Every label change, in the library or on a transcript, is listed for the person who made it under the `label` category of [GET /v1/notification](/api/notifications/).

## What can you do with the labels endpoints?

Speak AI groups these 11 endpoints under the labels resource. Each entry below links to the full reference for that endpoint further down this page.

### Label list

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | [`/labels`](#get-labels) | List labels |
| `POST` | [`/labels`](#post-labels) | Create a label or group |
| `PUT` | [`/labels/{labelId}`](#put-labels-label-id) | Update a label or group |
| `POST` | [`/labels/{labelId}/archive`](#post-labels-label-id-archive) | Archive a label or group |
| `POST` | [`/labels/{labelId}/merge`](#post-labels-label-id-merge) | Merge a label into another |
| `POST` | [`/labels/{labelId}/restore`](#post-labels-label-id-restore) | Restore an archived label or group |
| `POST` | [`/labels/speak-sets`](#post-labels-speak-sets) | Add Speak label sets |

### Labels on a transcript

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | [`/media/{mediaId}/labels`](#get-media-media-id-labels) | List labels on a transcript |
| `POST` | [`/media/{mediaId}/labels`](#post-media-media-id-labels) | Label a passage |
| `PATCH` | [`/media/{mediaId}/labels/{mediaLabelId}`](#patch-media-media-id-labels-media-label-id) | Change, confirm or move a labelled passage |
| `DELETE` | [`/media/{mediaId}/labels/{mediaLabelId}`](#delete-media-media-id-labels-media-label-id) | Remove a labelled passage |

## Label list

<h2 id="get-labels">List labels</h2>

Returns your company's labels as a tree: groups and ungrouped labels at the top level, each group carrying the labels inside it. Items are ordered by `sortOrder`, then by name. Groups go one level deep.

No labels permission is needed: anyone on the team can read the list.

#### Filtering

- `status` picks active labels (the default), archived ones, or both.
- `search` matches names, ignoring case. A group is listed when its own name matches or when any of its labels match; it then carries only the matching labels.

#### Usage counts

`usageCount` counts labelled passages only on media you can open, so it never reveals anything about files in folders you cannot access. Owners and members with **Access all** on folders see company-wide counts.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | string | No | Which labels to return. Leave it out for `active`. One of: `active`, `archived`, `all`. |
| `search` | query | string | No | Text to match against label and group names, ignoring case. Trimmed, up to 80 characters. An empty value returns everything. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X GET 'https://api.speakai.co/v1/labels?status=active&search=action' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000'
```

</CodePanel>

<ResponsePanel id="get-labels" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"A query value failed validation"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.labels` | object[] | Top-level groups and ungrouped labels. |

<details>
<summary>`data.labels` -- 15 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `labelId` | string | Id of the label or group. |
| `isGroup` | boolean | `true` for a group. A group holds labels and cannot be applied to a transcript. |
| `name` | string | Name. |
| `description` | string | Description. Left out when none was set. |
| `color` | string | `#rrggbb` in lower case. Groups have none. |
| `parentId` | string | `labelId` of the group this label sits in, or `null` at the top level. |
| `source` | string | `speak` for labels added from a Speak label set, `user` for everything else. One of: `user`, `speak` . |
| `isActive` | boolean | `false` once archived. |
| `mergedInto` | string | `labelId` this label was merged into. Present only on merged labels. |
| `sortOrder` | integer | Position among its siblings. |
| `userId` | string | Id of the person who created it. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |
| `usageCount` | integer | How many labelled passages use it, counted only on media you can open. For a group, the sum over its listed labels. |
| `labels` | object[] | Groups only: the labels in the group that match the filter, with the same fields. |

<details>
<summary>`labels` -- 14 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `labelId` | string | Id of the label or group. |
| `isGroup` | boolean | `true` for a group. A group holds labels and cannot be applied to a transcript. |
| `name` | string | Name. |
| `description` | string | Description. Left out when none was set. |
| `color` | string | `#rrggbb` in lower case. Groups have none. |
| `parentId` | string | `labelId` of the group this label sits in, or `null` at the top level. |
| `source` | string | `speak` for labels added from a Speak label set, `user` for everything else. One of: `user`, `speak` . |
| `isActive` | boolean | `false` once archived. |
| `mergedInto` | string | `labelId` this label was merged into. Present only on merged labels. |
| `sortOrder` | integer | Position among its siblings. |
| `userId` | string | Id of the person who created it. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |
| `usageCount` | integer | How many labelled passages use it, counted only on media you can open. For a group, the sum over its listed labels. |

</details>

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"labels": [
  {
    "labelId": "Gx7QkP2mVt9LwRz",
    "isGroup": true,
    "name": "Meetings",
    "parentId": null,
    "source": "speak",
    "isActive": true,
    "sortOrder": 0,
    "userId": "65f0c1a2b3c4d5e6f7a8b9c0",
    "createdAt": "2026-10-06T14:05:12.000Z",
    "updatedAt": "2026-10-06T14:05:12.000Z",
    "usageCount": 12,
    "labels": [
      {
        "labelId": "Lb3NcY8sDf4HqPe",
        "isGroup": false,
        "name": "Action item",
        "description": "Something a person agreed to do.",
        "color": "#6366f1",
        "parentId": "Gx7QkP2mVt9LwRz",
        "source": "speak",
        "isActive": true,
        "sortOrder": 0,
        "userId": "65f0c1a2b3c4d5e6f7a8b9c0",
        "createdAt": "2026-10-06T14:05:12.000Z",
        "updatedAt": "2026-10-06T14:05:12.000Z",
        "usageCount": 12
      }
    ]
  }
]
  }
}
```

</div>

<div slot="status-400">

**`400` A query value failed validation**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (A query value failed validation), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "\"status\" must be one of [active, archived, all]",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="post-labels">Create a label or group</h2>

Requires the **Labels: Create** permission (on by default for admins and members).

#### Request rules

- Send `isGroup: true` to create a group. A group cannot have `color` or `parentId`; sending either returns 400.
- `parentId` must be the `labelId` of an active group in your company. A missing group returns 404 "Parent group not found."; a label that is not a group returns 400 "A label can only be placed inside a group."
- `color` defaults to `#6366f1` when you leave it out.
- `sortOrder` defaults to 0.

#### Errors

- `400` when a field fails validation or the parent is not a group.
- `403` without the permission.
- `404` when the parent group does not exist.
- `409` when an active label with the same name already exists under the same parent. Names are compared ignoring case, after trimming and collapsing spaces.

### Request body

Fields marked **required** are the ones the server rejects the request without. Anything conditional, where a field becomes required only alongside another, is described under Request rules above rather than marked here.

| Field | Type | Description |
| --- | --- | --- |
| `name` | string, **required** | Name of the label or group. Trimmed, then 1 to 80 characters. Repeated spaces are collapsed to one before saving. Names are unique among active labels with the same parent, compared without regard to case, so a duplicate returns 409. |
| `isGroup` | boolean | `true` to create a group instead of a label. Defaults to `false`. |
| `description` | string | What the label means, shown to people choosing it. Up to 500 characters after trimming. An empty string is accepted. |
| `color` | string | Colour as six-digit hex, `#rrggbb` (pattern `^#[0-9a-fA-F]{6}$`). Stored in lower case. Not allowed on a group. |
| `parentId` | string | `labelId` of the group to put the label in. `null` or left out for the top level. Not allowed on a group. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |
| `sortOrder` | integer | Position among its siblings, lowest first. Integer from 0 to 1,000,000. Labels with the same value are ordered by name. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X POST 'https://api.speakai.co/v1/labels' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Action item",
  "description": "Something a person agreed to do.",
  "color": "#6366f1",
  "parentId": "Gx7QkP2mVt9LwRz"
}'
```

</CodePanel>

<ResponsePanel id="post-labels" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"The request failed validation"},{"code":"403","description":"You lack the Labels: Create permission"},{"code":"404","description":"The parent group does not exist"},{"code":"409","description":"An active label with this name already exists under the same parent"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | A label or group. |

<details>
<summary>`data` -- 13 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `labelId` | string | Id of the label or group. |
| `isGroup` | boolean | `true` for a group. A group holds labels and cannot be applied to a transcript. |
| `name` | string | Name. |
| `description` | string | Description. Left out when none was set. |
| `color` | string | `#rrggbb` in lower case. Groups have none. |
| `parentId` | string | `labelId` of the group this label sits in, or `null` at the top level. |
| `source` | string | `speak` for labels added from a Speak label set, `user` for everything else. One of: `user`, `speak` . |
| `isActive` | boolean | `false` once archived. |
| `mergedInto` | string | `labelId` this label was merged into. Present only on merged labels. |
| `sortOrder` | integer | Position among its siblings. |
| `userId` | string | Id of the person who created it. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"labelId": "Lb3NcY8sDf4HqPe",
"isGroup": false,
"name": "Action item",
"description": "Something a person agreed to do.",
"color": "#6366f1",
"parentId": "Gx7QkP2mVt9LwRz",
"source": "user",
"isActive": true,
"sortOrder": 0,
"userId": "65f0c1a2b3c4d5e6f7a8b9c0",
"createdAt": "2026-10-06T14:05:12.000Z",
"updatedAt": "2026-10-06T14:05:12.000Z"
  }
}
```

</div>

<div slot="status-400">

**`400` The request failed validation**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The request failed validation), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "\"name\" length must be less than or equal to 80 characters long",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Create permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Create permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` The parent group does not exist**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The parent group does not exist), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Parent group not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

<div slot="status-409">

**`409` An active label with this name already exists under the same parent**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (An active label with this name already exists under the same parent), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 409,
  "message": "A label with this name already exists here.",
  "hints": [
"Please use PUT for update instead of POST",
"The requested operation failed because it tried to create a resource that already exists."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="put-labels-label-id">Update a label or group</h2>

Requires the **Labels: Update** permission (on by default for admins and members). Send at least one field; an empty body returns 400. Only the fields you send change.

#### Request rules

- An archived label cannot be edited: restore it first (400 "Restore this label before editing it.").
- A group has no colour and cannot be placed inside another group (400).
- `parentId` moves a label into a group; `null` moves it to the top level. A label cannot be its own group (400).

#### Errors

- `400` for the rules above or a field that fails validation.
- `403` without the permission.
- `404` when the label or the new parent group does not exist.
- `409` when the new name is already used by an active label under the same parent.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `labelId` | path | string | Yes | Id of the label or group. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

### Request body

Fields marked **required** are the ones the server rejects the request without. Anything conditional, where a field becomes required only alongside another, is described under Request rules above rather than marked here.

| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Name of the label or group. Trimmed, then 1 to 80 characters. Repeated spaces are collapsed to one before saving. Names are unique among active labels with the same parent, compared without regard to case, so a duplicate returns 409. |
| `description` | string | What the label means, shown to people choosing it. Up to 500 characters after trimming. An empty string is accepted. |
| `color` | string | Colour as six-digit hex, `#rrggbb` (pattern `^#[0-9a-fA-F]{6}$`). Stored in lower case. Not allowed on a group. |
| `parentId` | string | `labelId` of a group to move the label into, or `null` to move it to the top level. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |
| `sortOrder` | integer | Position among its siblings, lowest first. Integer from 0 to 1,000,000. Labels with the same value are ordered by name. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X PUT 'https://api.speakai.co/v1/labels/Lb3NcY8sDf4HqPe' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "Next step"
}'
```

</CodePanel>

<ResponsePanel id="put-labels-label-id" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"A rule above was broken or a field failed validation"},{"code":"403","description":"You lack the Labels: Update permission"},{"code":"404","description":"No label with that id in your company"},{"code":"409","description":"An active label with this name already exists under the same parent"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | A label or group. |

<details>
<summary>`data` -- 13 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `labelId` | string | Id of the label or group. |
| `isGroup` | boolean | `true` for a group. A group holds labels and cannot be applied to a transcript. |
| `name` | string | Name. |
| `description` | string | Description. Left out when none was set. |
| `color` | string | `#rrggbb` in lower case. Groups have none. |
| `parentId` | string | `labelId` of the group this label sits in, or `null` at the top level. |
| `source` | string | `speak` for labels added from a Speak label set, `user` for everything else. One of: `user`, `speak` . |
| `isActive` | boolean | `false` once archived. |
| `mergedInto` | string | `labelId` this label was merged into. Present only on merged labels. |
| `sortOrder` | integer | Position among its siblings. |
| `userId` | string | Id of the person who created it. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"labelId": "Lb3NcY8sDf4HqPe",
"isGroup": false,
"name": "Next step",
"description": "Something a person agreed to do.",
"color": "#6366f1",
"parentId": "Gx7QkP2mVt9LwRz",
"source": "user",
"isActive": true,
"sortOrder": 0,
"userId": "65f0c1a2b3c4d5e6f7a8b9c0",
"createdAt": "2026-10-06T14:05:12.000Z",
"updatedAt": "2026-10-06T16:00:00.000Z"
  }
}
```

</div>

<div slot="status-400">

**`400` A rule above was broken or a field failed validation**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (A rule above was broken or a field failed validation), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "Restore this label before editing it.",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Update permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Update permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` No label with that id in your company**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (No label with that id in your company), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Label not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

<div slot="status-409">

**`409` An active label with this name already exists under the same parent**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (An active label with this name already exists under the same parent), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 409,
  "message": "A label with this name already exists here.",
  "hints": [
"Please use PUT for update instead of POST",
"The requested operation failed because it tried to create a resource that already exists."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="post-labels-label-id-archive">Archive a label or group</h2>

Requires the **Labels: Delete** permission (on by default for admins). Archived labels drop out of the default list and cannot be applied, but passages already labelled keep them. Archiving a group also archives every label in it. Safe to repeat: archiving something already archived returns `archivedCount: 0`. Nothing is deleted; restore it at any time.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `labelId` | path | string | Yes | Id of the label or group. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X POST 'https://api.speakai.co/v1/labels/Lb3NcY8sDf4HqPe/archive' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000'
```

</CodePanel>

<ResponsePanel id="post-labels-label-id-archive" statuses={[{"code":"200","description":"Success"},{"code":"403","description":"You lack the Labels: Delete permission"},{"code":"404","description":"No label with that id in your company"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.labelId` | string | Id of the label you archived. |
| `data.archivedCount` | integer | How many labels and groups changed from active to archived. |

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"labelId": "Gx7QkP2mVt9LwRz",
"archivedCount": 5
  }
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Delete permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Delete permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` No label with that id in your company**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (No label with that id in your company), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Label not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="post-labels-label-id-merge">Merge a label into another</h2>

Requires both **Labels: Update** and **Labels: Delete**. Moves every use of the label in the path onto `targetLabelId`, then archives it with `mergedInto` set. A passage that already had both labels keeps one. A merged label cannot be restored.

`movedCount` is the number of labelled passages changed across the whole company.

#### Errors

- `400` when the two ids are the same, when either is a group, or when either is archived.
- `403` without both permissions.
- `404` when either label does not exist.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `labelId` | path | string | Yes | Id of the label to merge away. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

### Request body

Fields marked **required** are the ones the server rejects the request without. Anything conditional, where a field becomes required only alongside another, is described under Request rules above rather than marked here.

| Field | Type | Description |
| --- | --- | --- |
| `targetLabelId` | string, **required** | Id of the label to keep. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X POST 'https://api.speakai.co/v1/labels/Lb9TmK2vXa6JrWo/merge' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000' \
  -H 'Content-Type: application/json' \
  -d '{
  "targetLabelId": "Lb3NcY8sDf4HqPe"
}'
```

</CodePanel>

<ResponsePanel id="post-labels-label-id-merge" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"Same label, a group, or an archived label"},{"code":"403","description":"You lack Labels: Update or Labels: Delete"},{"code":"404","description":"Either label does not exist"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.labelId` | string | Id of the label that was merged and archived. |
| `data.mergedInto` | string | Id of the label that now carries its uses. |
| `data.movedCount` | integer | Labelled passages changed, company-wide. |

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"labelId": "Lb9TmK2vXa6JrWo",
"mergedInto": "Lb3NcY8sDf4HqPe",
"movedCount": 7
  }
}
```

</div>

<div slot="status-400">

**`400` Same label, a group, or an archived label**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (Same label, a group, or an archived label), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "Choose a different label to merge into.",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack Labels: Update or Labels: Delete**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack Labels: Update or Labels: Delete), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` Either label does not exist**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (Either label does not exist), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Target label not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="post-labels-label-id-restore">Restore an archived label or group</h2>

Requires the **Labels: Delete** permission. Restoring a group also restores its archived labels, except ones that were merged. A label in that group whose name is now used by an active label stays archived and is counted in `skippedCount`.

#### Errors

- `400` "This label was merged into another label and cannot be restored."
- `400` "Restore its group first." when the label sits in a group that is still archived.
- `403` without the permission.
- `404` when the label does not exist.
- `409` when the label's own name is now used by an active label under the same parent. Rename one of them first.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `labelId` | path | string | Yes | Id of the label or group. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X POST 'https://api.speakai.co/v1/labels/Lb3NcY8sDf4HqPe/restore' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000'
```

</CodePanel>

<ResponsePanel id="post-labels-label-id-restore" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"The label was merged, or its group is archived"},{"code":"403","description":"You lack the Labels: Delete permission"},{"code":"404","description":"No label with that id in your company"},{"code":"409","description":"Its name is used by an active label"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.labelId` | string | Id of the label you restored. |
| `data.restoredCount` | integer | How many labels and groups changed from archived to active. |
| `data.skippedCount` | integer | Labels in the group left archived because their name is taken. |

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"labelId": "Gx7QkP2mVt9LwRz",
"restoredCount": 5,
"skippedCount": 0
  }
}
```

</div>

<div slot="status-400">

**`400` The label was merged, or its group is archived**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The label was merged, or its group is archived), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "Restore its group first.",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Delete permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Delete permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` No label with that id in your company**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (No label with that id in your company), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Label not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

<div slot="status-409">

**`409` Its name is used by an active label**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (Its name is used by an active label), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 409,
  "message": "A label with this name already exists here. Rename one of them first.",
  "hints": [
"Please use PUT for update instead of POST",
"The requested operation failed because it tried to create a resource that already exists."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="post-labels-speak-sets">Add Speak label sets</h2>

Adds ready-made sets of labels, each as a group with `source: "speak"`. Requires the **Labels: Create** permission.

| Set | Group | Labels |
| --- | --- | --- |
| `sales_qa` | Sales QA | Unprofessional, Slang, Objection, Great moment, Compliance risk |
| `research` | Research | Pain point, Motivation, Quote for report, Surprise, Follow-up |
| `meetings` | Meetings | Decision, Action item, Risk, Open question |
| `transcript_feedback` | Transcript feedback | Wrong split, Misheard word, Wrong speaker, Bad translation |

Safe to repeat: an active group or label with the same name is reused rather than duplicated, so `createdCount` counts only what is new. A set whose group name is already used by a plain top-level label is skipped and listed in `skippedSets`.

### Request body

Fields marked **required** are the ones the server rejects the request without. Anything conditional, where a field becomes required only alongside another, is described under Request rules above rather than marked here.

| Field | Type | Description |
| --- | --- | --- |
| `sets` | string[], **required** | The sets to add. At least one, no repeats. Each item is one of: `sales_qa`, `research`, `meetings`, `transcript_feedback` . |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X POST 'https://api.speakai.co/v1/labels/speak-sets' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000' \
  -H 'Content-Type: application/json' \
  -d '{
  "sets": [
"meetings"
  ]
}'
```

</CodePanel>

<ResponsePanel id="post-labels-speak-sets" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"The request failed validation"},{"code":"403","description":"You lack the Labels: Create permission"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.groups` | object[] | The group for each set that was added or already there. |
| `data.createdCount` | integer | How many groups and labels were created by this request. |
| `data.skippedSets` | string[] | Sets skipped because a plain label already uses the group name. |

<details>
<summary>`data.groups` -- 13 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `labelId` | string | Id of the label or group. |
| `isGroup` | boolean | `true` for a group. A group holds labels and cannot be applied to a transcript. |
| `name` | string | Name. |
| `description` | string | Description. Left out when none was set. |
| `color` | string | `#rrggbb` in lower case. Groups have none. |
| `parentId` | string | `labelId` of the group this label sits in, or `null` at the top level. |
| `source` | string | `speak` for labels added from a Speak label set, `user` for everything else. One of: `user`, `speak` . |
| `isActive` | boolean | `false` once archived. |
| `mergedInto` | string | `labelId` this label was merged into. Present only on merged labels. |
| `sortOrder` | integer | Position among its siblings. |
| `userId` | string | Id of the person who created it. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"groups": [
  {
    "labelId": "Gx7QkP2mVt9LwRz",
    "isGroup": true,
    "name": "Meetings",
    "parentId": null,
    "source": "speak",
    "isActive": true,
    "sortOrder": 0,
    "userId": "65f0c1a2b3c4d5e6f7a8b9c0",
    "createdAt": "2026-10-06T14:05:12.000Z",
    "updatedAt": "2026-10-06T14:05:12.000Z"
  }
],
"createdCount": 5,
"skippedSets": []
  }
}
```

</div>

<div slot="status-400">

**`400` The request failed validation**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The request failed validation), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "\"sets\" must contain at least 1 items",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Create permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Create permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

</ResponsePanel>

</div>

## Labels on a transcript

<h2 id="get-media-media-id-labels">List labels on a transcript</h2>

Returns every labelled passage on the media, ordered by position in the transcript, with the media's current `transcriptRevision`. Anyone who can open the file can read its labels; no labels permission is needed. The media must belong to your company and be one you can open. Owners and members with the folder **Access all** permission can open any file; everyone else can open files in folders assigned to them or their groups, and files they own. Any other media returns 404, the same as a media id that does not exist.

When a transcript is saved, labels and comments on passages are moved to the new text in the background, after the save has answered. Until that finishes, which is normally a moment, a list can return anchors whose `anchor.transcriptRevision` is lower than the top-level `transcriptRevision`. Fetch the list again shortly after a save.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `mediaId` | path | string | Yes | Id of the media file. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |
| `status` | query | string | No | Return only passages with this status. Leave it out for all of them. One of: `active`, `shifted`, `needs_review`. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X GET 'https://api.speakai.co/v1/media/b4994aa1267c/labels?status=needs_review' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000'
```

</CodePanel>

<ResponsePanel id="get-media-media-id-labels" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"A path or query value failed validation"},{"code":"404","description":"The media does not exist or you cannot open it"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.transcriptRevision` | integer | The media's current transcript revision. Send it as `expectedTranscriptRevision` when you label or comment. |
| `data.mediaLabels` | object[] | Labelled passages, ordered by `anchor.startWord`. |

<details>
<summary>`data.mediaLabels` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `mediaLabelId` | string | Id of the labelled passage. |
| `mediaId` | string | Id of the media. |
| `labelIds` | string[] | Labels on the passage. |
| `anchor` | object | Where the passage sits in the transcript. Built by the server from the word range you send. |
| `status` | string | `active`: the same words, possibly at new positions after an edit. `shifted`: most of the words survived an edit near the same time, but the text changed. `needs_review`: the words changed too much to follow; `anchor` then holds a suggested passage on the new transcript and `lastResolved` the last position a person confirmed. One of: `active`, `shifted`, `needs_review` . |
| `lastResolved` | object | Present only while `status` is `needs_review`: the last position a person confirmed. |
| `userId` | string | Id of the person who first labelled the passage. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |

<details>
<summary>`anchor` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `startWord` | integer | Position of the first word. |
| `endWord` | integer | Position of the last word, inclusive. |
| `exact` | string | The quoted words, joined by single spaces. |
| `prefix` | string | Up to 32 characters of transcript text before the quote. |
| `suffix` | string | Up to 32 characters of transcript text after the quote. |
| `startInSec` | number | Start time of the first word, in seconds. |
| `endInSec` | number | End time of the last word, in seconds. |
| `speakerIds` | string[] | Speakers of the quoted words. |
| `transcriptRevision` | integer | The transcript revision these positions refer to. |

</details>

<details>
<summary>`lastResolved` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `startWord` | integer | Position of the first word. |
| `endWord` | integer | Position of the last word, inclusive. |
| `exact` | string | The quoted words, joined by single spaces. |
| `prefix` | string | Up to 32 characters of transcript text before the quote. |
| `suffix` | string | Up to 32 characters of transcript text after the quote. |
| `startInSec` | number | Start time of the first word, in seconds. |
| `endInSec` | number | End time of the last word, in seconds. |
| `speakerIds` | string[] | Speakers of the quoted words. |
| `transcriptRevision` | integer | The transcript revision these positions refer to. |

</details>

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"transcriptRevision": 3,
"mediaLabels": [
  {
    "mediaLabelId": "Ml5RtB7nZc2GyQs",
    "mediaId": "b4994aa1267c",
    "labelIds": [
      "Lb3NcY8sDf4HqPe"
    ],
    "anchor": {
      "startWord": 42,
      "endWord": 49,
      "exact": "we need the pricing sheet by Friday",
      "prefix": "Before we close the quarter, ",
      "suffix": ". Can you send it over?",
      "startInSec": 61.4,
      "endInSec": 64.2,
      "speakerIds": [
        "1"
      ],
      "transcriptRevision": 3
    },
    "status": "active",
    "userId": "65f0c1a2b3c4d5e6f7a8b9c0",
    "createdAt": "2026-10-06T14:05:12.000Z",
    "updatedAt": "2026-10-06T14:05:12.000Z"
  }
]
  }
}
```

</div>

<div slot="status-400">

**`400` A path or query value failed validation**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (A path or query value failed validation), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "\"status\" must be one of [active, shifted, needs_review]",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-404">

**`404` The media does not exist or you cannot open it**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The media does not exist or you cannot open it), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Media not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="post-media-media-id-labels">Label a passage</h2>

Requires the **Labels: Assign** permission (on by default for admins and members). The media must belong to your company and be one you can open. Owners and members with the folder **Access all** permission can open any file; everyone else can open files in folders assigned to them or their groups, and files they own. Any other media returns 404, the same as a media id that does not exist.

#### How to address words

Send the word range and the revision you read it from. Word positions count every word of the transcript in order, starting at 0. Within each sentence, words come from `entities` in order (an entity that holds several words, such as "New York", counts as several), or from the sentence `text` split on whitespace when the sentence has no entities. A token that is only punctuation is not counted. `expectedTranscriptRevision` is the `transcriptRevision` you read the transcript at, from `GET /media/transcript/{mediaId}`, `GET /media/insight/{mediaId}`, or the list endpoints on this page. The server builds the anchor from its own copy of the transcript. If the transcript has been saved since, the request returns 409 "This transcript changed since you opened it. Reload to keep editing." and nothing is written: read the transcript again and recompute the word range.

#### Request rules

- When the same words already carry labels at this revision, the labels you send are added to that passage and the existing passage is returned.
- A transcript holds at most 5,000 labelled passages; past that the request returns 400.

#### Errors

- `400` when a field fails validation, the range runs past the transcript, a label is archived, a group or not in your company, or the passage limit is reached.
- `403` without the permission.
- `404` when the media does not exist or you cannot open it.
- `409` when `expectedTranscriptRevision` is stale.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `mediaId` | path | string | Yes | Id of the media file. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

### Request body

Fields marked **required** are the ones the server rejects the request without. Anything conditional, where a field becomes required only alongside another, is described under Request rules above rather than marked here.

| Field | Type | Description |
| --- | --- | --- |
| `range` | object, **required** | Inclusive range of word positions. Word positions count every word of the transcript in order, starting at 0. Within each sentence, words come from `entities` in order (an entity that holds several words, such as "New York", counts as several), or from the sentence `text` split on whitespace when the sentence has no entities. A token that is only punctuation is not counted. |
| `range.start` | integer, **required** | Position of the first word. Integer, 0 or more. |
| `range.end` | integer, **required** | Position of the last word, inclusive. Integer, at least `start`. A range that runs past the last word returns 400 "The selected words are outside this transcript." |
| `labelIds` | string[], **required** | Labels to put on the passage. 1 to 20 ids, no repeats. Each must be an active label (not a group) in your company, or the request returns 400 "Choose active labels from this workspace (groups cannot be applied)." |
| `expectedTranscriptRevision` | integer, **required** | The `transcriptRevision` you read the transcript at. Integer, 0 or more. A stale value returns 409. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X POST 'https://api.speakai.co/v1/media/b4994aa1267c/labels' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000' \
  -H 'Content-Type: application/json' \
  -d '{
  "range": {
"start": 42,
"end": 49
  },
  "labelIds": [
"Lb3NcY8sDf4HqPe"
  ],
  "expectedTranscriptRevision": 3
}'
```

</CodePanel>

<ResponsePanel id="post-media-media-id-labels" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"Validation, range, label or limit problem"},{"code":"403","description":"You lack the Labels: Assign permission"},{"code":"404","description":"The media does not exist or you cannot open it"},{"code":"409","description":"The transcript changed since you read it"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | Labels on one passage of a transcript. |

<details>
<summary>`data` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `mediaLabelId` | string | Id of the labelled passage. |
| `mediaId` | string | Id of the media. |
| `labelIds` | string[] | Labels on the passage. |
| `anchor` | object | Where the passage sits in the transcript. Built by the server from the word range you send. |
| `status` | string | `active`: the same words, possibly at new positions after an edit. `shifted`: most of the words survived an edit near the same time, but the text changed. `needs_review`: the words changed too much to follow; `anchor` then holds a suggested passage on the new transcript and `lastResolved` the last position a person confirmed. One of: `active`, `shifted`, `needs_review` . |
| `lastResolved` | object | Present only while `status` is `needs_review`: the last position a person confirmed. |
| `userId` | string | Id of the person who first labelled the passage. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |

<details>
<summary>`anchor` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `startWord` | integer | Position of the first word. |
| `endWord` | integer | Position of the last word, inclusive. |
| `exact` | string | The quoted words, joined by single spaces. |
| `prefix` | string | Up to 32 characters of transcript text before the quote. |
| `suffix` | string | Up to 32 characters of transcript text after the quote. |
| `startInSec` | number | Start time of the first word, in seconds. |
| `endInSec` | number | End time of the last word, in seconds. |
| `speakerIds` | string[] | Speakers of the quoted words. |
| `transcriptRevision` | integer | The transcript revision these positions refer to. |

</details>

<details>
<summary>`lastResolved` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `startWord` | integer | Position of the first word. |
| `endWord` | integer | Position of the last word, inclusive. |
| `exact` | string | The quoted words, joined by single spaces. |
| `prefix` | string | Up to 32 characters of transcript text before the quote. |
| `suffix` | string | Up to 32 characters of transcript text after the quote. |
| `startInSec` | number | Start time of the first word, in seconds. |
| `endInSec` | number | End time of the last word, in seconds. |
| `speakerIds` | string[] | Speakers of the quoted words. |
| `transcriptRevision` | integer | The transcript revision these positions refer to. |

</details>

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"mediaLabelId": "Ml5RtB7nZc2GyQs",
"mediaId": "b4994aa1267c",
"labelIds": [
  "Lb3NcY8sDf4HqPe"
],
"anchor": {
  "startWord": 42,
  "endWord": 49,
  "exact": "we need the pricing sheet by Friday",
  "prefix": "Before we close the quarter, ",
  "suffix": ". Can you send it over?",
  "startInSec": 61.4,
  "endInSec": 64.2,
  "speakerIds": [
    "1"
  ],
  "transcriptRevision": 3
},
"status": "active",
"userId": "65f0c1a2b3c4d5e6f7a8b9c0",
"createdAt": "2026-10-06T14:05:12.000Z",
"updatedAt": "2026-10-06T14:05:12.000Z"
  }
}
```

</div>

<div slot="status-400">

**`400` Validation, range, label or limit problem**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (Validation, range, label or limit problem), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "The selected words are outside this transcript.",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Assign permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Assign permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` The media does not exist or you cannot open it**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The media does not exist or you cannot open it), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Media not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

<div slot="status-409">

**`409` The transcript changed since you read it**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The transcript changed since you read it), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 409,
  "message": "This transcript changed since you opened it. Reload to keep editing.",
  "hints": [
"Please use PUT for update instead of POST",
"The requested operation failed because it tried to create a resource that already exists."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="patch-media-media-id-labels-media-label-id">Change, confirm or move a labelled passage</h2>

Requires the **Labels: Assign** permission. Send exactly one of `labelIds` or `action`; both or neither returns 400.

- `labelIds` replaces the passage's labels, with the same rules as when you label a passage.
- `action: "keep"` confirms the passage where it is now: `status` becomes `active` and `lastResolved` is cleared. Use it on a `shifted` passage, or to accept the suggestion on a `needs_review` one. It returns 409 if the passage is still on an older revision, which happens for a moment after a save.
- `action: "replace"` moves the passage to new words. It needs `range` and `expectedTranscriptRevision`, which are not allowed with any other choice. `expectedTranscriptRevision` is the `transcriptRevision` you read the transcript at, from `GET /media/transcript/{mediaId}`, `GET /media/insight/{mediaId}`, or the list endpoints on this page. The server builds the anchor from its own copy of the transcript. If the transcript has been saved since, the request returns 409 "This transcript changed since you opened it. Reload to keep editing." and nothing is written: read the transcript again and recompute the word range.

#### Errors

- `400` when the body breaks the rules above, the range runs past the transcript, or a label cannot be applied.
- `403` without the permission.
- `404` when the media or the labelled passage does not exist.
- `409` when the revision is stale.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `mediaId` | path | string | Yes | Id of the media file. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |
| `mediaLabelId` | path | string | Yes | Id of the labelled passage, from `mediaLabelId`. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

### Request body

Fields marked **required** are the ones the server rejects the request without. Anything conditional, where a field becomes required only alongside another, is described under Request rules above rather than marked here.

| Field | Type | Description |
| --- | --- | --- |
| `labelIds` | string[] | Labels to put on the passage. 1 to 20 ids, no repeats. Each must be an active label (not a group) in your company, or the request returns 400 "Choose active labels from this workspace (groups cannot be applied)." |
| `action` | string | `keep` to confirm the current position, `replace` to move the passage. One of: `keep`, `replace` . |
| `range` | object | Required with `action: "replace"`, not allowed otherwise. Inclusive range of word positions. Word positions count every word of the transcript in order, starting at 0. Within each sentence, words come from `entities` in order (an entity that holds several words, such as "New York", counts as several), or from the sentence `text` split on whitespace when the sentence has no entities. A token that is only punctuation is not counted. |
| `range.start` | integer, **required** | Position of the first word. Integer, 0 or more. |
| `range.end` | integer, **required** | Position of the last word, inclusive. Integer, at least `start`. A range that runs past the last word returns 400 "The selected words are outside this transcript." |
| `expectedTranscriptRevision` | integer | Required with `action: "replace"`, not allowed otherwise. The `transcriptRevision` you read the transcript at. Integer, 0 or more. A stale value returns 409. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X PATCH 'https://api.speakai.co/v1/media/b4994aa1267c/labels/Ml5RtB7nZc2GyQs' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000' \
  -H 'Content-Type: application/json' \
  -d '{
  "labelIds": [
"Lb3NcY8sDf4HqPe",
"Lb9TmK2vXa6JrWo"
  ]
}'
```

</CodePanel>

<ResponsePanel id="patch-media-media-id-labels-media-label-id" statuses={[{"code":"200","description":"Success"},{"code":"400","description":"The body broke a rule or failed validation"},{"code":"403","description":"You lack the Labels: Assign permission"},{"code":"404","description":"The media or the labelled passage does not exist"},{"code":"409","description":"The transcript changed since you read it"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | Labels on one passage of a transcript. |

<details>
<summary>`data` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `mediaLabelId` | string | Id of the labelled passage. |
| `mediaId` | string | Id of the media. |
| `labelIds` | string[] | Labels on the passage. |
| `anchor` | object | Where the passage sits in the transcript. Built by the server from the word range you send. |
| `status` | string | `active`: the same words, possibly at new positions after an edit. `shifted`: most of the words survived an edit near the same time, but the text changed. `needs_review`: the words changed too much to follow; `anchor` then holds a suggested passage on the new transcript and `lastResolved` the last position a person confirmed. One of: `active`, `shifted`, `needs_review` . |
| `lastResolved` | object | Present only while `status` is `needs_review`: the last position a person confirmed. |
| `userId` | string | Id of the person who first labelled the passage. |
| `createdAt` | string (date-time) | When it was created. |
| `updatedAt` | string (date-time) | When it last changed. |

<details>
<summary>`anchor` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `startWord` | integer | Position of the first word. |
| `endWord` | integer | Position of the last word, inclusive. |
| `exact` | string | The quoted words, joined by single spaces. |
| `prefix` | string | Up to 32 characters of transcript text before the quote. |
| `suffix` | string | Up to 32 characters of transcript text after the quote. |
| `startInSec` | number | Start time of the first word, in seconds. |
| `endInSec` | number | End time of the last word, in seconds. |
| `speakerIds` | string[] | Speakers of the quoted words. |
| `transcriptRevision` | integer | The transcript revision these positions refer to. |

</details>

<details>
<summary>`lastResolved` -- 9 more fields</summary>

| Field | Type | Description |
| --- | --- | --- |
| `startWord` | integer | Position of the first word. |
| `endWord` | integer | Position of the last word, inclusive. |
| `exact` | string | The quoted words, joined by single spaces. |
| `prefix` | string | Up to 32 characters of transcript text before the quote. |
| `suffix` | string | Up to 32 characters of transcript text after the quote. |
| `startInSec` | number | Start time of the first word, in seconds. |
| `endInSec` | number | End time of the last word, in seconds. |
| `speakerIds` | string[] | Speakers of the quoted words. |
| `transcriptRevision` | integer | The transcript revision these positions refer to. |

</details>

</details>

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"mediaLabelId": "Ml5RtB7nZc2GyQs",
"mediaId": "b4994aa1267c",
"labelIds": [
  "Lb3NcY8sDf4HqPe",
  "Lb9TmK2vXa6JrWo"
],
"anchor": {
  "startWord": 42,
  "endWord": 49,
  "exact": "we need the pricing sheet by Friday",
  "prefix": "Before we close the quarter, ",
  "suffix": ". Can you send it over?",
  "startInSec": 61.4,
  "endInSec": 64.2,
  "speakerIds": [
    "1"
  ],
  "transcriptRevision": 3
},
"status": "active",
"userId": "65f0c1a2b3c4d5e6f7a8b9c0",
"createdAt": "2026-10-06T14:05:12.000Z",
"updatedAt": "2026-10-06T14:05:12.000Z"
  }
}
```

</div>

<div slot="status-400">

**`400` The body broke a rule or failed validation**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The body broke a rule or failed validation), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 400,
  "message": "\"value\" must contain at least one of [labelIds, action]",
  "hints": [
"The API request is invalid or improperly formed."
  ]
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Assign permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Assign permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` The media or the labelled passage does not exist**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The media or the labelled passage does not exist), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Label on this transcript not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

<div slot="status-409">

**`409` The transcript changed since you read it**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The transcript changed since you read it), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 409,
  "message": "This transcript changed since you opened it. Reload to keep editing.",
  "hints": [
"Please use PUT for update instead of POST",
"The requested operation failed because it tried to create a resource that already exists."
  ]
}
```

</div>

</ResponsePanel>

</div>

<h2 id="delete-media-media-id-labels-media-label-id">Remove a labelled passage</h2>

Requires the **Labels: Assign** permission. Removes the passage and all its labels from the transcript. The labels themselves stay in your list. Comments linked to the passage stay, without the link.

### Parameters

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `mediaId` | path | string | Yes | Id of the media file. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |
| `mediaLabelId` | path | string | Yes | Id of the labelled passage, from `mediaLabelId`. Trimmed, then 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-` (pattern `^[A-Za-z0-9_-]{1,64}$`). Anything else returns 400. |

<div slot="code">

<CodePanel label="Example request">

```bash
curl -X DELETE 'https://api.speakai.co/v1/media/b4994aa1267c/labels/Ml5RtB7nZc2GyQs' \
  -H 'x-speakai-key: sk_test_speak_0000000000000000' \
  -H 'x-access-token: eyJhbGciOiJIUzI1NiJ9.test-access-token.0000000000'
```

</CodePanel>

<ResponsePanel id="delete-media-media-id-labels-media-label-id" statuses={[{"code":"200","description":"Success"},{"code":"403","description":"You lack the Labels: Assign permission"},{"code":"404","description":"The media or the labelled passage does not exist"}]}>

<div slot="status-200">

**`200` Success**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `success` on every 2xx response. One of: `success` . |
| `data` | object | The result. |
| `data.mediaLabelId` | string | Id of the passage you removed. |

Example response (Success), `application/json`.

```json
{
  "status": "success",
  "data": {
"mediaLabelId": "Ml5RtB7nZc2GyQs"
  }
}
```

</div>

<div slot="status-403">

**`403` You lack the Labels: Assign permission**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `message` | string | Why the request was refused. |

Example response (You lack the Labels: Assign permission), `application/json`.

```json
{
  "status": "failed",
  "message": "You do not have permission to perform this action"
}
```

</div>

<div slot="status-404">

**`404` The media or the labelled passage does not exist**

Response body, `application/json`.

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `failed`. One of: `failed` . |
| `requestId` | string | Id of this request, for support. |
| `code` | integer | The HTTP status code again. |
| `message` | string | What went wrong, in words you can show to a person. |
| `hints` | string[] | Generic hints for this kind of error. |

Example response (The media or the labelled passage does not exist), `application/json`.

```json
{
  "status": "failed",
  "requestId": "req_0000000000000000",
  "code": 404,
  "message": "Label on this transcript not found.",
  "hints": [
"The requested operation failed because a resource associated with the request could not be found."
  ]
}
```

</div>

</ResponsePanel>

</div>

## Related pages

- [API reference](/api/) for the base URL, authentication, and the error format.
- [Authenticate with the Speak AI API using access tokens](/api/authentication/)
- [Upload audio and video to Speak AI and read insights](/api/media/)
- [Create and update live transcription sessions](/api/live-transcription/)
- [Analyze text notes with the Speak AI text endpoints](/api/text/)

Get an API key on the [Speak AI developer page](https://app.speakai.co/developers?utm_source=docs&utm_medium=referral&utm_campaign=api-reference&utm_content=api-labels).

Source: https://docs.speakai.co/api/labels/index.mdx
