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

# Comments

> Read, import, and update AI and human review comments on a promotion version.

Comments are the feedback left on a promotion version, both Adclear AI findings and human review notes. Use the read endpoint to pull feedback into your own workflow, and the import endpoint to push external review comments back into Adclear.

## Get comments

<CodeGroup>
  ```bash curl theme={null}
  curl -s "$API_BASE/v1/promotions/$PROMOTION_ID/versions/$VERSION_ID/comments?limit=20&offset=0" \
    -H "Authorization: Bearer $ADCLEAR_API_KEY" \
    -H "X-External-User-ID: $USER_ID"
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    `${API_BASE}/v1/promotions/${promotionId}/versions/${versionId}/comments?limit=20&offset=0`,
    { headers },
  );
  const { threads, pagination } = await res.json();
  ```

  ```python Python theme={null}
  res = requests.get(
      f"{API_BASE}/v1/promotions/{promotion_id}/versions/{version_id}/comments",
      headers=headers,
      params={"limit": 20, "offset": 0},
  )
  threads = res.json()["threads"]
  ```
</CodeGroup>

**Response (200 OK):**

```json theme={null}
{
  "threads": [
    {
      "id": "thread-uuid-...",
      "createdAt": "2026-08-10T12:00:00.000Z",
      "comment": {
        "id": "comment-uuid-...",
        "author": { "type": "ai", "userId": "adclear-ai", "name": "Adclear AI" },
        "content": "This claim needs a risk disclaimer.",
        "createdAt": "2026-08-10T12:00:00.000Z",
        "editedAt": null
      },
      "replies": [
        {
          "id": "comment-uuid-2",
          "author": { "type": "reviewer", "userId": "user-...", "name": "Jane Doe" },
          "content": "Agreed, disclaimer added in the next version.",
          "createdAt": "2026-08-10T12:05:00.000Z",
          "editedAt": null
        }
      ]
    }
  ],
  "pagination": { "limit": 20, "offset": 0, "totalThreads": 1, "hasMore": false },
  "correlationId": "corr-uuid-..."
}
```

Each thread has one top-level `comment` and its `replies` in chronological order. Threads are ordered oldest first. The `promotionId` and `versionId` in the path must belong to your workspace, or the request returns `404`.

## Query parameters

| Parameter | Default | Description |
| - | - | - |
| `limit` | `20` | Maximum threads to return, 1 to 100 |
| `offset` | `0` | Number of threads to skip |

Page through with `offset`. Keep requesting until `pagination.hasMore` is `false`.

## Comment fields

| Field | Description |
| - | - |
| `id` | Comment identifier |
| `author` | Who wrote it (see below) |
| `content` | The comment text |
| `createdAt` | When it was posted (ISO 8601) |
| `editedAt` | When it was last edited, or `null` if never edited |

### Author

