Comments
Per-endpoint reference for the Comments resource. Comments attach to a single owning entity — a task or a page — and are returned in chronological order. The resource supports flat top-level comments plus a single level of threaded replies (no deeper nesting). Each section below shows the exact path, every parameter, a full request example, and the response shape with a worked example.
Quickstart
Base URL
https://api.tasklife.com/v1/
Auth (all endpoints)
Every request must include a bearer token in the Authorization header. The token is an API key bound to a specific organization — comments are always scoped to the org that owns the comment's parent entity, not the org on the API key (a task's org is what counts, even if the key was issued in a partner org).
Authorization: Bearer ***
Content-Type: application/json
Response envelope
All successful responses share a common envelope. Error responses are described in Errors below.
{
"success": true,
"data": { /* endpoint-specific payload */ }
}
Note: list responses nest the array under data.comments[] with data.entity_type, data.entity_id, and data.parent_comment_id echoed alongside it. Single-comment responses (create / get) nest the comment under data.comment.<field>, with the freshly-assigned id also exposed at data.comment_id. Update and delete return only a data.message string — not the comment itself. Each endpoint below shows the actual response shape — trust that.
Errors
Failed responses always have success: false and an error object with a stable code and a human-readable message:
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Comment not found"
}
}
The PATCH range-edit endpoint returns a richer error envelope with conflict details:
{
"success": false,
"error": {
"code": "VERSION_MISMATCH",
"message": "Base version does not match current version",
"currentVersion": 4,
"currentLines": [ { "n": 1, "start": 0, "end": 23, "hash": "h_abc12345" } ]
}
}
| HTTP status | When | What to do |
|---|---|---|
400 | Malformed JSON body, or a PUT/PATCH /v1/comments/{id} call whose body_markdown is empty / not a string. | Read error.message; fix the offending field. |
401 | Missing or invalid Authorization header. | Check the API key; confirm the header is present. |
403 | API key valid but the user is neither the comment author nor an org admin actor (for update/delete), or the user isn't an active member of the entity's owning org (for create). | Confirm the key's user is permitted on the entity's org. |
404 | Comment, task, or page doesn't exist; the entity isn't in this org; the comment is soft-deleted; or the parent comment (for replies) doesn't exist. | Verify the id and that nothing was deleted. |
409 | Conflict on the PATCH /v1/comments/{id} range-edit path — VERSION_MISMATCH or HASH_MISMATCH. The currentVersion and currentLines are echoed back. | Re-fetch the comment with GET /v1/comments/{id} and retry with the latest version and lines[].hash values. |
422 | Validation error — missing entity_type / entity_id / body_markdown; unsupported entity_type; comment over 10 000 chars; reply on a reply (Nested replies are not allowed); invalid patch range (INVALID_RANGE) / unknown op type (INVALID_OPERATION); mention span out of bounds; etc. | Read error.message; fix the offending field. |
500 | Unexpected server error (DB failure, etc.). | Retry with backoff; report if persistent. |
Comments
Comments are short Markdown notes pinned to a single owning entity (a task or a page). Each comment has an author, a Markdown body, an optional parent_comment_id for one-level threading, optional media attachments, and a list of mention entities. The comment object also surfaces can_edit / can_delete flags computed against the calling user so clients can show/hide UI affordances without a second round-trip.
⚠️ Comments-specific gotchas
entity_typeonly acceptstaskorpage— enforced byCommentsService::assertValidEntity(). Any other value (e.g.project,user) returns422 — Invalid entity reference. There is no nested-route alternative:POST /v1/tasks/{id}/commentsandPOST /v1/pages/{id}/commentsare not wired in the v1 router — always usePOST /v1/commentswithentity_type+entity_idin the body.- PUT and PATCH on
/v1/comments/{id}behave differently depending on the payload shape — the router inspects the JSON body and forks:- If the body contains
baseVersionandops[], it routes toCommentsService::patchComment(), which performs conflict-safe line-range edits (replace_lines/insert_after_line/delete_linesops) with 409VERSION_MISMATCH/HASH_MISMATCHenvelopes. - Otherwise it falls through to
CommentsService::updateComment(), which only acceptsbody_markdown— any other field is silently ignored. There is no full-update path on this endpoint;versionandetagare not bumped by the simple update.
- If the body contains
- Delete is soft —
DELETE /v1/comments/{id}setsdeleted_at = NOW()anddeleted_by_user_id = <caller>rather than removing the row. Thelist,get, and reply-counter queries all filter ondeleted_at IS NULL, so deleted comments silently disappear from API responses (no tombstone flag is surfaced). A secondDELETEon a soft-deleted comment returns404 — Comment not foundbecause the soft-delete filter rejects it. - Replies are one level deep only —
parent_comment_idmust reference a top-level comment (one with its ownparent_comment_id IS NULL). Trying to reply to a reply returns422 — Nested replies are not allowedon create and422 — Thread parent must be top-level commenton list-with-filter. - The org is derived from the entity, not the API key —
CommentsService::getEntityOrgId()resolves the entity's owning org fromtasks→project_columns→projects(for tasks) or directly fromcms_pages(for pages). On create the service also enforces that the calling user is an active member of that org. If the entity was created in org A but the API key is scoped to org B, the request still works — it's just always bound to A. - Create returns the full comment object, not just an id — the router response is
{success:true, data:{comment_id, comment:{...}, attachments:[...], attachment_count}}.data.comment_idis the integer id (handy for clients that only need it);data.commentis the fully-hydrated comment object, identical in shape to thelist/getresponse (minusreply_count, which the list computes via subquery at read time). - Create returns
HTTP 200, not201— the comments router does not callhttp_response_code(201)on the success path (unlike the tasks / projects / media routers). Same for update and delete — all three return default200 OK. Treat thesuccess: trueenvelope as the canonical signal. - POST accepts both
body_markdownand the olderbodyfield name — the service readsbody_markdownfirst and falls back tobody. There is also a canonical-payload compatibility shim:{"task_id": <id>, "body": "..."}is accepted on POST and is auto-mapped toentity_type = "task". Useentity_type+entity_id+body_markdownfor new integrations. can_editis author-only;can_deleteallows org admins —CommentsService::deleteComment()lets either the author or an org-admin actor delete a comment, whileupdateComment()enforces author-only (returns403 — Only the author can edit this comment). The simple PUT/PATCH path reflects this: only authors should attempt it.- Attachments live at
POST /v1/comments/{id}/attachments— multipart upload of one or more files via thefile/files[]fields. The media service is the same one used by tasks / pages. There is no separate list-attachments endpoint — attachments come back inline onGET/list/createas themediaandattachmentsarrays (the two keys are aliases).
Endpoints at a glance
| Action | Method + Path | Summary |
|---|---|---|
| List comments | GET /comments | List top-level (or thread-of-one) comments on a task or page. Required: entity_type + entity_id. Soft-deleted rows are excluded. |
| Create a comment | POST /comments | Create a top-level comment or a reply (parent_comment_id). JSON body or multipart with file(s). Returns 200 with data.comment fully hydrated. |
| Update a comment | PUT /comments/{id} or PATCH /comments/{id} | Replace body_markdown — or, with baseVersion + ops[], perform a conflict-safe line-range edit (409 on stale version / hash). |
| Delete a comment | DELETE /comments/{id} | Soft-delete (sets deleted_at). Comment disappears from list/get; row is preserved. |
Heads up: the sibling endpoints GET /v1/comments/{id} (single-comment fetch with version/etag/lines metadata), PATCH /v1/comments/{id} with baseVersion+ops[] (the conflict-safe range-edit path), and POST /v1/comments/{id}/attachments (multipart upload of media) are all implemented in the router and CommentsService, but the three core CRUD cards above are the canonical surface. Mention parsing (@user in body) and the realtime comment_created publish both happen automatically on create — no extra fields to send.
GET /comments
List top-level comments attached to a task or page, ordered by created_at ASC. Soft-deleted rows (deleted_at IS NOT NULL) are silently excluded. Pass parent_comment_id to list the replies under one top-level comment (must itself be a top-level comment — not a reply).
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
entity_type |
string |
yes | Owning entity kind. One of task or page. Anything else returns 422 — Invalid entity reference. |
entity_id |
integer |
yes | Owning entity id. Must reference a row visible to the API key's org — otherwise 404 — Entity not found or access denied. |
parent_comment_id |
integer |
no | If present and > 0, return only replies to this comment (must be top-level — passing a reply's id returns 422 — Thread parent must be top-level comment). If omitted, return top-level comments only (parent_comment_id IS NULL). |
Request example
curl -sS https://api.tasklife.com/v1/comments?entity_type=task&entity_id=8412 \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json"
Response 200 OK
The array is nested under data.comments[] alongside echo fields for the query:
| Field | Type | Description |
|---|---|---|
data.entity_type | string | Echo of the entity_type query param. |
data.entity_id | integer | Echo of the entity_id query param. |
data.parent_comment_id | integer | null | Echo of the parent_comment_id filter, or null when listing top-level. |
data.comments[].id | integer | Comment id. |
data.comments[].org_id | integer | Owning org id (always equal to the entity's org). |
data.comments[].entity_type | string | task or page. |
data.comments[].entity_id | integer | Owning entity id. |
data.comments[].author_user_id | integer | Author's user id. |
data.comments[].parent_comment_id | integer | null | Parent comment id for replies, or null for top-level. |
data.comments[].body_markdown | string | Markdown body (may be empty string). |
data.comments[].created_at | string (ISO 8601 datetime) | Creation timestamp. |
data.comments[].updated_at | string (ISO 8601 datetime) | null | Last update timestamp (set when body_markdown changes). |
data.comments[].deleted_at | string | null | Always null on this endpoint — soft-deleted rows are filtered server-side and never returned. |
data.comments[].author_name | string | null | Author display name (from users.name join). |
data.comments[].author_photo | string | null | Author avatar URL (from users.photo_url). |
data.comments[].reply_count | integer | Number of non-deleted replies under this comment (always 0 on a reply-row query). |
data.comments[].can_edit | boolean | true when the calling user is the author. Otherwise false. |
data.comments[].can_delete | boolean | true when the calling user is the author or an active org-admin actor. |
data.comments[].attachments | array | Media records (image / file) attached to this comment. |
data.comments[].attachment_count | integer | attachments.length. |
data.comments[].mentions | array | Parsed mention entities — one entry per @user span in the body. |
data.comments[].entities | array | Alias of mentions. Present for backward compatibility with clients that expect entities[]. |
data.comments[].markdown_image | string | Helper: pre-rendered Markdown snippet for image attachments (from MediaService::buildMarkdownHelpers()). |
data.comments[].markdown_file | string | Helper: pre-rendered Markdown snippet for file attachments. |
{
"success": true,
"data": {
"entity_type": "task",
"entity_id": 8412,
"parent_comment_id": null,
"comments": [
{
"id": 9121,
"org_id": 7,
"entity_type": "task",
"entity_id": 8412,
"author_user_id": 42,
"parent_comment_id": null,
"body_markdown": "Started looking at the proration math this morning. Will update by EOD.",
"created_at": "2026-07-01T15:42:11Z",
"updated_at": null,
"deleted_at": null,
"author_name": "Priya Shah",
"author_photo": "https://cdn.tasklife.com/avatars/42.jpg",
"reply_count": 2,
"can_edit": false,
"can_delete": true,
"attachments": [],
"attachment_count": 0,
"mentions": [],
"entities": [],
"markdown_image": "",
"markdown_file": ""
},
{
"id": 9123,
"org_id": 7,
"entity_type": "task",
"entity_id": 8412,
"author_user_id": 42,
"parent_comment_id": null,
"body_markdown": "Looks good \u2014 ship it. CC: @alex",
"created_at": "2026-07-02T18:11:09Z",
"updated_at": null,
"deleted_at": null,
"author_name": "Priya Shah",
"author_photo": "https://cdn.tasklife.com/avatars/42.jpg",
"reply_count": 0,
"can_edit": false,
"can_delete": true,
"attachments": [],
"attachment_count": 0,
"mentions": [
{
"type": "mention",
"start": 24,
"end": 29,
"org_user_id": 318,
"mentioned_user_id": 87,
"display": "@alex"
}
],
"entities": [
{
"type": "mention",
"start": 24,
"end": 29,
"org_user_id": 318,
"mentioned_user_id": 87,
"display": "@alex"
}
],
"markdown_image": "",
"markdown_file": ""
}
]
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Invalid entity reference"
}
}
POST /comments
Create a new top-level comment on a task or page, or a reply to an existing top-level comment by setting parent_comment_id. Sends back the fully-hydrated comment (with attachments, mentions, version, and etag) inside data.comment; the freshly-assigned integer id is also exposed at data.comment_id. On task comments, the service also emits a realtime comment_created event and a task_comment webhook outbox row when the assignee differs from the author.
entity_type + entity_id + body_markdown. entity_type must be task or page. body_markdown must be a non-empty string of at most 10 000 characters. The org is derived from the entity, not the API key — the calling user must also be an active member of that entity's org.
Body parameters
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
entity_type |
string |
yes | — | task or page. Anything else returns 422 — Invalid entity reference. |
entity_id |
integer |
yes | — | Owning entity id. The entity must exist and be in some org (the service derives org_id from it). |
body_markdown |
string |
yes | — | Markdown body. Empty / missing returns 422 — body is required. Max 10 000 chars (10 KB after mb_strlen) — over that returns 422 — Comment is too long. |
body |
string |
no | — | Older field name alias of body_markdown. Read only if body_markdown is absent. Prefer body_markdown in new integrations. |
task_id |
integer |
no | — | Canonical-payload compatibility shim. If entity_type is empty and task_id > 0, the service auto-sets entity_type = "task" and entity_id = task_id. Use entity_type + entity_id in new integrations. |
parent_comment_id |
integer |
no | null (top-level) |
Reply target. Must reference a top-level comment (one with parent_comment_id IS NULL) on the same entity_type + entity_id. Replies are one level deep: replying to a reply returns 422 — Nested replies are not allowed; replying to a comment on a different entity returns 422 — Parent comment does not belong to entity; missing parent returns 404 — Parent comment not found. |
mentions / entities |
array<object> |
no | auto-extracted from body | Optional explicit mention hints (each {type, start, end, org_user_id}). When omitted, the service scans body_markdown for @user spans automatically. entities is the older field name alias. Invalid span bounds return 422 — Invalid mention span bounds. |
file / files[] |
file (multipart) |
no | — | Multipart upload of one or more attachments on the same request. Requires Content-Type: multipart/form-data. The same media service used by tasks / pages handles storage. JSON-only requests can attach files afterwards via POST /v1/comments/{id}/attachments. |
Request example (JSON)
curl -sS -X POST https://api.tasklife.com/v1/comments \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json" \
-d '{
"entity_type": "task",
"entity_id": 8412,
"body_markdown": "Looks good \u2014 ship it. CC: @alex"
}'
Request example (multipart with attachment)
curl -sS -X POST https://api.tasklife.com/v1/comments \
-H "Authorization: Bearer *** \
-F "entity_type=task" \
-F "entity_id=8412" \
-F "body_markdown=Screenshot of the failing checkout attached." \
-F "file=@/tmp/checkout-error.png"
Response 200 OK
The freshly-created comment is nested under data.comment (identical shape to the list/get response) with the new id also exposed at data.comment_id:
| Field | Type | Description |
|---|---|---|
data.comment_id | integer | New comment id (handy for clients that only need the id). |
data.comment | object | Fully-hydrated comment — same field set as list / get: id, entity_type, entity_id, author_user_id, parent_comment_id, body_markdown, created_at, updated_at, author_name, author_photo, attachments, attachment_count, mentions, entities, version, etag, markdown_image, markdown_file. reply_count is not included on create (it's a list-time subquery). |
data.attachments | array | Alias of data.comment.attachments (kept for clients that read it from the top level). |
data.attachment_count | integer | Alias of data.comment.attachment_count. |
{
"success": true,
"data": {
"comment_id": 9247,
"comment": {
"id": 9247,
"org_id": 7,
"entity_type": "task",
"entity_id": 8412,
"author_user_id": 42,
"parent_comment_id": null,
"body_markdown": "Looks good \u2014 ship it. CC: @alex",
"created_at": "2026-07-03T18:11:09Z",
"updated_at": null,
"deleted_at": null,
"author_name": "Priya Shah",
"author_photo": "https://cdn.tasklife.com/avatars/42.jpg",
"media": [],
"attachments": [],
"attachment_count": 0,
"mentions": [
{
"type": "mention",
"start": 24,
"end": 29,
"org_user_id": 318,
"mentioned_user_id": 87,
"display": "@alex"
}
],
"entities": [
{
"type": "mention",
"start": 24,
"end": 29,
"org_user_id": 318,
"mentioned_user_id": 87,
"display": "@alex"
}
],
"version": 1,
"etag": "W/\"1_a1b2c3\"",
"markdown_image": "",
"markdown_file": ""
},
"attachments": [],
"attachment_count": 0
}
}
Errors
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "body is required"
}
}
PUT /comments/{id} (PATCH is also accepted)
Update a comment's body. The router inspects the request body and forks: a body containing baseVersion + ops[] runs a conflict-safe range-patch (409 on stale version / hash); anything else runs the simple body_markdown update. The simple path returns only a data.message string — not the comment itself. Author-only; non-authors (even org admins) get 403 — Only the author can edit this comment.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Comment id. Must belong to the API key's org (or, more precisely, to the entity's org — same thing in practice). |
Body parameters — simple update
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
body_markdown |
string |
yes | — | New Markdown body. Must be a non-empty string. Sending anything else returns 422 — Invalid comment update request. |
mentions / entities |
array<object> |
no | auto-extracted from body | Optional explicit mention hints. The service refreshes mention links on every body update — previous mentions for this comment are deleted and replaced. Omit to let the parser extract @user spans from the new body. |
Body parameters — conflict-safe range patch
When the body contains BOTH baseVersion and ops[], the router routes to CommentsService::patchComment():
| Field | Type | Required | Description |
|---|---|---|---|
baseVersion |
integer |
yes | The version the client thinks the comment is at. Read from the last GET /v1/comments/{id} response. Mismatch with the current server version returns 409 VERSION_MISMATCH with currentVersion echoed. |
ops[] |
array<object> |
yes | Ordered list of patch operations. Three supported op types: replace_lines, insert_after_line, delete_lines. Each op may carry expectedHashes for stronger conflict detection (mismatch returns 409 HASH_MISMATCH). |
Request example — simple update
curl -sS -X PUT https://api.tasklife.com/v1/comments/9247 \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json" \
-d '{
"body_markdown": "Looks good \u2014 ship it. CC: @alex, @jordan"
}'
Request example — conflict-safe range patch
curl -sS -X PATCH https://api.tasklife.com/v1/comments/9247 \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json" \
-d '{
"baseVersion": 4,
"ops": [
{
"type": "replace_lines",
"from": 1,
"to": 1,
"text": "Looks GREAT \u2014 ship it. CC: @alex, @jordan",
"expectedHashes": ["h_abc12345"]
}
]
}'
Response 200 OK — simple update
Only a data.message string is returned; the comment object is not echoed. Clients that need the new state must follow up with GET /v1/comments/{id}.
| Field | Type | Description |
|---|---|---|
data.message | string | Always "Comment updated" on success. |
{
"success": true,
"data": {
"message": "Comment updated"
}
}
Response 200 OK — conflict-safe range patch
The updated comment is nested under data.comment with bumped version, new etag, and recomputed lines[]:
| Field | Type | Description |
|---|---|---|
data.comment.id | integer | Comment id. |
data.comment.body_markdown | string | The patched body. |
data.comment.version | integer | New version (baseVersion + 1). Pass this on the next patch as baseVersion. |
data.comment.etag | string | New weak ETag in the form W/"<version>_<6-char-content-hash>". |
data.comment.lines | array<object> | One entry per line: {n, start, end, hash}. Use these hashes as expectedHashes on the next patch. |
{
"success": true,
"data": {
"comment": {
"id": 9247,
"body_markdown": "Looks GREAT \u2014 ship it. CC: @alex, @jordan\n\nReviewed the Stripe proration doc; signups should re-charge correctly now.",
"version": 5,
"etag": "W/\"5_9f4e2d\"",
"lines": [
{ "n": 1, "start": 0, "end": 47, "hash": "h_aa11bb22" },
{ "n": 2, "start": 48, "end": 48, "hash": "h_blank000" },
{ "n": 3, "start": 49, "end": 119, "hash": "h_33cc44dd" }
]
}
}
}
Errors
{
"success": false,
"error": {
"code": "VERSION_MISMATCH",
"message": "Base version does not match current version",
"currentVersion": 4
}
}
DELETE /comments/{id}
Soft-delete a comment. Sets deleted_at = NOW() and deleted_by_user_id = <caller> on the row; the comment immediately disappears from list, get, reply-count subqueries, and the task's last_comment_* denormalisations. Allowed for the comment's author or an active org-admin actor. Returns 404 on a second delete (already soft-deleted) or on an unknown id.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Comment id. Soft-deleted comments return 404 — Comment not found here too. |
Request example
curl -sS -X DELETE https://api.tasklife.com/v1/comments/9247 \
-H "Authorization: Bearer *** \
-H "Content-Type: application/json"
Response 200 OK
The response carries success: true with only a data.message string — the row is preserved server-side but hidden from every read path:
| Field | Type | Description |
|---|---|---|
data.message | string | Always "Comment deleted" on success. |
{
"success": true,
"data": {
"message": "Comment deleted"
}
}
{
"success": false,
"error": {
"code": "API_ERROR",
"message": "Comment not found"
}
}