Comments API
Inline comment threads on posts — create, list, reply, resolve/unresolve,
and delete. All routes live under /api/posts/{id}/comments..., require a
Cognito JWT, and use snake_case request bodies (per the
API-wide convention).
For the feature-level model (fields, lifecycle, real-time sync,
notifications), see Post Comments & Threads. This
page is the endpoint-by-endpoint wire contract; the authoritative spec is
specs/api/comments.md in the repo.
Route summary
| Method | Path | Description |
|---|---|---|
POST | /api/posts/{id}/comments | Add a comment |
GET | /api/posts/{id}/comments?workspaceId= | List comments |
DELETE | /api/posts/{id}/comments/{comment_id}?workspaceId= | Delete a comment |
POST | /api/posts/{id}/comments/{comment_id}/resolve | Resolve / unresolve |
POST | /api/posts/{id}/comments/{comment_id}/replies | Add a reply |
DELETE | /api/posts/{id}/comments/{comment_id}/replies/{reply_id}?workspaceId= | Delete a reply |
Authorization
Every route checks, in order:
- Workspace membership — the caller must belong to
workspace_id(body field onPOSTs,workspaceIdquery param onGET/DELETE), or every route returns403 Not a workspace member. - Post existence —
post_idmust exist inside that workspace, or404 Post not found.
Delete routes add a third check:
- Author-or-privileged — you must be the comment's/reply's author
(
created_by), or "privileged": your platform role (JWTcustom:role) or your workspace membership role isadmin/owner. Otherwise403 Not authorized to delete this comment(or... this reply).
Resolve, add-reply, and list do not apply the author-or-privileged check — any workspace member can resolve someone else's comment or reply to any thread. Only delete is restricted.
Add Comment
POST /api/posts/{id}/commentsBody
{
"workspace_id": "ws-1",
"body": "Can we punch up this opening line?",
"mark_id": "c1a2b3c4-...",
"text_excerpt": "the hook needs work"
}mark_id and text_excerpt are optional — omit both for a page-level
comment with no anchored highlight.
Response 201
{
"comment_id": "3f9e...",
"post_id": "post-abc",
"workspace_id": "ws-1",
"mark_id": "c1a2b3c4-...",
"text_excerpt": "the hook needs work",
"body": "Can we punch up this opening line?",
"created_by": "user-111",
"created_by_name": "Alice",
"created_at": "2026-07-01T09:00:00+00:00",
"updated_at": "2026-07-01T09:00:00+00:00",
"resolved_at": null,
"resolved_by": null,
"replies": []
}| Status | Meaning |
|---|---|
201 | Created |
400 | workspace_id or body missing/blank |
403 | Not a workspace member |
404 | Post not found |
Triggers a WebSocket broadcast (action: "created") and, if the post has
an author with a known email, a post_comment_added Slack DM to them.
List Comments
GET /api/posts/{id}/comments?workspaceId=ws-1Returns all comments (resolved and unresolved) in insertion order — no server-side filtering or pagination. The frontend's "Show resolved" toggle filters client-side.
Response 200
{ "items": [ /* comment objects, same shape as Add Comment's response */ ], "count": 2 }| Status | Meaning |
|---|---|
200 | OK, count: 0 / items: [] if none exist |
400 | workspaceId query param missing/blank |
403 | Not a workspace member |
404 | Post not found |
Delete Comment
DELETE /api/posts/{id}/comments/{comment_id}?workspaceId=ws-1Deletes the comment and all of its replies in one operation.
Response 200
{ "deleted": true }| Status | Meaning |
|---|---|
200 | Deleted |
400 | workspaceId query param missing/blank |
403 | Not a workspace member |
403 | Not the comment's author and not privileged |
404 | Post not found |
404 | Comment not found |
Triggers a WebSocket broadcast with action: "deleted".
Resolve Comment
POST /api/posts/{id}/comments/{comment_id}/resolveThis route toggles state — it resolves an open comment or reopens a
resolved one, depending on the comment's current resolved_at. There is no
separate unresolve route and no body field to pick a direction.
Body
{ "workspace_id": "ws-1" }Response 200 — the updated comment. When resolving:
resolved_at becomes the current timestamp, resolved_by becomes your
user id. When unresolving, both become null.
| Status | Meaning |
|---|---|
200 | Toggled, returns updated comment |
400 | workspace_id missing/blank |
403 | Not a workspace member |
404 | Post or comment not found |
Triggers a WebSocket broadcast with action: "resolved" or "unresolved".
No Slack notification is sent for this action.
Add Reply
POST /api/posts/{id}/comments/{comment_id}/repliesBody
{ "workspace_id": "ws-1", "body": "Agreed, tightening now." }Response 200 — the full comment object with the new reply appended
to replies:
{
"...": "comment fields",
"replies": [
{
"reply_id": "8b1c...",
"body": "Agreed, tightening now.",
"created_by": "user-222",
"created_by_name": "Bob",
"created_at": "2026-07-01T09:05:00+00:00"
}
]
}| Status | Meaning |
|---|---|
200 | Reply added, returns updated comment |
400 | workspace_id or body missing/blank |
403 | Not a workspace member |
404 | Post or comment not found |
Triggers a WebSocket broadcast with action: "reply_added". If the
replier isn't the comment's original author, sends that author a
post_comment_reply Slack DM (no self-notification when replying to your
own comment).
Delete Reply
DELETE /api/posts/{id}/comments/{comment_id}/replies/{reply_id}?workspaceId=ws-1Removes one reply, leaving the parent comment and any other replies intact.
Response 200
{ "deleted": true }| Status | Meaning |
|---|---|
200 | Deleted |
400 | workspaceId query param missing/blank |
403 | Not a workspace member |
403 | Not the reply's author and not privileged |
404 | Post not found |
404 | Comment not found |
404 | Reply not found |
Triggers a WebSocket broadcast with action: "reply_deleted".
Real-time delivery
Every mutating route above broadcasts a lightweight event over the same connections table used by the collaboration WebSocket API:
{ "type": "comment", "action": "created", "postId": "post-abc", "commentId": "3f9e..." }The payload carries no comment content — clients re-fetch
GET /api/posts/{id}/comments on receipt. Delivery is best-effort: if the
Lambda's WS_API_ENDPOINT / CONNECTIONS_TABLE_NAME env vars aren't set,
or a connection is stale, the broadcast is silently skipped for that
connection without affecting the HTTP response.