| Field | Description |
| - | - |
| `type` | `ai` for Adclear AI, `reviewer` for a human with review permission, `author` for the person who submitted the promotion |
| `userId` | Author identifier. Always `"adclear-ai"` for AI comments |
| `name` | Display name, or `null` if the user has not been synced yet. Fall back to `userId` when `null` |
| `externalAuthor` | Only on comments imported for someone outside your organization: the `email` and `externalUserId` you sent, and `sourceLabel` (your integration's name). `type` follows the role recorded for them |

## Pagination fields

| Field | Description |
| - | - |
| `limit` | Maximum threads returned in this page |
| `offset` | Threads skipped before this page |
| `totalThreads` | Total threads on this version |
| `hasMore` | `true` when more threads exist after this page |

For cross-cutting errors (401, 403, 429, 502), see [Errors & rate limits](/developer/errors).

## Import comments

Use this endpoint to push external review feedback (roots, replies, and action responses) onto the **current latest version** of a promotion. Each item is processed independently: one failure does not roll back the rest.

Send `externalCommentId` on every item if you need safe **sequential** retries. If you omit it, a retry after an ambiguous failure may create duplicates. Two simultaneous duplicate requests can still race.

Imported comments may not appear in [Get comments](#get-comments) immediately. Wait briefly before using GET to resolve `parentCommentId`.

<CodeGroup>
  ```bash curl theme={null}
  curl -s -X POST "$API_BASE/v1/promotions/$PROMOTION_ID/versions/$VERSION_ID/comments" \
    -H "Authorization: Bearer $ADCLEAR_API_KEY" \
    -H "Content-Type: application/json" \
    -H "X-External-User-ID: $USER_ID" \
    -d '{
      "comments": [
        {
          "externalCommentId": "wf-100",
          "author": { "email": "reviewer@brand.com" },
          "content": "Please add the required disclaimer.",
          "uploadId": "00000000-0000-4000-8000-000000000099"
        }
      ]
    }'
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    `${API_BASE}/v1/promotions/${promotionId}/versions/${versionId}/comments`,
    {
      method: 'POST',
      headers: { ...headers, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        comments: [
          {
            externalCommentId: 'wf-100',
            author: { email: 'reviewer@brand.com' },
            content: 'Please add the required disclaimer.',
            uploadId: '00000000-0000-4000-8000-000000000099',
          },
        ],
      }),
    },
  );
  const { results, summary } = await res.json();
  ```
</CodeGroup>

**Response (200 OK, partial success):**

```json theme={null}
{
  "results": [
    {
      "index": 0,
      "externalCommentId": "wf-100",
      "status": "created",
      "threadId": "th_...",
      "commentId": "cm_...",
      "authorResolution": "member"
    }
  ],
  "summary": { "created": 1, "skipped": 0, "rejected": 0 },
  "correlationId": "corr-uuid-..."
}
```

### Author identity

Each author needs `author.email`, `author.externalUserId`, or both.

We try the email first. If it matches an active member of your organization, the comment is theirs and uses their real Adclear permissions. Members ignore `displayName` and `role`.

Attributing a comment or action to a member is acting on their behalf, so it needs a key with [act-on-behalf enabled](/developer/authentication#acting-on-behalf-of-adclear-users). An ordinary key can only attribute to its own user. On an ordinary key, an item whose email matches any other member is imported as an external author instead (`authorResolution: "external"`). It shows in Adclear as "Name · Integration" and never gets that member's permissions. [Update comments](#update-comments) has no external authors, so there the item is rejected with `AUTHOR_NOT_PERMITTED`.

If the email matches no member, or you send only `externalUserId`, the comment is imported as an external author. Adclear records the person once per organization, with the `email` and `externalUserId` you sent, their `displayName`, and their role. They show in Adclear as "Name · Integration", for example "Jane Doe · Workfront". The same email always maps to the same person. Without an email, the same `externalUserId` does. `displayName` falls back to the email, then `externalUserId`.

Set `author.role` to `reviewer` or `author` (default `author`). An external `reviewer` counts as compliance review only on keys enabled for external reviewers. Ask your Adclear contact to enable this. On other keys every external author is treated as an `author`. Your integration declares the role and Adclear does not verify it. The latest role you send for a person is the one Adclear keeps.

`sourceUserId` still works as an older name for `externalUserId`. If you send both, `externalUserId` wins. `sourceRole` is logged but never stored or used for permissions.

Each result includes `authorResolution`: `member` or `external`.

### Item types

| Kind | Required fields | Notes |
| - | - | - |
| Root comment | `content`, `author.email` or `author.externalUserId` | On multi-upload versions, also send `uploadId`. Optional `location` anchors the thread. |
| Ordinary reply | `content`, one parent field | Use `parentCommentId` for an existing Adclear comment, or `parentExternalCommentId` for a root imported earlier (including later in the same batch). Replies must not send `uploadId` or `location`. |
| Action response | `action`, one parent field | Targets an Adclear AI thread. Canned actions may omit `content`; `action: "reply"` requires `content`. |

Supported `action` values: `agree_to_action`, `agree_will_action`, `dismiss`, `dispute`, `relevant_not_required`, `reply`.

To reply to an AI evaluation finding, use the `commentId` that each comment-backed issue carries in the [`evaluation-completed` webhook](/developer/webhooks#reply-to-an-issue) as `parentCommentId`. Comment indexing is asynchronous, so a reply sent immediately after the webhook may be rejected with `PARENT_NOT_FOUND`; wait briefly and retry.

### Location (roots only)

`location` must already be collapsed to a single point, timestamp, or exact text snippet. The server does not accept rectangles or time ranges, so collapse them before sending:

* **A region or bounding box** → its **centre point**, as `xRatio`/`yRatio`.
* **A time range** → its **start**, as `timeSeconds`.

Omit `location` entirely to create an unanchored thread, which appears in the comment sidebar rather than on the asset.

| `location.type` | Fields | Upload format |
| - | - | - |
| `image` | `xRatio`, `yRatio` (0–1) | Image |
| `pdf` | `pageNumber` (1-based), `xRatio`, `yRatio` | PDF |
| `timecode` | `timeSeconds` | Video or audio |
| `text` | `textToFind` | Text |

### Per-item result codes

| Code | Meaning |
| - | - |
| `PARENT_NOT_FOUND` | Parent id unresolved or belongs to another version |
| `UPLOAD_NOT_FOUND` | `uploadId` is not attached to this version |
| `LOCATION_INVALID` | Location shape or values invalid for the upload format |
| `ACTION_TARGET_INVALID` | Action response aimed at a non-AI thread |
| `DEDUP_CAPACITY_EXCEEDED` | Retry metadata capacity reached on the target thread |
| `INVALID_ITEM` | Shape violation for this item only |
| `WRITE_FAILED` | Persist failed for this item; other items may still succeed |

Whole-request errors use the standard envelope: `404` when the promotion or version is not found, `409` when the version is not the latest, and `502` when author identity lookup is temporarily unavailable (no items are written).

## Update comments

Use this endpoint to change an existing comment when its body is edited or its action changes in your integration. Send the new `content`, a new `action`, or both. Fields you omit are left unchanged. Each item is processed independently, and updates keep the comment's identity and thread. They never create a duplicate or a reply.

An update becomes visible in [Get comments](#get-comments) after a short sync delay, with `editedAt` set on the changed comment.

<CodeGroup>
  ```bash curl theme={null}
  curl -s -X PATCH "$API_BASE/v1/promotions/$PROMOTION_ID/versions/$VERSION_ID/comments" \
    -H "Authorization: Bearer $ADCLEAR_API_KEY" \
    -H "Content-Type: application/json" \
    -H "X-External-User-ID: $USER_ID" \
    -d '{
      "comments": [
        { "externalCommentId": "wf-100", "content": "Updated: add the risk disclaimer." },
        {
          "commentId": "cm_reply_123",
          "action": "dismiss",
          "author": { "email": "reviewer@brand.com" }
        }
      ]
    }'
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    `${API_BASE}/v1/promotions/${promotionId}/versions/${versionId}/comments`,
    {
      method: 'PATCH',
      headers: { ...headers, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        comments: [
          { externalCommentId: 'wf-100', content: 'Updated: add the risk disclaimer.' },
          {
            commentId: 'cm_reply_123',
            action: 'dismiss',
            author: { email: 'reviewer@brand.com' },
          },
        ],
      }),
    },
  );
  const { results, summary } = await res.json();
  ```
</CodeGroup>

**Response (200 OK, partial success):**

```json theme={null}
{
  "results": [
    { "index": 0, "commentId": "cm_root_1", "externalCommentId": "wf-100", "status": "updated", "threadId": "th_..." },
    { "index": 1, "commentId": "cm_reply_123", "externalCommentId": null, "status": "updated", "threadId": "th_..." }
  ],
  "summary": { "updated": 2, "skipped": 0, "rejected": 0 },
  "correlationId": "corr-uuid-..."
}
```

An item whose requested state already matches the current comment returns `status: "skipped"` with `reason: "not_modified"`, and nothing is written for it.

### Identify the comment

Give each item one identifier. When both are present, `commentId` is used.

| Identifier | Resolves |
| - | - |
| `commentId` | Any comment (root or reply), using the id returned by import or [Get comments](#get-comments) |
| `externalCommentId` | Your own id. A root resolves as soon as its import returns. A reply becomes resolvable after the short sync delay, so use `commentId` to update a reply straight away |

`externalCommentId` is not guaranteed unique within a version. If one matches more than one comment, the item is rejected with `COMMENT_AMBIGUOUS`. Target it by `commentId` instead.

### What you can change

| Field | Effect |
| - | - |
| `content` | Replaces the comment body. With `action`, it is appended to the canned decision text. On its own, it replaces the whole body |
| `action` | Sets or changes the action response. The target must be a reply on an Adclear AI thread, and `author.email` is required. Supported values match [Import comments](#item-types). `action: "reply"` requires `content` |

Changing an action does not clear an existing content. Resend `content` alongside the action to keep it. Clearing an action back to none is not supported in this version.

### Per-item result codes

| Code | Meaning |
| - | - |
| `COMMENT_NOT_FOUND` | No comment on this version matches the identifier |
| `COMMENT_AMBIGUOUS` | The `externalCommentId` matches more than one comment; target it by `commentId` |
| `ACTION_TARGET_INVALID` | Action aimed at a comment that is not a reply on an Adclear AI thread |
| `AUTHOR_UNAVAILABLE` | Author email unknown, deleted, or not a member of this org |
| `AUTHOR_NOT_PERMITTED` | Author email matches another member of your organization, and the key cannot act on behalf of users |
| `INVALID_ITEM` | Shape violation for this item only (for example, an action without `author`) |
| `WRITE_FAILED` | Persist failed for this item; other items may still succeed |

Whole-request errors use the standard envelope: `404` when the promotion or version is not found, and `502` when author identity lookup is temporarily unavailable (no items are written).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.