Find and reward your superfans
A few readers open everything, reply, highlight and share. They are the reason a newsletter grows, and they rarely hear back. Commune scores every reader on what they do in the inbox and in the community, so you can name your superfans, keep them in one group, and thank them with something the rest of the list does not get.
Before you start
Only readers with a Commune account are scored, so your superfans are found among them. Someone who knows your newsletter only by email has no score at all, which is different from a low one.
Connect your agent once, then ask it to find your superfans every week and tell you who is new. Your agent reads; putting them in a Superfans segment and sending them something is yours to do in Commune, and takes a few minutes.
Connect once
Add Commune's MCP server to your agent, then approve the consent screen and pick your newsletter. Nothing else to install.
claude mcp add --transport http commune https://api.usecommune.com/mcpAsk
One prompt does the whole job. Paste it as it is, or change the parts that are yours.
Every Monday at 8:00, find my superfans on Commune.
List each one with their email address, their score, which way their engagement is moving and when they were last active, strongest first. Leave out anyone who has unsubscribed.
Mark who is new since last week, and who is still a superfan but cooling off.
What it uses
Your agent picks these of Commune's tools to answer. You do not name them; they are here so you know where every part of the answer comes from.
find_superfansThe readers in Commune's top band,
superfan, strongest first, each with the total score, the last fourteen days against the fourteen before, which way that is moving and when they last did something. Readers come back as subscriber ids, not addresses.commune_readLooks up the subscriber record behind each id: the email address, whether they are still
subscribed, and the tags they already hold.what_are_readers_actually_doingThe newsletter's recent activity (views, likes, comments, shares, opens, clicks), which the agent reads for your superfans' rows to say what they have been doing.
What comes back
12 superfans this week, strongest first (top 3):
| Reader | Score | Moving | Last active |
|---|---|---|---|
| [email protected] | 412 | Up: 96, from 41 | 26 Aug |
| [email protected] | 355 | Steady | 25 Aug |
| [email protected] | 301 | Down: 18, from 60 | 20 Aug |
- New since last week: [email protected] and one more.
- Worth a personal note: [email protected] is still a superfan but cooling off.
An example. Yours comes from your own newsletter.
Worth asking next
- Who could be my next superfans?
- What have my superfans been talking about in the community?
- How did the issue I sent to Superfans do?
Tips
- Then tag them: in Subscribers in your Commune dashboard, create a Superfans tag once and add it to each reader on the list. A tag decides who can read an issue, not only who receives it, so only add, never remove, while an issue is addressed to it.
- Send them something nobody else gets: write it in the Commune editor and choose Superfans as its audience. It goes only to the readers holding the tag.
- The band is recomputed on a schedule, by rank within your newsletter, so a reader can move without doing anything. Treat each answer as this week's list.
- Scheduling is your agent's: most can run a prompt on a schedule when you ask them to. If yours cannot, ask the same thing each Monday.
Build it as a job that runs every Monday: find this week's superfans, make sure each one holds a Superfans tag, pass every new one to the tool where the reward happens (a community role, a CRM field, a discount code), and see what you have sent the segment. Ten steps, then the job as one file.
Before you start
- An API key with
insights: read,audience: write,content: read, exported asCOMMUNE_API_KEY. - Node 18 or newer, for the handler and the script. Every request also works as the cURL shown, or from the collection.
- A plan that includes insights and API writes. Either one can answer
402otherwise;GET /newsletters/{newsletter}/entitlements(withsettings: read) tells you in advance. - 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 your newsletter
A key reaches one newsletter or several, whichever its owner ticked when creating it. List them and pick yours by
handle(orid, either works in a path): the requests below name it.cURLcurl "https://api.usecommune.com/newsletters" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411", "handle": "example-letter", "name": "The Example Letter", "description": "A weekly letter about how newsletters and communities fit together.", "esp": "commune", "image_url": "https://cdn.example.com/newsletters/example-letter/avatar.png", "website_url": "https://example.com", "social_links": { "twitter": "https://x.com/exampleletter", "bluesky": "https://bsky.app/profile/exampleletter.bsky.social" }, "language": "en", "chat_create_permission": "subscribers", "allow_non_subscriber_chat": false, "owner": { "object": "user", "id": "usr_2Nf8Kq1pWc" }, "featured_article": { "object": "article", "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f" }, "created_at": "2025-03-04T10:00:00Z", "updated_at": "2026-08-26T09:32:11Z" } ], "pagination": { "has_more": false, "next_cursor": null } } - 2
Ask for your superfans
Commune places every scored reader on a ladder from
dormanttosuperfan, from what they did in the inbox (esp_score) and in the community (community_score).status=superfankeeps the top band. The band is assigned by rank within your newsletter, not by a fixed score, so it is always your own top readers whatever the size of your list.The list comes back highest
total_scorefirst, 100 at a time. Whilepagination.next_cursoris set, send the same request again withcursorset to it.cURLcurl "https://api.usecommune.com/newsletters/example-letter/insights?status=superfan&limit=100" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "subscriber_insight", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "subscriber": { "object": "subscriber", "id": "33445566-7788-4990-a1b2-c3d4e5f60718" }, "total_score": 412, "community_score": 412, "esp_score": 0, "t1_score": 96, "t2_score": 41, "velocity": "rising", "status": "superfan", "share_points": 60, "last_action_at": "2026-08-26T21:04:11Z", "synced_to_esp_at": null }, { "object": "subscriber_insight", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "subscriber": { "object": "subscriber", "id": "55667788-99aa-4bb2-83d4-e5f607182930" }, "total_score": 371, "community_score": 243, "esp_score": 128, "t1_score": 58, "t2_score": 61, "velocity": "steady", "status": "superfan", "share_points": 35, "last_action_at": "2026-08-25T07:48:30Z", "synced_to_esp_at": null } ], "pagination": { "has_more": false, "next_cursor": null } } - 3
Look for a Superfans tag
A tag is a segment of your audience. Look through the live tags for one called
Superfansbefore making one: a name a live tag already has is refused. The list is alphabetical and each tag carriesknown_subscriber_count, the people currently subscribed who hold it.The first time the job runs there is none, as here. Every run after that finds it, so keep its
id.cURLcurl "https://api.usecommune.com/newsletters/example-letter/tags?limit=100" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "tag", "id": "aa11bb22-cc33-4d44-8e55-ff6677889900", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "name": "Founding member", "known_subscriber_count": 214, "retired": false, "retired_at": null, "created_at": "2025-06-11T08:45:00Z" }, { "object": "tag", "id": "bb22cc33-dd44-4e55-9f66-001122334455", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "name": "Paid supporters", "known_subscriber_count": 1180, "retired": false, "retired_at": null, "created_at": "2025-09-02T14:12:30Z" } ], "pagination": { "has_more": false, "next_cursor": null } } - 4
Create it, once
A new tag starts empty: nobody holds it and no article is addressed to it, so creating one gives nobody access to anything. It is a write, so it needs an
Idempotency-Key. Send the same key again to retry safely; Commune replays the first answer instead of making a second tag. A different request under a key you already used answers409.If a tag named
Superfansalready exists, this answers422rather than making a duplicate, which is why step 3 comes first.cURLcurl -X POST "https://api.usecommune.com/newsletters/example-letter/tags" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: 4e9a1c37-8b2d-4f60-a5e1-7c3d9b0f2a68" \ -H "Content-Type: application/json" \ -d '{ "name": "Superfans" }'Response 201{ "object": "tag", "id": "cc33dd44-ee55-4f66-8a77-112233445566", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "name": "Superfans", "known_subscriber_count": 0, "retired": false, "retired_at": null, "created_at": "2026-09-29T09:15:00Z" } - 5
Give each superfan the tag
Send this week's superfans to the tag in one request: up to 500 subscriber ids in
subscribers, with oneIdempotency-Keyfor the batch. More than 500 superfans means one request per 500, each with its own key. A retry with the same key and the same list replays the first answer and does nothing twice.Every id comes back in exactly one of three lists.
taggedholds the tag now and did not before.already_taggedheld it already, which is a success: nothing changes for them, so the weekly job can send the whole list every time.not_foundis not a subscriber of this newsletter, and nothing is written for them. Retrying those will not help; check where the ids came from (a superfan id from step 2 should never land there).tagis the tag as it stands after the write, soknown_subscriber_countalready includes the new holders. A retired tag answers422and tags nobody.A tag decides what a person can read, not only who receives what. Once an article is addressed to Superfans, holding the tag is what lets somebody read it, on the web as well as in the inbox, including issues sent before they got the tag. That is also why this job only adds: taking the tag off a reader who slipped out of the band would take away what you already gave them. Remove it deliberately, with
DELETE /subscribers/{subscriber}/tags/{tag}, if that is what you want.cURLcurl -X POST "https://api.usecommune.com/tags/cc33dd44-ee55-4f66-8a77-112233445566/subscribers" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: b6f0d2a4-3c1e-4f7a-9d85-6e2c7a1b0f93" \ -H "Content-Type: application/json" \ -d '{ "subscribers": [ "33445566-7788-4990-a1b2-c3d4e5f60718", "44556677-8899-4aa1-b2c3-d4e5f6071829", "9f1e2d3c-4b5a-4c6d-8e7f-0a1b2c3d4e5f" ] }'Response 200{ "object": "tag_assignment", "tag": { "object": "tag", "id": "cc33dd44-ee55-4f66-8a77-112233445566", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "name": "Superfans", "known_subscriber_count": 38, "retired": false, "retired_at": null, "created_at": "2026-09-29T09:15:00Z" }, "tagged": [ "33445566-7788-4990-a1b2-c3d4e5f60718" ], "already_tagged": [ "44556677-8899-4aa1-b2c3-d4e5f6071829" ], "not_found": [ "9f1e2d3c-4b5a-4c6d-8e7f-0a1b2c3d4e5f" ] } - 6
See who holds it
The segment as it stands, with the address of each reader in it.
tagnarrows the subscribers to the ones holding it, and the list only includes people stillsubscribedunless you ask for other states. This is also the export: a CSV of these addresses is what most other tools will take.cURLcurl "https://api.usecommune.com/newsletters/example-letter/subscribers?tag=cc33dd44-ee55-4f66-8a77-112233445566&limit=100" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "subscriber", "id": "33445566-7788-4990-a1b2-c3d4e5f60718", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "user": { "object": "user", "id": "usr_9Tb3Wk6Rn2" }, "email": "[email protected]", "status": "subscribed", "source": "commune", "tags": [ { "object": "tag", "id": "cc33dd44-ee55-4f66-8a77-112233445566" } ], "created_at": "2025-05-14T17:22:08Z", "synced_at": null } ], "pagination": { "has_more": false, "next_cursor": null } } - 7
Hear about every new superfan
You'll need
- An endpoint registered under Webhooks in your Commune dashboard, subscribed to
subscriber.tagged, and its signing secret.
Rather than export the list, let Commune tell your other tools as it changes. Every time a tag is put on or taken off a subscriber, Commune posts
subscriber.taggedto your endpoint, withdirectionset toassignedorremoved. One event arrives per subscriber intagged; the ones inalready_taggedandnot_foundsend nothing, so a weekly job that re-tags everyone only produces events for the new superfans.A change your job made through the API arrives with
actornaming your key andidempotency_keyset to the key you sent with the step 5 batch (every event from one batch carries the same key), so you can tell your own writes from a tag applied by hand in Commune. Delivery order is not guaranteed: order byoccurred_at, and dedupe onid.Commune posts
subscriber.taggedto your endpoint:event subscriber.tagged{ "id": "018f2a91-bbbb-7000-8000-00000000000b", "type": "subscriber.tagged", "api_version": "2026-08-26", "occurred_at": "2026-09-29T09:15:04Z", "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411", "actor": { "type": "api_key", "id": "c1f4b9a7-5d2e-4a11-8f36-0b7e2d4c9a83", "label": "Weekly superfans job" }, "idempotency_key": "b6f0d2a4-3c1e-4f7a-9d85-6e2c7a1b0f93", "data": { "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718", "email": "[email protected]", "tag_id": "cc33dd44-ee55-4f66-8a77-112233445566", "tag_name": "Superfans", "direction": "assigned" } } - An endpoint registered under Webhooks in your Commune dashboard, subscribed to
- 8
Grant the perk where it lives
The handler that receives it. It checks the signature against the raw body before trusting anything, answers
200straight away (a slow answer is retried), and only then does the work. Here the work is a POST to whatever grants the reward: a role in your community server, a field in your CRM, a discount code in your shop. Route ontag_idrather than the name, which a creator can rename.The
seenset stands in for a table in your own database; a set in memory forgets on every restart. The webhooks guide covers the signature and retries in full.superfans-handler.mjsimport { createServer } from "node:http"; import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.COMMUNE_WEBHOOK_SECRET; // "whsec_..." const SUPERFANS_TAG_ID = process.env.SUPERFANS_TAG_ID; const seen = new Set(); // Use a table in production: this forgets on restart. function verify(raw, header, timestamp) { if (!header || !timestamp) return false; // Signed: the timestamp in Unix seconds, a dot, then the raw body. const seconds = Math.floor(Date.parse(timestamp) / 1000); if (!Number.isFinite(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false; const expected = Buffer.from( createHmac("sha256", SECRET).update(seconds + ".").update(raw).digest("hex"), ); // "v0=" then one or more digests, comma separated while a secret rotates. return header .replace(/^v0=/, "") .split(",") .some((candidate) => { const presented = Buffer.from(candidate.trim()); return presented.length === expected.length && timingSafeEqual(presented, expected); }); } async function handle(event) { if (event.type !== "subscriber.tagged" || seen.has(event.id)) return; seen.add(event.id); const { tag_id, email, direction } = event.data; if (tag_id !== SUPERFANS_TAG_ID) return; // Swap this for the tool where the reward happens. await fetch(process.env.PERKS_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, action: direction === "assigned" ? "grant" : "revoke" }), }); } createServer((req, res) => { 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"])) { res.writeHead(401).end(); return; } res.writeHead(200).end(); // Acknowledge first, work afterwards. handle(JSON.parse(raw)).catch((error) => console.error(error)); }); }).listen(process.env.PORT ?? 3000); - 9
Send them something nobody else gets
A thank-you note, an early look at the next issue, a subscriber-only essay. An article addressed to a tag goes only to the subscribers holding it, and only they can read it on the web afterwards.
The API does not address an article to a tag. Creating an article takes a title, preview text, cover, slug and body, and sending one takes no audience, so there is no field to set. Write the issue wherever you like (through the API, as a draft, if you want), then open it in the Commune editor, choose Superfans as its audience and send it from there.
What the API does give you is the record.
tagon the article list returns only the articles addressed to that segment, so you can see what your superfans have received and read how each one did.cURLcurl "https://api.usecommune.com/newsletters/example-letter/articles?tag=cc33dd44-ee55-4f66-8a77-112233445566&status=sent" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "article", "id": "6e2a9c14-7b35-4f08-a1d9-3c5e7f9b2d40", "short_id": "Hx4Lp9Qa", "slug": "an-early-look-at-october", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "Thank you, and an early look at October", "preview_text": "You read everything. Here is next month before anyone else.", "image_url": null, "external_url": null, "status": "sent", "is_imported": false, "posted_at": "2026-10-02T09:03:40Z", "scheduled_for": null, "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": null, "stats": { "likes": 9, "comments": 4, "highlights": 6 }, "created_at": "2026-09-30T16:40:12Z", "updated_at": "2026-10-02T09:03:40Z" } ], "pagination": { "has_more": false, "next_cursor": null } } - 10
Run it every Monday
Scores move daily; a weekly pass is enough to catch new superfans while their enthusiasm is fresh. Any scheduler works. This GitHub Actions workflow runs the script below at 08:00 UTC every Monday with the key kept as a repository secret. Each run tags only the readers new to the band, and your handler hears about each of them.
.github/workflows/superfans.ymlname: Tag superfans on: schedule: - cron: "0 8 * * 1" workflow_dispatch: jobs: run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: node superfans.mjs env: COMMUNE_API_KEY: ${{ secrets.COMMUNE_API_KEY }}
All together
Steps 1 to 5 as one file, with no dependencies. It follows the cursor through every page, creates the tag only when it is missing, and tags this week's superfans 500 at a time, one request per batch with its own Idempotency-Key. A retried request reuses its key, so a retry can never tag anyone twice.
const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
const TAG_NAME = "Superfans";
// A write gets one Idempotency-Key, kept across its retries, so a retry
// replays the first answer instead of making the change twice.
async function api(path, { method = "GET", body, key = method === "GET" ? null : crypto.randomUUID() } = {}) {
const headers = { authorization: `Bearer ${KEY}`, "commune-version": VERSION };
if (key) headers["idempotency-key"] = key;
if (body !== undefined) headers["content-type"] = "application/json";
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429 || (res.status === 409 && res.headers.has("retry-after"))) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
return api(path, { method, body, key });
}
const json = await res.json();
if (!res.ok) {
const { code, message, request_id } = json.error;
throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
}
return json;
}
async function* all(path) {
let cursor = null;
do {
const sep = path.includes("?") ? "&" : "?";
const page = await api(cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
yield* page.data;
cursor = page.pagination.next_cursor;
} while (cursor);
}
// 1. The newsletter to work on. A key can reach several newsletters. COMMUNE_NEWSLETTER picks one by
// handle; without it, a key that reaches exactly one uses that one.
const { data: newsletters } = await api("/newsletters");
const wanted = process.env.COMMUNE_NEWSLETTER;
const newsletter = wanted
? newsletters.find((n) => n.handle === wanted || n.id === wanted)
: newsletters.length === 1 ? newsletters[0] : null;
if (!newsletter) {
throw new Error(
wanted
? "This key does not reach " + wanted + "."
: "This key reaches " + newsletters.length + " newsletters. Set COMMUNE_NEWSLETTER to one handle.",
);
}
const handle = newsletter.handle;
// 2. This week's superfans.
const superfans = [];
for await (const row of all(`/newsletters/${handle}/insights?status=superfan&limit=100`)) {
superfans.push(row.subscriber.id);
}
// 3 and 4. The Superfans tag, created the first time only.
let tag = null;
for await (const candidate of all(`/newsletters/${handle}/tags?limit=100`)) {
if (candidate.name === TAG_NAME) tag = candidate;
}
if (!tag) {
tag = await api(`/newsletters/${handle}/tags`, { method: "POST", body: { name: TAG_NAME } });
console.log(`Created the ${TAG_NAME} tag: ${tag.id}`);
}
// 5. Tag them, 500 per request. Holders come back in already_tagged, and each
// newly tagged subscriber publishes one subscriber.tagged.
let added = 0;
const missing = [];
for (let i = 0; i < superfans.length; i += 500) {
const result = await api(`/tags/${tag.id}/subscribers`, {
method: "POST",
body: { subscribers: superfans.slice(i, i + 500) },
});
tag = result.tag;
added += result.tagged.length;
missing.push(...result.not_found);
}
if (missing.length) console.warn(`Not subscribers of ${handle}: ${missing.join(", ")}`);
console.log(
`${superfans.length} superfans this week, ${added} new. ${tag.known_subscriber_count} subscribed readers hold ${TAG_NAME}.`,
);Next use cases
See all- API
Publish from wherever you write
Write in your CMS, notes app or repository. Commune sends it.
Read the use case - API
React the moment something happens
A new subscriber, a new reply, an issue sent: start your own workflow in seconds.
Read the use case - API
Bring the conversation to your site
Show each issue's discussion wherever your readers already are.
Read the use case