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 asCOMMUNE_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.
Every request sends Authorization: Bearer $COMMUNE_API_KEY and Commune-Version: 2026-08-26.
- 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: readand 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 seessentarticles, and never one limited to a tag audience or dated in the future.threadisnullwhen 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 itsshort_idreturns the samethreadfor one article.cURLcurl "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
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
visibilitysubscribers: your readers wrote it for your other readers. A key holding onlyreadpermissions seespublicthreads and nothing else, so the site's key answers404for this one. That is the boundary you want on a page anybody can open, and it is why the site's key should never holdwritein 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 answers200and changes nothing. It is a write, so it carries anIdempotency-Key.cURLcurl -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" } - A separate key with
- 3
Read the opening comment
The thread is the first comment under the article, and its
contentis what Mira wrote.expand=authorputs her public profile in place of the bare reference, so there is no second request per person. A profile is only everdisplay_name,usernameandavatar; no address or anything private is on it.reply_countis there if you want a count before you load the replies.Use the thread's
idhere. A discussion Commune opened under an article is addressed on the web through the article's own page, so do not build links from itsshort_id.cURLcurl "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
Read every reply
Replies come oldest first in one flat list. A reply to the thread has
depth1 andparentnull; a reply to a reply hasdepth2 and aparentpointing at the reply it answers, which is always in the same list, so you rebuild the tree without another request.expand=authorworks here too. Deleted replies are left out rather than marked.Ask for up to 100 at a time, and while
pagination.next_cursoris notnull, repeat the request withcursorset 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.cURLcurl "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
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 ignoresmediaandreactions; 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
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
429still carriesRetry-After, and the fetch code above waits it out, but a cache means you rarely see one.GET /rate-limitreports 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.mjsimport { 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
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.createdarrives for every reply, in every thread, with thethread_idit 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.publishedto 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.createdto 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" } } - An endpoint registered for
- 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.mjsimport { 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. 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) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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- AgentAPI
Get a pulse on your newsletter in plain words
Ask how things are going and get an answer, not a dashboard.
Read the use case - API
Build an app every creator can connect
Ship an integration other newsletters install in a few clicks.
Read the use case - AgentAPI
Win back readers before they leave
See who is going quiet while there is still time to reach them.
Read the use case