# Get a pulse on your newsletter in plain words

> Dashboards answer questions you did not ask and make you work out the ones you did. Ask how your newsletter is doing and get a few sentences back: how the list moved, where new readers came from, how the last issue landed and what the community did with it. Or have the same few sentences waiting in Slack every Monday.

How: with your agent (One prompt), or with the API (About 20 minutes for the Monday digest).

What you will have:

- A plain answer to "how is it going?", covering audience, publishing, community and email.
- Where last week's new subscribers came from, and whether the trend is up or down.
- A Monday message in Slack that says all of that in a few lines.

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 for the pulse the way you would ask a colleague, and have it arrive every Monday on its own. Commune's tools read your numbers over the window you name; your agent puts them into sentences.

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, tell me how my Commune newsletter did last week (Monday to Monday).

In eight lines or fewer: the net change in subscribers and where the new ones came from, how my latest article did by email and in the community, and how weekly sign-ups compare with the previous seven weeks.

Leave out numbers that are empty rather than calling them zero.
```

The tools it uses:

- `how_is_my_newsletter_doing`: One snapshot over the window: subscribers on record and the net change, articles sent and scheduled, conversations, replies, highlights and reactions, and the open and click rates of the email Commune sent. The rates are empty for a newsletter Commune does not send. If the connection only reads, the scheduled count comes back empty and the community numbers cover public conversations only.
- `where_are_new_subscribers_coming_from`: The arrivals in the window, split by source, largest first: an import's provider, `csv` for a file, or no source for people who joined through Commune.
- `how_did_my_latest_article_do`: Your newest sent article with its email side (recipients, delivered, opened, clicked, bounced, unsubscribed, each a count of people) and its community side (views, likes, highlights, replies).
- `chart_one_number_over_time`: One number, such as new subscribers, bucketed by week, oldest first. Each bucket counts what happened in it, not a running total, so it is sign-ups per week, not your list size.

What comes back, for example:

**Last week, 25 August to 1 September**

| | Last week |
|---|---|
| Subscribers | +38 net, 2,184 on record |
| New, by source | 43: 31 joined through Commune, 12 from a CSV import |
| Latest issue | "What newsletters get wrong about community" |
| By email | 2,126 reached, 1,206 opened, 311 clicked |
| In the community | 148 likes, 63 highlights, 27 replies |

- Sign-ups were the **best of the last eight weeks**, against an average of about 22.
- 4 people unsubscribed after the latest issue.

Worth asking next:

- "How does this week compare with the one before?"
- "Where did last week's new subscribers come from?"
- "Which article drove the most replies this month?"

Tips:

- Name the window. "Last week", "since 1 July" or "the last 90 days" beats the default (the last 30 days) when you are comparing.
- On a newsletter connected to another email provider, subscribers on record are a floor, not your audience size. Ask your provider for the real total.
- Treat open rates as a direction, not a fact. Mail clients that prefetch images inflate them and ones that block images hide them.
- 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 a message that lands in Slack every Monday morning: last week's numbers, where new readers came from, an eight-week trend and how the latest issue did, in a few plain sentences. Five requests, then the code that writes the message and runs it on a schedule.

Before you start:

- An API key from https://usecommune.com/settings/api-keys with: insights: read, content: read. Export it as `COMMUNE_API_KEY`.
- Node 18 or newer, for the script at the end. Every request also works as the cURL shown, or from the collection.
- 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/newsletter-pulse/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) for the requests that follow, its `name` for the message, and its `esp`: on a `commune` newsletter the subscriber counts are the real ones, while on one connected to another provider they are Commune's partial record.

`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. Read last week's headline numbers

`since` and `until` pin the window to last Monday through this Monday, so the digest describes a whole week and not the last seven days from whenever the job ran. The response echoes the window it resolved in `period_start` and `period_end`.

Four groups come back. `audience` is how the list moved: `net_change` is gained minus lost inside the week, and `by_status` is how everyone on record splits at the end of it. `publishing` counts articles, never emails. `community` is what readers did on Commune. `delivery` has the open and click rates, and is `null` for a newsletter Commune does not send.

