Commune

Getting started with the API

Ten minutes from no credential to a response you can read. Mint a key, make a call, understand the envelope it comes back in, make a change safely, and pin the contract version before you write anything against it.

On this page(9)

Mint a key#

A key belongs to you rather than to a newsletter, so you mint it in your own account, at Settings, API keys.

You answer four questions when you create one. The first is a name, for telling four keys apart once their secrets are gone.

The second is which newsletters it reaches. Pick them one by one, or take every newsletter you run today, or take every newsletter you will ever run. The last two differ only in the future, which is why they are separate choices: a credential that quietly gains a newsletter you start next year is convenient and worth deciding on purpose.

The third is what it may do, on each of them. Six families, each none, read or write:

  • content is articles, threads, messages and highlights. read is the newsletter as published; write also reaches drafts and scheduled articles.
  • audience is subscribers, tags and the community roster. This is the family that carries email addresses.
  • sending is sends, senders and delivery attempts. write here puts real mail in front of real people.
  • insights is engagement events and metrics.
  • settingsis the newsletter’s configuration, its website domains, its team and its credentials.
  • webhooks is where Commune sends events.

There is no hierarchy across families. content: write is no claim at all on audience, which is the whole reason there are six of them rather than one switch. An operation that needs more than the key holds answers 403 and names the family and level it wanted, in the same words the form used.

The fourth is when it expires, and it is required. Ninety days by default, any date up to a year away, or never, which the form offers and tells you why it would rather you did not choose.

The secret is shown once, when the key is created, and is unrecoverable after that. It looks like cmn_sk_ followed by a long random string. Put it wherever you keep secrets before closing the dialog.

A key is for your own code. If you are building something other people will connect to their newsletters, they should never paste you a key: use OAuth, as Build an integration shows end to end.

A key granted the audience reads email addresses

That is the point of that family, and it is also the reason to treat such a key like a password rather than like a configuration value. Every subscriber read a key makes is recorded against that key, the reads are charged to a tighter rate limit than everything else, and revoking a key takes effect on its next request. Do not commit one, and do not paste one into a page you might screenshot.

What a key holds is not the last word

A grant is a ceiling, not an authority. What a key can actually do is what you granted it, narrowed by what youcan do on each newsletter at the moment of the request, recomputed every time. Leave a newsletter’s team and every key of yours stops reaching it on its very next call, with nothing to revoke. An owner or admin of a newsletter can also cut a key off that newsletter without touching the key itself, and your settings page will say so beside the entry.

Make the first call#

GET /newsletters is the call to start with, because this collection is reduced to what the key can see. So it returns the newsletters you granted it and nothing else, and it tells you the handle and id that every other operation wants.

shell
curl -i https://api.usecommune.com/newsletters \
  -H "Authorization: Bearer cmn_sk_..."

-i is there on purpose. Three of the response headers are part of the contract and you will want to have seen them once before you need them.

response
HTTP/2 200
content-type: application/json; charset=utf-8
commune-version: 2026-08-26
commune-request-id: req_01j9c8h1q7m3n4p5r6s7t8u9v0
ratelimit-limit: 600
ratelimit-remaining: 599
ratelimit-reset: 60

{
  "object": "list",
  "data": [
    {
      "object": "newsletter",
      "id": "9a4c1c6e-0f2b-4f47-9d3f-6d1b1a2c3d4e",
      "handle": "the-weekly",
      "name": "The Weekly",
      "esp": "commune",
      "created_at": "2026-02-11T09:14:03Z"
    }
  ],
  "pagination": { "has_more": false, "next_cursor": null }
}

Reading the response#

Every collection comes back in the same envelope: object is always "list", data is the page, and pagination holds the cursor state. Every object inside carries its own object field too, so a response is self describing and you can branch on a type without tracking which call produced it.

Three response headers are worth wiring into your client now:

  • Commune-Version is the contract version this request actually resolved to. It is on every response, success and failure alike.
  • Commune-Request-Id identifies the request. It is the same value that appears as request_id in an error body. Log it, and quote it when you ask us about something.
  • RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset report whichever budget is closest to exhaustion, with RateLimit-Reset in seconds from now.

When something fails#

Every non-2xx response from every operation has the same shape, so you can branch on error.code without knowing which call produced it. Treat an unrecognised code as a generic failure of its status class, because new ones can be added. Every error carries a docs_url pointing at the page for its code, which says what it means, why it happens and how to fix it:

  • bad_request 400. Something in the request itself is wrong: a query parameter, a header or the body. Change the request and send it again.
  • invalid_version 400. The `Commune-Version` header names a contract version this API does not serve. Send one from `allowed_values`, or leave the header out.
  • unauthorized 401. The request carried no credential the API accepts: none at all, a malformed one, or one that is unknown, revoked or expired.
  • forbidden 403. The credential is valid but cannot act on the newsletter you addressed, or on the key you tried to revoke.
  • insufficient_scope 403. The credential reaches the newsletter but does not hold the permission this operation, filter or expansion needs.
  • payment_required 402. The credential may do this, but the newsletter's Commune plan does not include it. Reads outside insights stay free.
  • not_found 404. Nothing this credential may see exists at that identifier. It may not exist at all, or your credential may not be allowed to know it does.
  • conflict 409. The `Idempotency-Key` on this write was already used for a different request, or a request with the same key has not finished.
  • unprocessable 422. The request is well formed, and the current state of what it addresses refuses it. The message says what is in the way.
  • not_commune_newsletter 422. Articles can only be created or edited on a newsletter Commune publishes itself, one whose `esp` is `commune`.
  • rate_limited 429. A rate limit budget or a daily send limit is spent. Wait `Retry-After` seconds; the message names which limit it was.
  • internal_error 500. Something failed inside Commune. Retry with backoff, reusing the same `Idempotency-Key` for a write, and quote the request id if it persists.
  • service_unavailable 503. A service this one operation depends on did not answer. The rest of the API is unaffected; retry this operation after `Retry-After`.

