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 statusWhenWhat to do
400Malformed 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.
401Missing or invalid Authorization header.Check the API key; confirm the header is present.
403API 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.
404Comment, 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.
409Conflict 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.
422Validation 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.
500Unexpected 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.

Endpoints at a glance

ActionMethod + PathSummary
List commentsGET /commentsList top-level (or thread-of-one) comments on a task or page. Required: entity_type + entity_id. Soft-deleted rows are excluded.
Create a commentPOST /commentsCreate 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 commentPUT /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 commentDELETE /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

FieldTypeRequiredDescription
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:

FieldTypeDescription
data.entity_typestringEcho of the entity_type query param.
data.entity_idintegerEcho of the entity_id query param.
data.parent_comment_idinteger | nullEcho of the parent_comment_id filter, or null when listing top-level.
data.comments[].idintegerComment id.
data.comments[].org_idintegerOwning org id (always equal to the entity's org).
data.comments[].entity_typestringtask or page.
data.comments[].entity_idintegerOwning entity id.
data.comments[].author_user_idintegerAuthor's user id.
data.comments[].parent_comment_idinteger | nullParent comment id for replies, or null for top-level.
data.comments[].body_markdownstringMarkdown body (may be empty string).
data.comments[].created_atstring (ISO 8601 datetime)Creation timestamp.
data.comments[].updated_atstring (ISO 8601 datetime) | nullLast update timestamp (set when body_markdown changes).
data.comments[].deleted_atstring | nullAlways null on this endpoint — soft-deleted rows are filtered server-side and never returned.
data.comments[].author_namestring | nullAuthor display name (from users.name join).
data.comments[].author_photostring | nullAuthor avatar URL (from users.photo_url).
data.comments[].reply_countintegerNumber of non-deleted replies under this comment (always 0 on a reply-row query).
data.comments[].can_editbooleantrue when the calling user is the author. Otherwise false.
data.comments[].can_deletebooleantrue when the calling user is the author or an active org-admin actor.
data.comments[].attachmentsarrayMedia records (image / file) attached to this comment.
data.comments[].attachment_countintegerattachments.length.
data.comments[].mentionsarrayParsed mention entities — one entry per @user span in the body.
data.comments[].entitiesarrayAlias of mentions. Present for backward compatibility with clients that expect entities[].
data.comments[].markdown_imagestringHelper: pre-rendered Markdown snippet for image attachments (from MediaService::buildMarkdownHelpers()).
data.comments[].markdown_filestringHelper: 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.

Body parameters

FieldTypeRequiredDefaultDescription
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:

FieldTypeDescription
data.comment_idintegerNew comment id (handy for clients that only need the id).
data.commentobjectFully-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.attachmentsarrayAlias of data.comment.attachments (kept for clients that read it from the top level).
data.attachment_countintegerAlias 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

FieldTypeRequiredDescription
idintegeryesComment 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

FieldTypeRequiredDefaultDescription
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():

FieldTypeRequiredDescription
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}.

FieldTypeDescription
data.messagestringAlways "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[]:

FieldTypeDescription
data.comment.idintegerComment id.
data.comment.body_markdownstringThe patched body.
data.comment.versionintegerNew version (baseVersion + 1). Pass this on the next patch as baseVersion.
data.comment.etagstringNew weak ETag in the form W/"<version>_<6-char-content-hash>".
data.comment.linesarray<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

FieldTypeRequiredDescription
idintegeryesComment 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:

FieldTypeDescription
data.messagestringAlways "Comment deleted" on success.
{
  "success": true,
  "data": {
    "message": "Comment deleted"
  }
}