Comments API
Comments are threaded discussions anchored to a page, block,
database record, or a text selection inside a block. They support
replies, resolve/reopen, emoji reactions, and mentions
(@user, @team, @page, @database) which fan out notifications.
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
Permission note: commenting requires at least COMMENT on the anchored
resource (pages inherit it from the workspace/team/parent chain). Editing
or deleting requires the comment to be yours (or you hold EDIT+ on the
resource to remove others' comments). Resolving/reopening is allowed for the
author and anyone with EDIT on the resource.
Comment object (used by all endpoints):
{
"id": "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"teamId": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"blockId": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"recordId": null,
"selection": null,
"body": "Can we move the GA date to May? @Ada @Engineering",
"mentions": [
{ "type": "user", "id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A", "name": "Ada Lovelace" },
{ "type": "team", "id": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1", "name": "Engineering" }
],
"pageMentions": [
{ "type": "page", "id": "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8", "title": "Q2 Launch Checklist" }
],
"status": "open",
"resolvedAt": null,
"resolvedBy": null,
"replyCount": 2,
"reactions": [
{ "emoji": "👍", "count": 2, "userIds": ["usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9", "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0"] },
{ "emoji": "🔥", "count": 1, "userIds": ["usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A"] }
],
"author": { "id": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9", "name": "Charles Babbage", "avatarUrl": null },
"createdAt": "2025-01-02T10:15:00.000Z",
"updatedAt": "2025-01-02T10:42:00.000Z"
}
body is plain text with inline mention tokens; the server parses them into
mentions / pageMentions on write (see §8 Mentions).
1. Anchor Types
One of pageId, blockId, recordId must be present; a selection
may be added on top of pageId/blockId.
| Anchor | Fields | Notes |
|---|---|---|
| Page | pageId |
Comment on the whole page (page footer / sidebar). |
| Block | pageId + blockId |
Hover/inline comment on a block. pageId is required for validation (a block id alone is accepted and resolved, but pageId is cheaper to send). |
| Text selection | pageId + blockId + selection |
selection: { "startOffset": 6, "endOffset": 12, "quotedText": "world" } — offsets into the block's content.text at comment-creation time. If the block text changes so the offsets no longer match, the selection is kept (best-effort) and the UI re-anchors to the nearest text. |
| Record | pageId + recordId |
Comment on a database record. pageId is the record's 1:1 page (sent so the comment also appears in the page's comment list). recordId alone is accepted and the page is resolved. |
teamId is optional and defaults to the team of the anchored page (for
scoping mentions/notifications).
2. Create a Comment
POST /comments
- Auth: JWT (API keys may create comments with scope
WRITE; the author is recorded as the key's creator user).
Request:
{
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"blockId": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"selection": { "startOffset": 20, "endOffset": 31, "quotedText": "2025-04-30" },
"body": "Can we move the GA date to May? @Ada @Engineering",
"mentions": [
{ "type": "user", "id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A" },
{ "type": "team", "id": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1" }
]
}
| Field | Required | Notes |
|---|---|---|
| Anchor fields | one of the anchors (§1) | |
body |
yes (1–5000 chars) | |
mentions |
no | Explicit mention objects; if omitted the server parses @… tokens from body. Explicit entries win over parsing. |
teamId |
no |
Mention validation: unknown user/team ids are dropped from mentions (the
raw @text stays in body) — a comment never fails because of a stale
mention. Mentioning a page/database uses { "type": "page"|"database", "id" }.
201 Created — the full comment object (as in the top of this doc), with
reactions: [] and replyCount: 0.
Errors: 400 VALIDATION_ERROR (no anchor, empty body), 401 UNAUTHORIZED, 403 FORBIDDEN (no COMMENT on the resource), 404 NOT_FOUND (page/block/record invisible), 429 RATE_LIMITED (120 comments /
hour / user).
Events emitted: comment.created (payload includes the resolved
mentions — the notification worker uses it to fan out
MENTION/COMMENT notifications).
3. List Comments
GET /pages/:pageId/comments
All comment threads on a page (top-level comments; replies are embedded
via ?include=replies or fetched per thread). Also returns record comments
whose record page belongs to this page id (1:1 mapping).
- Auth: JWT or API key (scope
READ).VIEWon the page.
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
blockId |
string | – | Only comments anchored to this block. |
recordId |
string | – | Only comments anchored to this record. |
status |
open | resolved | all |
all |
|
include |
list | reactions |
replies embeds each thread's replies (ordered ascending). |
cursor, limit |
– | 20 / max 100 | Ordered by createdAt DESC (newest threads first). |
200 OK
{
"data": [
{
"id": "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"blockId": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"body": "Can we move the GA date to May? @Ada @Engineering",
"status": "open",
"replyCount": 2,
"reactions": [ { "emoji": "👍", "count": 2, "userIds": ["usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9"] } ],
"author": { "id": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9", "name": "Charles Babbage" },
"createdAt": "2025-01-02T10:15:00.000Z",
"replies": [
{
"id": "crr_01J9ZKQB2C4D6E8F0A2B4C6D8",
"body": "May works for infra — let's target May 15.",
"author": { "id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A", "name": "Ada Lovelace" },
"createdAt": "2025-01-02T10:30:00.000Z",
"updatedAt": "2025-01-02T10:30:00.000Z"
},
{
"id": "crr_01J9ZKQC3D5E7F9A1B3C5D7E9",
"body": "Marketing is fine with that too.",
"author": { "id": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "name": "Augusta Ada" },
"createdAt": "2025-01-02T10:42:00.000Z",
"updatedAt": "2025-01-02T10:42:00.000Z"
}
]
}
],
"nextCursor": null
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
GET /comments/:commentId
One thread with its replies and reactions.
- Auth: JWT or API key (scope
READ).VIEW.
200 OK — comment object with replies: [ … ] (ascending) embedded.
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
4. Edit
PATCH /comments/:commentId
- Auth: JWT. Only the author may edit their comment (or workspace
Admin/Owner). API keys:
403 FORBIDDEN(details: [{reason: "requires_user_credentials"}]).
Request:
{ "body": "Can we move the GA date to May 15? @Ada @Engineering" }
Re-parses mentions on save (old mention notifications are not re-sent
unless a mention is newly added — newly mentioned users get a
MENTION notification).
200 OK — updated comment object (updatedAt bumped; mentions refreshed).
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN
(not author), 404 NOT_FOUND.
Events emitted: comment.updated (+ comment.mentioned per newly
added mention).
5. Replies
POST /comments/:commentId/replies
- Auth: JWT or API key (scope
WRITE).COMMENTon the resource. - Replies can be made on resolved threads; the thread stays resolved (it does not auto-reopen — see §7).
Request:
{
"body": "Agreed — updating the plan page.",
"mentions": [ { "type": "user", "id": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9" } ]
}
201 Created — the reply object:
{
"data": {
"id": "crr_01J9ZKQC3D5E7F9A1B3C5D7E9",
"commentId": "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7",
"body": "Agreed — updating the plan page.",
"mentions": [ { "type": "user", "id": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9", "name": "Charles Babbage" } ],
"author": { "id": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "name": "Augusta Ada" },
"createdAt": "2025-01-02T10:42:00.000Z",
"updatedAt": "2025-01-02T10:42:00.000Z"
}
}
Errors: 400 VALIDATION_ERROR, 403 FORBIDDEN, 404 NOT_FOUND,
429 RATE_LIMITED.
Events emitted: comment.reply_created (notifications: thread author +
mentions).
DELETE /comments/:commentId/replies/:replyId
- Auth: JWT (author of the reply, or
EDITon resource).
204 No Content
Events emitted: comment.reply_deleted.
6. Delete a Thread
DELETE /comments/:commentId
Deletes the thread and all its replies.
- Auth: JWT (author of the thread, or
EDITon the resource, or workspace Admin/Owner). API keys:ADMINscope only.
204 No Content
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
Events emitted: comment.deleted.
7. Resolve & Reopen
POST /comments/:commentId/resolve
- Auth: JWT (thread author, or
EDITon resource).
200 OK
{
"data": {
"id": "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7",
"status": "resolved",
"resolvedAt": "2025-01-03T09:00:00.000Z",
"resolvedBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A"
}
}
Resolving is idempotent (resolving an already-resolved thread returns the same object).
POST /comments/:commentId/reopen
Same auth; sets status: "open", resolvedAt: null.
200 OK — comment object with status: "open".
Errors (both): 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
Events emitted: comment.resolved / comment.reopened.
8. Mentions & Notifications
Mentions are written as @Token in body or as explicit objects in
mentions. Server-side resolution:
| Token pattern | Resolution |
|---|---|
@Full Name / @first last |
Match against workspace members' name and displayName (case-insensitive, first token). On a match → mentionType: "user". |
@Team Name |
Match against workspace teams. |
@page / @database |
Only via explicit objects `{ "type": "page" |
Rules:
- A mention may target a user who is not a workspace member (they are still notified — the notification is workspace-scoped but delivered to their personal inbox).
- Team mentions notify every member of the team (except the author).
- Dedup: the author never receives notifications for their own comment; multiple mentions of the same user collapse to one notification.
- Notification types produced:
MENTION(any mention),COMMENT(a new reply in a thread you previously participated in, or on a resource you follow). Channels: in-app always; email per the user's notification preferences. - Notification deep link:
link: { "type": "comment", "id": "cmt_…" }— opens the page with the thread scrolled into view.
Example notification fan-out for the comment in §2 (author Charles mentions Ada and the Engineering team of 4, of which Ada is also a member):
Ada → MENTION (deduped against team mention)
Other Eng ×3 → MENTION (team)
No COMMENT notification (no prior participants)
9. Reactions
Emoji reactions on threads and replies. One reaction per (user, emoji, comment/reply) — re-adding is idempotent (returns the existing aggregate).
POST /comments/:commentId/reactions
- Auth: JWT or API key (scope
WRITE).COMMENTon the resource.
Request:
{ "emoji": "👍" }
| Field | Notes |
|---|---|
emoji |
Any single emoji (1–4 UTF-16 code units). Validated against a built-in allowed list of 100 common emojis; unknown → 400 VALIDATION_ERROR (details: [{field: "emoji", issue: "not_supported"}]). |
replyId |
Optional — react to a reply instead of the thread. |
200 OK
{
"data": {
"commentId": "cmt_01J9ZKQA1B3C5D7E9F1A3B5C7",
"emoji": "👍",
"count": 3,
"userIds": ["usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9", "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A"]
}
}
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN,
404 NOT_FOUND.
Events emitted: comment.reaction_added.
DELETE /comments/:commentId/reactions
- Auth: JWT (the reacting user).
Query: ?emoji=👍 (URL-encoded; required). Optional &replyId=….
200 OK — the updated aggregate for that emoji (with the user removed), or
204 No Content when the last reactor removes it.
Events emitted: comment.reaction_removed.
10. curl Examples
# Comment on a block with a text selection
curl -s -X POST https://factory.example.com/api/v1/comments \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"blockId": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"selection": { "startOffset": 20, "endOffset": 31, "quotedText": "2025-04-30" },
"body": "Can we move the GA date to May? @Ada @Engineering"
}'
# List page threads with replies
curl -s "https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9/comments?status=open&include=replies" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Reply to a thread
curl -s -X POST https://factory.example.com/api/v1/comments/cmt_01J9ZKQA1B3C5D7E9F1A3B5C7/replies \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"body": "Agreed — updating the plan page."}'
# Resolve
curl -s -X POST https://factory.example.com/api/v1/comments/cmt_01J9ZKQA1B3C5D7E9F1A3B5C7/resolve \
-H "Authorization: Bearer $ACCESS_TOKEN"
# React
curl -s -X POST https://factory.example.com/api/v1/comments/cmt_01J9ZKQA1B3C5D7E9F1A3B5C7/reactions \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"emoji": "👍"}'
# Edit own comment
curl -s -X PATCH https://factory.example.com/api/v1/comments/cmt_01J9ZKQA1B3C5D7E9F1A3B5C7 \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"body": "Can we move the GA date to May 15? @Ada @Engineering"}'
11. Events Summary
| Operation | Domain event(s) |
|---|---|
| Create comment | comment.created (+ comment.mentioned per resolved mention) |
| Edit comment | comment.updated (+ comment.mentioned for new mentions) |
| Create reply | comment.reply_created |
| Delete reply | comment.reply_deleted |
| Delete thread | comment.deleted |
| Resolve / reopen | comment.resolved / comment.reopened |
| Reaction add / remove | comment.reaction_added / comment.reaction_removed |
All comment events carry workspaceId, the anchor (pageId/blockId/
recordId), authorId, and the resolved mentions list — the same payload
is delivered to webhook subscribers of comment.created.