Commune
API

Bring the conversation to your site

Your readers talk about each issue in Commune, and most people who find that issue on your website never see the conversation. Put it under the article on your own site, kept current as replies arrive, so every visitor sees that people are reading and answering.

Before you start

Only discussions you have made public appear, by design: the site's key can read nothing else. A discussion kept for subscribers stays off your site until you promote it, and promoting it also puts it on Commune's global feed.

Your server reads the discussion with a key, turns it into HTML and caches it; a reply arriving as an event clears the cache. The browser never talks to Commune. Four requests find and read a discussion, then the code renders it, caches it and keeps it current.

Before you start

  • An API key with content: read, exported as COMMUNE_API_KEY.
  • A server that renders your article pages (Next.js, Astro, Rails, plain Node, anything that can make a request before it answers). The examples are Node 18 or newer, no dependencies.
  • Some steps need more than this. Each one lists it where it starts.

Run it in your client

Every request below, in order, as a collection. Import the file into Postman, Insomnia, Bruno, Yaak or Hoppscotch, set apiKey, and send them one by one.

Download the collection

Every request sends Authorization: Bearer $COMMUNE_API_KEY and Commune-Version: 2026-08-26.

  1. 1

    Find the article and its discussion

    Every request needs a key, and a key is a secret: keep it on your server and never ship it to the browser. The API answers browser requests too, so nothing stops a page from calling it directly except that anybody could then read your key from the page. The site's key should hold content: read and nothing else, for a reason step 2 makes clear.

    List the newsletter's published articles; each one names its discussion in thread. A read-only key only ever sees sent articles, and never one limited to a tag audience or dated in the future. thread is null when an article has no discussion (the imported article in the contract's example, for one). If your page already knows which article it is showing, GET /articles/{article} with its short_id returns the same thread for one article.

    GET/newsletters/{newsletter}/articlesReference
    cURL
    curl "https://api.usecommune.com/newsletters/example-letter/articles?status=sent&limit=10" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "list",
      "data": [
        {
          "object": "article",
          "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
          "short_id": "k7Rm2xQp",
          "slug": "what-newsletters-get-wrong-about-community",
          "newsletter": {
            "object": "newsletter",
            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
          },
          "title": "What newsletters get wrong about community",
          "preview_text": "The moderation load is the product, not a tax on it.",
          "image_url": "https://cdn.example.com/articles/k7Rm2xQp/cover.png",
          "external_url": null,
          "status": "sent",
          "is_imported": false,
          "posted_at": "2026-08-26T09:32:11Z",
          "scheduled_for": null,
          "authors": [
            {
              "object": "user",
              "id": "usr_2Nf8Kq1pWc"
            }
          ],
          "thread": {
            "object": "thread",
            "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9"
          },
          "stats": {
            "likes": 148,
            "comments": 27,
            "highlights": 63
          },
          "created_at": "2026-08-24T11:04:52Z",
          "updated_at": "2026-08-26T09:32:11Z"
        }
      ],
      "pagination": {
        "has_more": true,
        "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
      }
    }
  2. 2

    Decide which discussions are public

    You'll need

    • A separate key with content: write, or the Commune app. Keep it away from your site.

    A discussion starts inside your newsletter's space, with visibility subscribers: your readers wrote it for your other readers. A key holding only read permissions sees public threads and nothing else, so the site's key answers 404 for this one. That is the boundary you want on a page anybody can open, and it is why the site's key should never hold write in any family: a key that does sees every thread, and your page would republish words people wrote for subscribers.

    To show a discussion, promote it. This request does it one thread at a time, with a separate key holding content: write (or do it in the Commune app). Be sure first: it also puts the thread on Commune's global feed, and no operation in the API takes it back off (the Commune app can). A thread already public answers 200 and changes nothing. It is a write, so it carries an Idempotency-Key.

    POST/threads/{thread}/publishReference
    cURL
    curl -X POST "https://api.usecommune.com/threads/b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9/publish" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26" \
      -H "Idempotency-Key: 3e9a61d4-7b25-4c08-9f13-a8d2c5e7b046"
    Response 200
    {
      "object": "thread",
      "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
      "short_id": "k3n8qz",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "author": {
        "object": "user",
        "id": "usr_2Nf8Kq1pWc"
      },
      "content": "The bit about moderation load matched my experience exactly.",
      "media": [],
      "visibility": "public",
      "is_article_thread": true,
      "article": {
        "object": "article",
        "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
      },
      "reply_count": 27,
      "view_count": 1042,
      "created_at": "2026-08-26T15:10:44Z",
      "updated_at": "2026-08-27T08:41:12Z",
      "edited_at": null,
      "last_activity_at": "2026-08-27T08:41:12Z"
    }
  3. 3

    Read the opening comment

    The thread is the first comment under the article, and its content is what Mira wrote. expand=author puts her public profile in place of the bare reference, so there is no second request per person. A profile is only ever display_name, username and avatar; no address or anything private is on it. reply_count is there if you want a count before you load the replies.

    Use the thread's id here. A discussion Commune opened under an article is addressed on the web through the article's own page, so do not build links from its short_id.

    GET/threads/{thread}Reference
    cURL
    curl "https://api.usecommune.com/threads/b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9?expand=author" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "thread",
      "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
      "short_id": "k3n8qz",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "author": {
        "object": "user",
        "id": "usr_2Nf8Kq1pWc",
        "username": "mira",
        "display_name": "Mira Okafor",
        "avatar": "https://cdn.example.com/avatars/mira.png"
      },
      "content": "The bit about moderation load matched my experience exactly.",
      "media": [],
      "visibility": "public",
      "is_article_thread": true,
      "article": {
        "object": "article",
        "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
      },
      "reply_count": 27,
      "view_count": 1042,
      "created_at": "2026-08-26T15:10:44Z",
      "updated_at": "2026-08-27T08:41:12Z",
      "edited_at": null,
      "last_activity_at": "2026-08-27T08:41:12Z"
    }
  4. 4

    Read every reply

    Replies come oldest first in one flat list. A reply to the thread has depth 1 and parent null; a reply to a reply has depth 2 and a parent pointing at the reply it answers, which is always in the same list, so you rebuild the tree without another request. expand=author works here too. Deleted replies are left out rather than marked.

    Ask for up to 100 at a time, and while pagination.next_cursor is not null, repeat the request with cursor set to it. If you ever hold only an author reference (from somewhere that does not expand), GET /users/{user} returns the same profile, but here expanding saves a request per person.

    GET/threads/{thread}/messagesReference
    cURL
    curl "https://api.usecommune.com/threads/b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9/messages?expand=author&limit=100" \
      -H "Authorization: Bearer $COMMUNE_API_KEY" \
      -H "Commune-Version: 2026-08-26"
    Response 200
    {
      "object": "list",
      "data": [
        {
          "object": "message",
          "id": "c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a",
          "short_id": "p7w2rd",
          "thread": {
            "object": "thread",
            "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9"
          },
          "newsletter": {
            "object": "newsletter",
            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
          },
          "author": {
            "object": "user",
            "id": "usr_9Lp3Zr7tYb",
            "username": "dan",
            "display_name": "Dan Whitlock",
            "avatar": null
          },
          "parent": null,
          "quoted": null,
          "depth": 1,
          "content": "Same here. We ended up capping thread depth for that reason.",
          "media": [],
          "reactions": [
            {
              "emoji": "👍",
              "count": 3
            },
            {
              "emoji": "🎉",
              "count": 1
            }
          ],
          "highlight": null,
          "created_at": "2026-08-26T15:14:02Z",
          "updated_at": "2026-08-26T15:14:02Z",
          "edited_at": null
        },
        {
          "object": "message",
          "id": "d3e4f506-1728-493a-a4b5-c6d7e8f9001b",
          "short_id": "r2k9vt",
          "thread": {
            "object": "thread",
            "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9"
          },
          "newsletter": {
            "object": "newsletter",
            "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
          },
          "author": {
            "object": "user",
            "id": "usr_2Nf8Kq1pWc",
            "username": "mira",
            "display_name": "Mira Okafor",
            "avatar": "https://cdn.example.com/avatars/mira.png"
          },
          "parent": {
            "object": "message",
            "id": "c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a"
          },
          "quoted": null,
          "depth": 2,
          "content": "What depth did you settle on? We are still arguing about three.",
          "media": [],
          "reactions": [],
          "highlight": null,
          "created_at": "2026-08-26T15:31:48Z",
          "updated_at": "2026-08-26T15:31:48Z",
          "edited_at": null
        }
      ],
      "pagination": {
        "has_more": false,
        "next_cursor": null
      }
    }
  5. 5

    Render it on your server

    Two rules matter more than the markup. Escape everything a reader wrote, content and names alike, or one reply containing a <script> runs on your site. And render on the server, so the key stays there. The function returns an HTML fragment for the thread (or an empty string when there is nothing public to show) that you drop under the article in whatever template you use; in a Next.js server component it is an async call before you return the page.

    A display name can be an empty string rather than missing, so fall back with ||, not ??. This ignores media and reactions; both are on each message if you want them.

    discussion.mjs
    // discussion.mjs: fetching and rendering, server side only.
    const BASE = "https://api.usecommune.com";
    const VERSION = "2026-08-26";
    const KEY = process.env.COMMUNE_API_KEY; // content: read, and nothing else
    
    async function api(path) {
      const res = await fetch(BASE + path, {
        headers: { authorization: `Bearer ${KEY}`, "commune-version": VERSION },
      });
      if (res.status === 429) {
        await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
        return api(path);
      }
      if (res.status === 404) return null; // Not readable with this key: render nothing.
      const body = await res.json();
      if (!res.ok) {
        const { code, message, request_id } = body.error;
        throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
      }
      return body;
    }
    
    async function* all(path) {
      let cursor = null;
      do {
        const sep = path.includes("?") ? "&" : "?";
        const page = await api(cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
        if (!page) return;
        yield* page.data;
        cursor = page.pagination.next_cursor;
      } while (cursor);
    }
    
    // Everything a reader wrote goes through this before it touches the page.
    const escape = (value) =>
      String(value ?? "").replace(/[&<>"']/g, (c) =>
        ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
      );
    
    function byline(author, at) {
      const name = author?.display_name || author?.username || "A reader";
      const avatar = author?.avatar
        ? `<img src="${escape(author.avatar)}" alt="" width="28" height="28" loading="lazy">`
        : "";
      return `<header>${avatar}<strong>${escape(name)}</strong> <time datetime="${escape(at)}">${escape(at.slice(0, 10))}</time></header>`;
    }
    
    const paragraphs = (text) => `<p>${escape(text).replace(/\n/g, "<br>")}</p>`;
    
    export async function renderDiscussion(threadId) {
      const thread = await api(`/threads/${threadId}?expand=author`);
      if (!thread) return "";
    
      const replies = [];
      for await (const message of all(`/threads/${threadId}/messages?expand=author&limit=100`)) {
        replies.push(message);
      }
    
      // One flat page, oldest first. Rebuild the two levels from `parent`.
      const children = new Map();
      for (const message of replies) {
        if (!message.parent) continue;
        if (!children.has(message.parent.id)) children.set(message.parent.id, []);
        children.get(message.parent.id).push(message);
      }
      const item = (message) => {
        const below = children.get(message.id) ?? [];
        return `<li>${byline(message.author, message.created_at)}${paragraphs(message.content)}${
          below.length ? `<ul>${below.map(item).join("")}</ul>` : ""
        }</li>`;
      };
    
      return `<section class="discussion" aria-label="Discussion">
      <article>${byline(thread.author, thread.created_at)}${paragraphs(thread.content)}</article>
      <ul>${replies.filter((m) => !m.parent).map(item).join("")}</ul>
    </section>`;
    }
  6. 6

    Cache it, so a page view is not an API call

    Rendering one discussion costs two requests or more (the thread, then each page of replies), and every one counts against your key's rate limit budget, which is per key, not per visitor. Without a cache a busy article would spend that budget on its readers. Keep the HTML per thread and serve it from memory; a 429 still carries Retry-After, and the fetch code above waits it out, but a cache means you rarely see one. GET /rate-limit reports what the key has left.

    Keep a time limit on each entry even with the event in the next step. Nothing is published when a reply is edited or deleted, so the TTL is what eventually picks those up. A few minutes is a sensible balance.

    cache.mjs
    import { renderDiscussion } from "./discussion.mjs";
    
    const TTL = 5 * 60 * 1000; // Picks up edits and deletions, which publish no event.
    const cache = new Map(); // thread id -> { html, at }
    
    export async function discussionHtml(threadId) {
      const hit = cache.get(threadId);
      if (hit && Date.now() - hit.at < TTL) return hit.html;
      const html = await renderDiscussion(threadId);
      cache.set(threadId, { html, at: Date.now() });
      return html;
    }
    
    export function forget(threadId) {
      cache.delete(threadId);
    }
  7. 7

    Keep it live when somebody replies

    You'll need

    • An endpoint registered for message.created, set up as in the real-time use case. Without it the page still works and refreshes on a timer.

    message.created arrives for every reply, in every thread, with the thread_id it belongs to. That is all the page needs: drop that thread's cached HTML and the next visitor gets a fresh render. The event carries the whole reply, author inlined, but it fires for subscriber-only threads too, so do not render from it. Clearing the cache and re-reading with the read-only key means a reply in a thread that is not public can never reach your page.

    Add thread.published to the same endpoint if you want a discussion to appear the moment you promote it, rather than when the TTL runs out.

    Commune posts message.created to your endpoint:

    event message.created
    {
      "id": "018f2a91-eeee-7000-8000-00000000000e",
      "type": "message.created",
      "api_version": "2026-08-26",
      "occurred_at": "2026-08-26T15:14:02Z",
      "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
      "actor": null,
      "idempotency_key": null,
      "data": {
        "message_id": "c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a",
        "short_id": "p7w2rd",
        "url": "https://example.com/t/k3n8qz#p7w2rd",
        "thread_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
        "parent_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
        "thread_level": 1,
        "author": {
          "user_id": "usr_9Lp3Zr7tYb",
          "username": "dan",
          "display_name": "Dan Whitlock",
          "avatar_url": null
        },
        "content": "Same here. We ended up capping thread depth for that reason.",
        "created_at": "2026-08-26T15:14:02Z"
      }
    }
  8. 8

    Clear the cache on the event

    Verify the delivery exactly as the webhooks guide does (the full script below carries the function), answer 200, then forget the thread. Clearing a cache entry twice is harmless, so a redelivered event needs no dedupe here. If your site runs on several instances, or on a framework with its own data cache, clear that instead: in Next.js, tag the fetches with the thread id and revalidate the tag from this handler.

    on-event.mjs
    import { forget } from "./cache.mjs";
    
    // Called with a delivery that has already passed verify().
    export function onEvent(event) {
      if (event.type === "message.created" || event.type === "thread.published") {
        forget(event.data.thread_id);
      }
      // Anything else: ignore it. New topics appear over time.
    }

All together

Everything above as one Node server with no dependencies. GET /discussions/<article> answers the HTML fragment for an article (by id or short_id) for your page to include, and POST /commune/events takes the signed deliveries that keep it current. The site's key holds content: read only.

discussion-server.mjs
// discussion-server.mjs. Node 18 or newer, no dependencies.
//
//   COMMUNE_API_KEY=...            (content: read, and nothing else)
//   COMMUNE_WEBHOOK_SECRET=whsec_... node discussion-server.mjs
//
// Your page renders on the server and includes the fragment from
//   GET http://localhost:3000/discussions/k7Rm2xQp

import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
const SECRET = process.env.COMMUNE_WEBHOOK_SECRET; // "whsec_..."
const TTL = 5 * 60 * 1000;

// ------------------------------------------------------------- reading --

async function api(path) {
  const res = await fetch(BASE + path, {
    headers: { authorization: `Bearer ${KEY}`, "commune-version": VERSION },
  });
  if (res.status === 429) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
    return api(path);
  }
  if (res.status === 404) return null;
  const body = await res.json();
  if (!res.ok) {
    const { code, message, request_id } = body.error;
    throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
  }
  return body;
}

async function* all(path) {
  let cursor = null;
  do {
    const sep = path.includes("?") ? "&" : "?";
    const page = await api(cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
    if (!page) return;
    yield* page.data;
    cursor = page.pagination.next_cursor;
  } while (cursor);
}

// ----------------------------------------------------------- rendering --

const escape = (value) =>
  String(value ?? "").replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
  );

function byline(author, at) {
  const name = author?.display_name || author?.username || "A reader";
  const avatar = author?.avatar
    ? `<img src="${escape(author.avatar)}" alt="" width="28" height="28" loading="lazy">`
    : "";
  return `<header>${avatar}<strong>${escape(name)}</strong> <time datetime="${escape(at)}">${escape(at.slice(0, 10))}</time></header>`;
}

const paragraphs = (text) => `<p>${escape(text).replace(/\n/g, "<br>")}</p>`;

async function renderDiscussion(threadId) {
  const thread = await api(`/threads/${threadId}?expand=author`);
  if (!thread) return ""; // Not public: nothing to show.

  const replies = [];
  for await (const message of all(`/threads/${threadId}/messages?expand=author&limit=100`)) {
    replies.push(message);
  }

  const children = new Map();
  for (const message of replies) {
    if (!message.parent) continue;
    if (!children.has(message.parent.id)) children.set(message.parent.id, []);
    children.get(message.parent.id).push(message);
  }
  const item = (message) => {
    const below = children.get(message.id) ?? [];
    return `<li>${byline(message.author, message.created_at)}${paragraphs(message.content)}${
      below.length ? `<ul>${below.map(item).join("")}</ul>` : ""
    }</li>`;
  };

  return `<section class="discussion" aria-label="Discussion">
  <article>${byline(thread.author, thread.created_at)}${paragraphs(thread.content)}</article>
  <ul>${replies.filter((m) => !m.parent).map(item).join("")}</ul>
</section>`;
}

// ------------------------------------------------------------- caching --

const threadOf = new Map(); // article -> { threadId, at }
const html = new Map(); // thread id -> { html, at }
const fresh = (entry) => entry && Date.now() - entry.at < TTL;

async function discussionFor(article) {
  let known = threadOf.get(article);
  if (!fresh(known)) {
    const found = await api(`/articles/${encodeURIComponent(article)}`);
    known = { threadId: found?.thread?.id ?? null, at: Date.now() };
    threadOf.set(article, known);
  }
  if (!known.threadId) return "";

  const hit = html.get(known.threadId);
  if (fresh(hit)) return hit.html;
  const rendered = await renderDiscussion(known.threadId);
  html.set(known.threadId, { html: rendered, at: Date.now() });
  return rendered;
}

// ------------------------------------------ verifying (the guide's) --

function verify(raw, header, timestamp) {
  if (!header || !timestamp) return false;

  // The signed string is the timestamp in UNIX SECONDS, a dot, then the raw
  // body. The header is RFC 3339, so convert before signing.
  const seconds = Math.floor(Date.parse(timestamp) / 1000);
  if (!Number.isFinite(seconds)) return false;

  // Covered by the signature, so this window is a real defence.
  if (Math.abs(Date.now() / 1000 - seconds) > 300) return false;

  const expected = Buffer.from(
    createHmac("sha256", SECRET)
      .update(seconds + ".")
      .update(raw)
      .digest("hex"),
    "utf8",
  );

  // "v0=" then one or more hex digests, comma separated. Accept any of them.
  return header
    .replace(/^v0=/, "")
    .split(",")
    .some((candidate) => {
      const presented = Buffer.from(candidate.trim(), "utf8");
      return (
        presented.length === expected.length &&
        timingSafeEqual(presented, expected)
      );
    });
}

// -------------------------------------------------------------- serving --

createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");

  if (req.method === "GET" && url.pathname.startsWith("/discussions/")) {
    const article = decodeURIComponent(url.pathname.slice("/discussions/".length));
    discussionFor(article).then(
      (fragment) => {
        res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
        res.end(fragment);
      },
      (error) => {
        console.error(error.message);
        res.writeHead(502).end(); // Let the page render without the discussion.
      },
    );
    return;
  }

  if (req.method === "POST" && url.pathname === "/commune/events") {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const raw = Buffer.concat(chunks);
      if (!verify(raw, req.headers["commune-signature"], req.headers["commune-timestamp"])) {
        return res.writeHead(401).end();
      }
      const event = JSON.parse(raw.toString("utf8"));
      res.writeHead(200).end();
      if (event.type === "message.created" || event.type === "thread.published") {
        html.delete(event.data.thread_id);
      }
    });
    return;
  }

  res.writeHead(404).end();
}).listen(Number(process.env.PORT ?? 3000));

Next use cases

See all