When exactly one parameter is at fault it is named in param, and when that parameter accepts a finite set the whole set comes back in allowed_values. That array is deliberately redundant with the sentence in message: the array is what a program branches on and the sentence is what a model reads, and either way you can correct the request from the response without opening a reference page.

Do not branch on message. It is written for a developer reading a log and it can change.

Paging through a collection#

Cursors, never offsets. A response carries pagination.next_cursor; pass it back as ?cursor= to get the following page, and stop when has_more is false. A cursor is opaque, is only valid for the same operation with the same filters, and is not a durable identifier, so do not store one and come back to it tomorrow.

shell
curl "https://api.usecommune.com/newsletters/the-weekly/articles?limit=100" \
  -H "Authorization: Bearer cmn_sk_..."

# then, while pagination.has_more is true:
curl "https://api.usecommune.com/newsletters/the-weekly/articles?limit=100&cursor=<next_cursor>" \
  -H "Authorization: Bearer cmn_sk_..."

?limit= is a page size, between 1 and 100, defaulting to 20. Getting fewer items than you asked for does not mean the collection is exhausted. Only an absent next_cursor means that.

Making a change#

An operation that changes something (a POST, a PATCH or a DELETE) needs write in its own family, and one more thing: an Idempotency-Key header. Choose one value per change you mean to make, and send the same value again if you have to retry. Commune makes the change once and answers the retry with the first answer, marked Idempotent-Replay: true, so a timeout is never a reason to wonder whether an article was drafted twice.

shell
curl -X POST https://api.usecommune.com/newsletters/the-weekly/articles \
  -H "Authorization: Bearer cmn_sk_..." \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: 7c1e2a9d-4b3f-4e8a-9d61-2f5b0c3e8a14" \
  -H "Content-Type: application/json" \
  -d '{"title": "What we learned in March"}'

A key is remembered for 24 hours, per credential. Reusing one for a different request answers 409 rather than replaying the wrong answer. A request refused with a 4xx changed nothing, so its key is free to use again once you have fixed it.

Most changes can be taken back. Sending an article to the list cannot: it emails real people, and a key holding sending: write can do it. Send yourself a test copy first: POST /articles/{article}/test-send with no body sends one to you.

Pin the contract version#

This is the part that costs people later, so it is worth five minutes now. There is no version segment in the URL. A request selects a contract version with the Commune-Version header, and the value is a release date:

shell
curl https://api.usecommune.com/newsletters \
  -H "Authorization: Bearer cmn_sk_..." \
  -H "Commune-Version: 2026-08-26"

If you omit the header, you are still pinned. A request with no Commune-Version is served the version that was current when its API key was issued. That is a deliberate default: an integration written last year keeps working when a new contract ships, instead of changing shape underneath it. But it means two keys in the same codebase can be answering from two different contracts, and nothing in your code would say so.

So send the header. Explicitly, everywhere, from the first request.

An unknown value answers 400 with invalid_version, rather than quietly serving the nearest version it does have. A failed build is cheaper than an integration that believes it is pinned and is not.

400 Bad Request
{
  "error": {
    "code": "invalid_version",
    "message": "Unsupported value \"2025-01-01\" for the Commune-Version header. Supported versions: 2026-08-26. Omit the header to use the version your key was issued against.",
    "param": "Commune-Version",
    "allowed_values": ["2026-08-26"],
    "request_id": "req_01j9c8h1q7m3n4p5r6s7t8u9v0"
  }
}

To find out whether there is a newer contract to move to, ask GET /status, which is unauthenticated and reports the newest version the service serves. Compare it against the Commune-Version you are getting back on your own responses.

shell
curl https://api.usecommune.com/status

When those two differ, the changelog says what moving would cost, change by change, and which of them break a caller.

Rate limits#

Three budgets, counted against the key rather than against an address. The general budget counts every request. Two tighter ones count only some: the audience budget counts the operations that return subscriber or recipient email addresses, and the write budget counts the ones that change something. Those are charged to their own budget and to the general one, and have to pass both. Being refused by one of the narrow budgets still leaves the rest of the API callable, which is why they are separate counters rather than one smaller number.

A 429 carries Retry-After, and its message names the budget that refused. The headers on an ordinary response can only describe one budget, so if you want the whole picture, ask GET /rate-limit, which reports every budget at once.

Where to go next#

To see a whole job done end to end, read the use cases: winning back readers who are drifting away, publishing from wherever you write, reacting to events as they happen, and more. Each one is a sequence of real requests you can run or import into an API client.

The reference has all 63 operations with every field and every error, and the contract itself is published at openapi.yaml if you would rather generate a client than read one.