Skip to content

Comments

Comment writes are gated by site authentication: either the browser Origin must match the site's verified/configured origins, or (secret mode) the request must carry X-Cumments-Timestamp and X-Cumments-Signature (HMAC-SHA256 over timestamp\nMETHOD\npath\nsha256(body), ±5 minutes). See Site trust for the policy.

Every write also carries the author proof described in the API overview and an Idempotency-Key header.

List comments

QUERY /api/v1/sites/{site_id}/pages/{page_slug}/comments (RFC 10008)

Body:

{ "page": 1, "per_page": 20 }

Response:

{
  "data": [
    {
      "event_id": "$event:server",
      "site_id": "my-blog",
      "page_slug": "hello-world",
      "author": {
        "type": "visitor",
        "display_name": "Alice",
        "avatar_url": null,
        "public_key": "...",
        "mxid": null
      },
      "content": {
        "type": "text",
        "body": "hello **world**",
        "formatted_body": "<p>hello <strong>world</strong></p>",
        "style": "normal"
      },
      "timestamp": "2026-08-08T00:00:00Z",
      "edited_at": null,
      "reply_to": null,
      "thread_root": null,
      "submission_id": 42,
      "status": "active",
      "redacted_at": null,
      "redacted_by": null,
      "reactions": [
        { "key": "👍", "count": 2 }
      ]
    }
  ],
  "meta": {
    "total": 1,
    "page": 1,
    "per_page": 20,
    "total_pages": 1
  }
}

content is a typed object; the other variants look like:

  • media: { "type": "media", "kind": "image|video|audio|file|sticker", "url": "mxc://… or /api/v1/media/…", "filename": …, "mimetype": …, "size": …, "width": …, "height": …, "thumbnail_url": …, "alt_text": …, "voice": false }
  • location: { "type": "location", "geo_uri": "geo:30.2,120.1", "description": …, "thumbnail_url": … }
  • poll: { "type": "poll", "question": …, "options": [{ "id": …, "text": … }], "responses": [{ "option_index": 0, "count": 3 }] }
  • encrypted: { "type": "encrypted", "algorithm": "m.megolm.v1.aes-sha2", "sender_key": … }
  • unknown: { "type": "unknown", "fallback": …, "raw": { … } }

Media URLs and author avatars (author.avatar_url) are rewritten to signed proxy URLs when the media proxy is enabled (avatars through the 96×96 crop variant); see Media proxy.

author.display_name and author.avatar_url render the author's current joined m.room.member profile: renaming or changing the avatar updates old comments as well. The value captured at projection time is only used as a fallback after the author leaves the room.

Post a comment

POST /api/v1/sites/{site_id}/pages/{page_slug}/comments

Body:

{
  "content": "...",
  "media": null,
  "display_name": "Alice",
  "author_public_key": "...",
  "author_signature": "...",
  "reply_to": null,
  "challenge_response": "challenge|nonce"
}

When media is present (an object returned by Visitor media upload, or a site sticker pack reference with "kind": "sticker"), the signature covers media.url instead of content; content then only serves as the fallback filename/text.

Successful writes are asynchronous and return 202 with the queue row ID:

{ "submission_id": 42 }

The 202 acknowledges that the submission is durably stored in the local queue; it does not mean the comment exists in Matrix yet. See Idempotent writes for the exact durability semantics.

The projected comment (list/SSE message_created) carries the same submission_id when it was submitted through the Cumments API, so clients can correlate the accepted request with the final comment. Matrix-native comments omit submission_id.

Signature message:

POST\n{site_id}\n{page_slug}\n{content}\n{reply_to}\n{challenge_prefix}

reply_to is the exact Matrix event ID of the parent comment as returned by the API, or an empty line when the comment is not a reply. Event IDs are opaque strings by spec; legacy v1/v2 IDs look like $localpart:server while room v3+ IDs are bare hashes (v3 may even contain /). When an event ID is used in a request path (the path-based edit form) or query string (delete), clients must percent-encode it.

display_name is presentation data and is deliberately not part of the signature: the API writes it to the virtual user's Matrix profile, and the event proof block only carries public_key, signature, challenge, content and submission_id.

Edit a comment

PATCH /api/v1/sites/{site_id}/pages/{page_slug}/comments/{comment_id}

Signature message:

PATCH\n{site_id}\n{page_slug}\n{comment_id}\n{content}\n{challenge_prefix}

The same operation is available without embedding comment_id in the URL:

PATCH /api/v1/sites/{site_id}/pages/{page_slug}/comments

{
  "comment_id": "$event:server",
  "content": "edited",
  "author_public_key": "...",
  "author_signature": "...",
  "challenge_response": "challenge|nonce"
}

Both edit forms are supported: the body-based form avoids percent-encoding opaque event IDs, while the path-based form keeps the target in the URL. Both require the Idempotency-Key header.

Delete a comment

DELETE /api/v1/sites/{site_id}/pages/{page_slug}/comments?comment_id=$event%3Aserver

The target event id travels as a percent-encoded comment_id query parameter. RFC 9110 leaves DELETE request bodies undefined, so Cumments never puts the target in a DELETE body — that keeps requests acceptable to proxies that reject body-bearing DELETEs. The body carries only the author proof:

{
  "author_public_key": "...",
  "author_signature": "...",
  "challenge_response": "challenge|nonce"
}

Signature message:

DELETE\n{site_id}\n{page_slug}\n{comment_id}\n{challenge_prefix}

The request requires the Idempotency-Key header.

React to a comment

POST /api/v1/sites/{site_id}/pages/{page_slug}/comments/{comment_id}/reactions

Body: { "key", "author_public_key", "author_signature", "challenge_response" }. The signature covers ["REACT", site_id, page_slug, comment_id, key, challenge]; the reaction is sent as the visitor's virtual user (m.reaction with the signed proof block) and projected into the message's reaction counts.

Vote on a poll

POST /api/v1/sites/{site_id}/pages/{page_slug}/polls/{poll_id}/votes

Body: { "option_id", "author_public_key", "author_signature", "challenge_response" }. The signature covers ["VOTE", site_id, page_slug, poll_id, option_id, challenge]; the vote is sent as m.poll.response (MSC3381) with the signed proof block and aggregated into the poll's response counts.

Post a location

POST /api/v1/sites/{site_id}/pages/{page_slug}/location

Body: { "geo_uri", "description?", "display_name", "author_public_key", "author_signature", "challenge_response" }. The signature covers ["LOCATE", site_id, page_slug, geo_uri, challenge]; the message is queued like a comment (same Idempotency-Key and 202 { "submission_id" } contract) and sent as m.location (MSC3488) with the signed proof block, closing the loop through the same projection path. As with comments, display_name is written to the virtual user's profile and not covered by the signature.

Room info

GET /api/v1/sites/{site_id}/pages/{page_slug}/room

Returns the comment room's current metadata (name, topic, avatar_url, avatar_thumbnail_url, member_count) and the most recent system messages (member joins/leaves, room name/topic/avatar changes). avatar_url is a signed media-proxy URL and avatar_thumbnail_url is the same image through the 96×96 crop variant, both when the proxy is enabled. See Data model for the room metadata tables.