# 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.

How: with the API (About an hour, most of it fitting the HTML to your site).

What you will have:

- Each article on your site showing its Commune discussion: the opening comment and every reply, threaded the way readers wrote them.
- A page that updates within seconds of a new reply, without calling Commune on every view.
- A key on your server that can only ever show what is already public.

**Warning:** 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.

## With the API

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 from https://usecommune.com/settings/api-keys with: content: read. Export it 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.

Every request sends `Authorization: Bearer $COMMUNE_API_KEY` and `Commune-Version: 2026-08-26`. The requests below are also a collection you can import into Postman, Insomnia, Bruno, Yaak or Hoppscotch: https://usecommune.dev/use-cases/conversation-on-your-site/collection.json

### 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}/articles` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterarticles))

```sh
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`:

```json
{
  "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 will 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}/publish` ([reference](https://api-reference.usecommune.dev/operation/operation-publishthread))

```sh
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`:

```json
{
  "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. 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](https://api-reference.usecommune.dev/operation/operation-getthread))

```sh
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`:

```json
{
  "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 `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}/messages` ([reference](https://api-reference.usecommune.dev/operation/operation-listthreadmessages))

```sh
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`:

```json
{
  "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 ignores `media` and `reactions`; both are on each message if you want them.

discussion.mjs:

```js
// 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. 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:

```js
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. Keep it live when somebody replies

You will 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.

Event `message.created`, as Commune posts it to your endpoint:

```json
{
  "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. 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:

```js
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.

```js
// 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));
```