**A read-only key counts what a reader can see.** This page's key only reads, so Commune treats it as a reader of the published newsletter. `audience` and `delivery` are the same as for your team, but `publishing.scheduled` is `null` (not zero: you may well have articles queued), `publishing.sent` covers published articles not restricted to a tag, and `community` covers public conversations and their replies only. The discussion under each article is for subscribers, so it is not in these community numbers.

Giving the key `write` in any family makes it count everything, because Commune then treats it as acting for your team. For a Monday digest, keep the key read-only anyway. It lives in a scheduler's secrets and runs unattended, so it should not be able to change your newsletter, and what the read-only view misses is recovered elsewhere: the latest article's discussion is counted in full in step 6, and the audience, sources and trend are unaffected. The digest labels the community line as public conversations so nobody reads it as the whole picture.

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

```sh
curl "https://api.usecommune.com/newsletters/example-letter/stats?since=2026-08-24&until=2026-08-31" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "newsletter_stats",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "period_start": "2026-08-24T00:00:00Z",
  "period_end": "2026-08-31T00:00:00Z",
  "audience": {
    "known_subscribers": 2184,
    "net_change": 38,
    "by_status": {
      "subscribed": 2131,
      "unsubscribed": 37,
      "bounced": 11,
      "complained": 1,
      "pending": 4
    }
  },
  "publishing": {
    "sent": 1,
    "scheduled": null
  },
  "community": {
    "threads": 2,
    "messages": 9,
    "highlights": 71,
    "reactions": 17
  },
  "delivery": {
    "open_rate": 0.569,
    "click_rate": 0.147
  }
}
```

### 3. See where the new subscribers came from

Same window, split by acquisition source, largest first. `source` names the import that brought people in: a provider's slug, or `csv` for a file. `null` is the ordinary value for someone who joined through Commune rather than an import, though it also covers older rows nobody could label, so call it "not from an import" rather than "from Commune".

These are arrivals, not an audience size. A source with no arrivals in the window is left out rather than returned as zero.

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

```sh
curl "https://api.usecommune.com/newsletters/example-letter/growth?since=2026-08-24&until=2026-08-31" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "newsletter_growth",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "period_start": "2026-08-24T00:00:00Z",
  "period_end": "2026-08-31T00:00:00Z",
  "by_source": [
    {
      "source": null,
      "known_subscribers": 31
    },
    {
      "source": "csv",
      "known_subscribers": 12
    }
  ]
}
```

### 4. Get the trend behind the week

One week on its own does not say whether 43 new subscribers is good. Ask for the same number bucketed by `week` over the eight weeks before this Monday. Weeks start on Monday in UTC, every bucket is present (an empty week is `0`), and each `value` counts only that week, so the last bucket is last week.

Compare the last bucket with the average of the ones before it. That one comparison is most of what a chart would tell you.

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

```sh
curl "https://api.usecommune.com/newsletters/example-letter/timeseries?metric=subscribers&interval=week&since=2026-07-06&until=2026-08-31" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "timeseries",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "metric": "subscribers",
  "interval": "week",
  "period_start": "2026-07-06T00:00:00Z",
  "period_end": "2026-08-31T00:00:00Z",
  "buckets": [
    {
      "ts": "2026-07-06T00:00:00Z",
      "value": 19
    },
    {
      "ts": "2026-07-13T00:00:00Z",
      "value": 24
    },
    {
      "ts": "2026-07-20T00:00:00Z",
      "value": 17
    },
    {
      "ts": "2026-07-27T00:00:00Z",
      "value": 22
    },
    {
      "ts": "2026-08-03T00:00:00Z",
      "value": 26
    },
    {
      "ts": "2026-08-10T00:00:00Z",
      "value": 21
    },
    {
      "ts": "2026-08-17T00:00:00Z",
      "value": 31
    },
    {
      "ts": "2026-08-24T00:00:00Z",
      "value": 43
    }
  ]
}
```

