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

How: with your agent (One prompt, then a few minutes tagging), or with the API (About 30 minutes for a weekly job).

What you will have:

- Your superfans named, with the address to reach each one and what they have been doing lately.
- A Superfans segment in Commune, brought up to date every week.
- An issue only your superfans receive.

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

There are 2 ways to do this. Pick one: with your agent, or with the API.

## With your agent

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 the MCP server at `https://api.usecommune.com/mcp` to your agent and approve the consent screen ([how](https://usecommune.dev/guides/mcp)). Then ask:

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

The tools it uses:

- `find_superfans`: The 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_read`: Looks 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_doing`: The 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, for example:

**12 superfans this week**, strongest first (top 3):

| Reader | Score | Moving | Last active |
|---|---|---|---|
| reader@example.com | 412 | Up: 96, from 41 | 26 Aug |
| mira@example.com | 355 | Steady | 25 Aug |
| avery@example.com | 301 | Down: 18, from 60 | 20 Aug |

- **New since last week:** mira@example.com and one more.
- **Worth a personal note:** avery@example.com is still a superfan but cooling off.

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](https://usecommune.com/dashboard/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](https://usecommune.com/dashboard/articles) 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.

## With the API

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 from https://usecommune.com/settings/api-keys with: insights: read, audience: write, content: read. Export it as `COMMUNE_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 `402` otherwise; `GET /newsletters/{newsletter}/entitlements` (with `settings: read`) tells you in advance.
- 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/reward-your-superfans/collection.json

### 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` (or `id`, either works in a path): the requests below name it.

`GET /newsletters` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletters))

```sh
curl "https://api.usecommune.com/newsletters" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "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 `dormant` to `superfan`, from what they did in the inbox (`esp_score`) and in the community (`community_score`). `status=superfan` keeps 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_score` first, 100 at a time. While `pagination.next_cursor` is set, send the same request again with `cursor` set to it.

`GET /newsletters/{newsletter}/insights` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterinsights))

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

```json
{
  "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 `Superfans` before making one: a name a live tag already has is refused. The list is alphabetical and each tag carries `known_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`.

`GET /newsletters/{newsletter}/tags` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewslettertags))

```sh
curl "https://api.usecommune.com/newsletters/example-letter/tags?limit=100" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

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

If a tag named `Superfans` already exists, this answers `422` rather than making a duplicate, which is why step 3 comes first.

`POST /newsletters/{newsletter}/tags` ([reference](https://api-reference.usecommune.dev/operation/operation-createtag))

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

```json
{
  "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 one `Idempotency-Key` for 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. `tagged` holds the tag now and did not before. `already_tagged` held it already, which is a success: nothing changes for them, so the weekly job can send the whole list every time. `not_found` is 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). `tag` is the tag as it stands after the write, so `known_subscriber_count` already includes the new holders. A retired tag answers `422` and 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.

`POST /tags/{tag}/subscribers` ([reference](https://api-reference.usecommune.dev/operation/operation-addtagsubscribers))

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

```json
{
  "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. `tag` narrows the subscribers to the ones holding it, and the list only includes people still `subscribed` unless you ask for other states. This is also the export: a CSV of these addresses is what most other tools will take.

`GET /newsletters/{newsletter}/subscribers` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewslettersubscribers))

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

```json
{
  "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": "reader@example.com",
      "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 will need:

- An endpoint registered under [Webhooks](https://usecommune.com/dashboard/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.tagged` to your endpoint, with `direction` set to `assigned` or `removed`. One event arrives per subscriber in `tagged`; the ones in `already_tagged` and `not_found` send 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 `actor` naming your key and `idempotency_key` set 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 by `occurred_at`, and dedupe on `id`.

Event `subscriber.tagged`, as Commune posts it to your endpoint:

```json
{
  "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": "reader@example.com",
    "tag_id": "cc33dd44-ee55-4f66-8a77-112233445566",
    "tag_name": "Superfans",
    "direction": "assigned"
  }
}
```

### 8. Grant the perk where it lives

The handler that receives it. It checks the signature against the raw body before trusting anything, answers `200` straight 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 on `tag_id` rather than the name, which a creator can rename.

The `seen` set 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.mjs:

```js
import { 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](https://usecommune.com/dashboard/articles), choose **Superfans** as its audience and send it from there.

What the API does give you is the record. `tag` on 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.

`GET /newsletters/{newsletter}/articles` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterarticles))

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

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

```yaml
name: 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.

```js
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}.`,
);
```
