Comments & reactions

Both endpoints are public reads, keyed on a project plus a content slug — the same slug the blog, docs, or changelog endpoints use. Neither is in the OpenAPI spec yet; they're documented here.

These endpoints are read-only. Posting a comment and toggling a reaction go through gated Server Actions with reader authentication, not through /api/v1. There is no way to write a comment with an API key.

Comment thread

GET /api/v1/comments?projectId=…&slug=…

Both parameters are required; omitting either returns 400.

curl "https://api.inkform.dev/api/v1/comments?projectId=YOUR_PROJECT_ID&slug=hello-world"
{
  "data": [
    {
      "id": "c1",
      "body": "Great post.",
      "authorName": "Ada",
      "createdAt": "2026-09-02T09:14:00.000Z",
      "replies": [
        {
          "id": "c2",
          "body": "Agreed.",
          "authorName": null,
          "createdAt": "2026-09-02T10:01:00.000Z",
          "replies": []
        }
      ]
    }
  ]
}

Notes worth planning for:

  • The tree is already nested and unbounded in depth — render it recursively.
  • Ordering is oldest-first at every level.
  • Deleted comments are omitted entirely, along with their replies.
  • authorName can be null; fall back to something like "Anonymous" in your UI.
  • An unknown slug returns 200 with data: [], not 404.

Comment bodies are user-submitted text. Escape them when rendering — never inject one as HTML.

Reaction counts

GET /api/v1/reactions?projectId=…&slug=…
curl "https://api.inkform.dev/api/v1/reactions?projectId=YOUR_PROJECT_ID&slug=hello-world"
{ "data": { "love": 12, "like": 30, "total": 42 } }

Two reaction types, love and like, plus their sum. An item nobody has reacted to returns zeroes, not 404.

Rendering both on a post page

One fetch each, in parallel, at request time:

const BASE = 'https://api.inkform.dev/api/v1';
 
async function getSocial(projectId: string, slug: string) {
  const q = `projectId=${projectId}&slug=${encodeURIComponent(slug)}`;
  const [comments, reactions] = await Promise.all([
    fetch(`${BASE}/comments?${q}`, { next: { revalidate: 60 } }).then((r) => r.json()),
    fetch(`${BASE}/reactions?${q}`, { next: { revalidate: 60 } }).then((r) => r.json()),
  ]);
  return { comments: comments.data, reactions: reactions.data };
}

Unlike published content, these change between publishes — a short revalidate window suits them better than build-time fetching.