### 5. Find the latest issue

`status=sent` with `limit=1` is the newest article that actually went out. Keep its `id` for the next request and its `title` for the message. An empty `data` means nothing has been sent yet, and the digest simply leaves the section out.

`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=1" \
  -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"
  }
}
```

### 6. See how it did

The article measured on both sides. `email` counts recipients, not events: `opened` is people who opened at least once, and the rates a provider quotes divide by `delivered`, not `recipients`. It is `null` for an article your provider sent, because the provider kept those numbers.

`community` is what readers did on Commune, counted when you ask, so it keeps growing after the send. `participants` next to `thread_messages` tells you whether 27 replies were a conversation among 19 people or an argument between two.

`GET /articles/{article}/stats` ([reference](https://api-reference.usecommune.dev/operation/operation-getarticlestats))

```sh
curl "https://api.usecommune.com/articles/4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f/stats" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "article_stats",
  "article": {
    "object": "article",
    "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f"
  },
  "email": {
    "recipients": 2126,
    "delivered": 2118,
    "opened": 1206,
    "clicked": 311,
    "bounced": 8,
    "unsubscribed": 4
  },
  "community": {
    "views": 1580,
    "likes": 148,
    "saves": 41,
    "highlights": 63,
    "thread_messages": 27,
    "participants": 19
  }
}
```

### 7. Say it in plain words and post it

You will need:

- A [Slack incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) URL for the channel you want the digest in.

Turn the four answers into a handful of sentences, one per question you would actually ask, and leave out anything that is `null` rather than printing a zero it does not mean. Then post it to a [Slack incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/); swap the last call for email, Discord or wherever your team reads on Monday.

pulse.mjs:

```js
// stats, growth, series, latest, performance: steps 2 to 6.
const pct = (x) => `${Math.round(x * 100)}%`;
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
const { audience, publishing, community, delivery } = stats;

const weeks = series.buckets.map((b) => b.value);
const thisWeek = weeks.at(-1);
const before = weeks.slice(0, -1);
const average = before.length ? before.reduce((a, b) => a + b, 0) / before.length : 0;

const lines = [
  `*${newsletter.name}, week of ${stats.period_start.slice(0, 10)}*`,
  `Audience: ${signed(audience.net_change)} this week, ${audience.by_status.subscribed} mailable of ${audience.known_subscribers} on record.`,
  `New subscribers: ${thisWeek}, against an average of ${average.toFixed(0)} over the previous ${before.length} weeks.`,
  `Where they came from: ${growth.by_source.map((s) => `${s.known_subscribers} ${s.source ?? "not from an import"}`).join(", ") || "no arrivals recorded"}.`,
  // scheduled is null for a key that only reads: it then counts public conversations only.
  `${publishing.scheduled === null ? "Public conversations" : "Community"}: ${community.threads} conversations, ${community.messages} replies, ${community.highlights} highlights, ${community.reactions} reactions.`,
  `Published: ${publishing.sent} ${publishing.sent === 1 ? "article" : "articles"}${publishing.scheduled === null ? "" : `, ${publishing.scheduled} scheduled`}.`,
];
if (delivery?.open_rate != null) {
  lines.push(`Email: ${pct(delivery.open_rate)} opened, ${pct(delivery.click_rate)} clicked.`);
}
if (latest) {
  const { email, community: c } = performance;
  lines.push(
    `Latest issue, "${latest.title}": ` +
      (email ? `${email.opened} of ${email.delivered} opened, ${email.clicked} clicked, ${email.unsubscribed} unsubscribed. ` : "") +
      `${c.views} views, ${c.likes} likes, ${c.highlights} highlights, ${c.thread_messages} replies from ${c.participants} people.`,
  );
}

await fetch(process.env.SLACK_WEBHOOK_URL, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ text: lines.join("\n") }),
});
```

### 8. Run it every Monday

Any scheduler works. This GitHub Actions workflow runs the script below at 08:00 UTC every Monday, with the key and the Slack URL kept as repository secrets. The script works out last week from the day it runs, so a late or manual run still reports the same whole week.

.github/workflows/pulse.yml:

```yaml
name: Newsletter pulse
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 pulse.mjs
        env:
          COMMUNE_API_KEY: ${{ secrets.COMMUNE_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          COMMUNE_NEWSLETTER: ${{ vars.COMMUNE_NEWSLETTER }}
```

### All together

Steps 1 to 7 as one file, with no dependencies. It works out last week's Monday-to-Monday window in UTC, waits out a `429` instead of failing on it, and reports errors with the request id Commune gave them.

```js
const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;

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);
  }
  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;
}

// This Monday at 00:00 UTC, and the Mondays before it.
const DAY = 24 * 60 * 60 * 1000;
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const monday = new Date(today.getTime() - ((today.getUTCDay() + 6) % 7) * DAY);
const date = (d) => d.toISOString().slice(0, 10);
const until = date(monday);
const since = date(new Date(monday.getTime() - 7 * DAY));
const trendSince = date(new Date(monday.getTime() - 8 * 7 * DAY));

// 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 base = `/newsletters/${newsletter.handle}`;
const week = `since=${since}&until=${until}`;

// 2 to 4. Last week's numbers, where arrivals came from, and the trend.
const [stats, growth, series] = await Promise.all([
  api(`${base}/stats?${week}`),
  api(`${base}/growth?${week}`),
  api(`${base}/timeseries?metric=subscribers&interval=week&since=${trendSince}&until=${until}`),
]);

// 5 and 6. The latest issue and how it did, if anything has been sent.
const { data: [latest] } = await api(`${base}/articles?status=sent&limit=1`);
const performance = latest ? await api(`/articles/${latest.id}/stats`) : null;

// 7. In plain words, to Slack.
const pct = (x) => `${Math.round(x * 100)}%`;
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
const { audience, publishing, community, delivery } = stats;

const weeks = series.buckets.map((b) => b.value);
const thisWeek = weeks.at(-1) ?? 0;
const before = weeks.slice(0, -1);
const average = before.length ? before.reduce((a, b) => a + b, 0) / before.length : 0;

const lines = [
  `*${newsletter.name}, week of ${since}*`,
  `Audience: ${signed(audience.net_change)} this week, ${audience.by_status.subscribed} mailable of ${audience.known_subscribers} on record.`,
  `New subscribers: ${thisWeek}, against an average of ${average.toFixed(0)} over the previous ${before.length} weeks.`,
  `Where they came from: ${growth.by_source.map((s) => `${s.known_subscribers} ${s.source ?? "not from an import"}`).join(", ") || "no arrivals recorded"}.`,
  // scheduled is null for a key that only reads: it then counts public conversations only.
  `${publishing.scheduled === null ? "Public conversations" : "Community"}: ${community.threads} conversations, ${community.messages} replies, ${community.highlights} highlights, ${community.reactions} reactions.`,
  `Published: ${publishing.sent} ${publishing.sent === 1 ? "article" : "articles"}${publishing.scheduled === null ? "" : `, ${publishing.scheduled} scheduled`}.`,
];
if (newsletter.esp !== "commune") {
  lines.push("Subscriber counts are Commune's record of your provider's list, so read them as a floor.");
}
if (delivery?.open_rate != null) {
  lines.push(`Email: ${pct(delivery.open_rate)} opened, ${pct(delivery.click_rate)} clicked.`);
}
if (latest) {
  const { email, community: c } = performance;
  lines.push(
    `Latest issue, "${latest.title}": ` +
      (email ? `${email.opened} of ${email.delivered} opened, ${email.clicked} clicked, ${email.unsubscribed} unsubscribed. ` : "") +
      `${c.views} views, ${c.likes} likes, ${c.highlights} highlights, ${c.thread_messages} replies from ${c.participants} people.`,
  );
}

await fetch(process.env.SLACK_WEBHOOK_URL, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ text: lines.join("\n") }),
});
```
